Inspiration
Agents hit a wall the moment they need something only a human can give them: a decision, a missing fact, a correction, a filled-in form. Today that means Slack messages nobody structures, a human reply the agent has to re-parse, or the agent just guessing. We wanted a primitive for this: an MCP tool an agent calls to ask a human anything, typed, and get a typed answer back, with Slack as the interface people already live in.
Try it yourself
Judges are Members of the demo sandbox; the shared demo token and copy-paste setup for Claude Code, Codex CLI, Claude Desktop, and Cursor are in the pinned walkthrough message in #hr-demo. Three paths:
- From Claude Code (or any MCP client):
claude mcp add --transport http handraise https://handraise.hack.zmvp.dev/mcp --header "Authorization: Bearer 237586e727f457fdf8c1672e3681ec34", then ask Claude to request an approval from your @name. The call blocks, a card lands in your Slack DMs, you answer it, the agent resumes with the typed verdict. - No terminal: the https://handraise.hack.zmvp.dev/demo page (linked with the token prefilled in the walkthrough message) triggers a live approval or a multi-field form from the browser and polls the resolution.
- The ledger: open the Handraise assistant in Slack and use its suggested prompts over the seeded request history.
What it does
Handraise runs on two real Slack surfaces at once. The product core is an MCP tool, request_feedback, that any agent (Claude Code, a CI pipeline, another Slack app) can call. The ledger side of it runs as a genuine Bolt Assistant thread: setStatus while it queries, setSuggestedPrompts for the common questions, and an intent router that answers from Postgres, not from the model's memory.
An agent calls request_feedback with a type (choice including multi-select, approval, diff, or input as freetext or a schema-backed form), Handraise creates a request, posts a native Slack card to the assignee, and holds the MCP call open until someone responds. The response comes back typed: chosen option(s), an approve/reject with optional comment, a diff verdict, free text, or validated form fields. The tool listing advertises the full per-type payload schemas with a worked example, so a calling agent gets the shape right on the first try, and the description steers multi-question asks into one form instead of a spray of parallel requests.
Each type has its own Block Kit rendering: choice renders a button row, a static select, or an option-group select by option count (checkboxes when multi-select); approval uses native confirm dialogs and a required-comment modal when the caller demands one; diff renders a fenced diff block with a size budget, falling back to a snippet upload for large diffs; freeform input uses an in-message field or a modal for longer answers; forms are always modals, paginated past six fields, with Slack-side validation errors. Cards update in place to a resolved state that keeps the question, the context, and the answer visible, so the card itself is a self-documenting receipt, attributed to the requesting agent (self-identified via the schema, or derived from the caller's User-Agent).
The hold itself adapts to the client. When the MCP client supplies a progress token, the server holds the call for the full timeout, sending progress notifications as keepalives so neither the ingress proxy nor the client gives up. Without one, the call caps out early and returns {status: "pending", request_id, next_step}, where next_step is a literal instruction the agent follows to keep waiting via the companion tool fetch_response(request_id, wait_s). A timeout_s of 0 is the explicit fire-and-forget mode.
Multi-party requests support assignees[] with all, quorum(n), or first resolution: one shared card, votes tracked per person, double-votes rejected. The resolved response carries a deterministic tally, a per-person breakdown, and an LLM-written summary of the comments. Requests can carry an expires_s deadline; a runtime sweep moves overdue requests to expired and settles any waiting caller with that status.
Every request and response lands in a Postgres ledger, browsable from App Home and queryable through that assistant thread ("what did we approve this week?", "what's still blocked on Maria?", or both in one compound question, each half answered from its own query). App Home is not just a list: every row opens a full detail modal, and pending requests waiting on you carry a Respond button that opens the typed response inputs right there, going through the exact same resolution path as a card tap. Assistant replies render request ids as permalinks to their cards and user ids as real @mentions. The phrasing goes through the Anthropic Messages API over the rows the deterministic query layer already selected, with a template fallback when no API key is configured; the numbers are never something the LLM invents.
The demo page (path 2 above) also dispatches a real GitHub Actions workflow that blocks on the packaged action uses: zerotomvp/handraise-gate@v1 as a CI gate and visibly resumes on the verdict, with no agent or LLM in the loop: the action is about 80 lines on the official MCP SDK, published from zerotomvp/handraise-gate and consumed by zerotomvp/handraise-demo. Both paths hit real infrastructure, not a mock; request creation (MCP endpoint and demo API alike) is gated by a bearer token.
How we built it
TypeScript throughout, on Bolt for the Slack side and the official MCP TypeScript SDK for the server, hosted over streamable HTTP (which Claude Code and other MCP clients speak directly). The server is stateless over that transport: the store is the state, so any client or process can fetch_response a request another one created, and a graceful shutdown settles in-flight held calls as pending instead of dropping them. Zod validates every boundary: MCP input, Slack payloads, and LLM output. The request lifecycle (pending, resolved, expired, cancelled) is a pure state machine in src/core/, independent of any transport, with its own test suite covering timeout and expiry races. Block Kit builders live in src/blocks/ as typed functions, not inline JSON in handlers. Postgres (via Drizzle) holds requests, responses, and votes; a Nomad job and Slack manifest in deploy/ host it on our own cluster.
Tech stack: Node/TypeScript, Bolt, Postgres, Anthropic (ledger-assistant phrasing and the multi-party comment summary), Agent Builder (the ledger's Bolt Assistant thread), MCP (the product's core tool, hosted over streamable HTTP). RTS is not used here: Handraise deliberately reads nothing from a workspace's channels, so there is no channel history to ground against.
The whole thing was built test-first: a test-writer pass wrote the full suite plus stubs that threw NOT_IMPLEMENTED, then an implementer pass made every suite green without touching the tests, per our two-agent TDD process. The suite stands at 522 tests, including real-Postgres transactional suites that verify the concurrency invariants at the database level: single-resolution races settled with row locks, double votes rejected by a unique constraint.
Challenges we ran into
Holding an MCP call open across a Nomad ingress for the duration of a human response is the whole premise, and also the fragile part. Proxies have idle timeouts; MCP clients have their own per-call limits. We ended up with a layered answer. Progress-notification keepalives let the server hold the call for the full timeout when the client cooperates, and when it can't, the pending result carries a next_step instruction the agent follows to resume waiting through fetch_response. Watching a real Claude Code session stop "blocking" mid-demo is what forced the second layer: an agent that gets a pending result with no instructions just reports back to its user and stops.
We also learned that the tool schema is the documentation that matters. Our first live session watched an agent fail the payload shape twice before getting a card out, because the listing advertised payload as an opaque object and the real schema only lived server-side. Advertising the full discriminated union with descriptions fixed first-call success, and it cut the same agent from two parallel requests down to the single form we wanted.
Assignee resolution is a small but real problem: an agent knows "maria," Slack needs a user ID. Handraise accepts a handle, a user ID, or an email; when resolution fails, the cancelled response the calling agent gets back lists near-matches ("did you mean @jane?") rather than a bare 404.
Ephemeral Slack messages aren't a safe ballot for multi-party voting (delivery isn't guaranteed), so votes are tracked app-side and the shared card is re-rendered as the record of who has and hasn't responded.
Accomplishments we're proud of
The response types cover a real spread of what a human is actually being asked for: not just yes/no, but a diff to review, a form to fill in, free text to type. Getting each of those to degrade correctly at boundary sizes (5 vs. 6 options, a 2.4k vs. 3k character diff, a 6 vs. 7 field form) took explicit golden tests, not just happy-path coverage.
What we learned
Handraise is deliberately minimal in what it reads from a workspace: no channel history scopes, only its own DMs and the cards it posts. That constraint shaped the assistant to answer purely from Handraise's own ledger rather than reaching into Slack search.
Keeping the LLM strictly on phrasing, never on the underlying facts, made the assistant's answers auditable: every number it states traces back to a row in Postgres, not a language model doing arithmetic.
What's next
A policy engine for who can be assigned what, escalation/delegation chains when an assignee doesn't respond, and richer diff rendering (field-level diffs as tables, not just fenced text) are all on the roadmap but weren't required for the core loop to be real.
Built With
- mcp
- node.js
- slack
- slack-agent-builder
- slack-ai
- typescript
Log in or sign up for Devpost to join the conversation.