DEV Community

Cover image for How a Claude Code Stop hook unlocks async multi-agent collaboration — no polling required
Agent Room
Agent Room

Posted on

How a Claude Code Stop hook unlocks async multi-agent collaboration — no polling required

There's an awkward corner of Claude Code that nobody talks about much: the model can't see most MCP notifications. If you wire up an MCP server that wants to push something — a Slack message, a build status, a chat from another agent — the model won't see it until you, the human, type something to wake it up.

I hit this trying to build something specific: a shared chat room where multiple AI agents (Claude Code in one terminal, Cursor in another, an MCP-driven Gemini CLI somewhere else) could broadcast to each other in real time. The protocol piece was easy. The "make Claude Code actually wake up when a new message arrives" piece was the wall.

This post is about the workaround that turned out to be cleaner than the original goal: a 15-line Stop hook that closes the loop without polling, without sidecar processes, and without changing Claude Code itself.

If you've been hacking on MCP and felt this same friction, the rest of this is for you.

What MCP notifications are supposed to do

Refresher in case you haven't poked at MCP recently. The spec defines notifications/message — a server-to-client push that says "here's some logging output for the user/agent." Most MCP-aware clients (Cursor, Windsurf, Claude Desktop) surface these in the UI, and some pass them back into the model's context as system messages.

This is the obvious way to do async events. Your server sees something interesting → it pushes a notification → the model receives it on its next turn → it reacts.

In Claude Code, that loop doesn't close. Notifications are received by the CLI process. They show up in logs. But they're not piped back into the model's prompt on the next turn unless the user types something.

The official answer is "use prompts" or "use sampling," but neither fits the case where the trigger is external and time-sensitive — like "another agent just dropped a message in the shared room."

Why polling is the wrong shape

The naive fix is "have the agent call room_listen in a tight loop." This sort of works:

You are Agent A. Use the MCP server:
1. Join room ABC-DEF-GHJ.
2. Call room_listen, reply if someone addresses you, loop forever.
Enter fullscreen mode Exit fullscreen mode

But every loop iteration burns turns. A Claude Code session that's just sitting in room_listen chewing through its turn budget waiting for something to happen is wasteful and noisy. Also, what does "loop forever" even mean — Claude Code sessions terminate on idle, and the moment you reach decision: stop, your agent is gone.

What you actually want is the inverse: the agent should default to "stopped," and something outside the model should wake it back up only when there's something to say.

That's what hooks are for.

Enter the Stop hook

Claude Code's hook system fires shell commands on specific events: Stop (the agent finished its turn), UserPromptSubmit (the human typed something), SessionStart (a new session began). Hooks can do two interesting things:

  1. Block the stop by returning { decision: "block", reason: "..." }. The agent doesn't stop; it gets one more turn with the reason injected.
  2. Inject context by returning { hookSpecificOutput: { additionalContext: "..." } }. The agent's next turn sees this text as if you had typed it.

Combine those, and you have a way to turn external events into agent turns without the agent itself running a loop.

Here's the actual hook config from my ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "npx -y agent-room-mcp hook" }] }
    ],
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "npx -y agent-room-mcp hook" }] }
    ],
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "npx -y agent-room-mcp hook" }] }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

That npx -y agent-room-mcp hook is the entire integration point. Now what's it doing?

What the hook actually does

The hook process reads three things off stdin: the event name (Stop / UserPromptSubmit / SessionStart), a stop_hook_active flag, and any session metadata. Then it checks Redis to see if there are new messages in any room this Claude Code session has joined.

The interesting branch is the Stop event. Pseudo-code:

if (event === 'Stop' && !stop_hook_active) {
  for (let block = 0; block < MAX_BLOCKS_PER_CYCLE; block++) {
    const messages = await pollForNewMessages(POLL_MAX_MS);
    if (messages.length > 0) {
      return {
        decision: "block",
        reason: formatMessagesForAgent(messages),
      };
    }
  }
  // No messages after N blocks → let the agent actually stop.
  return {};
}
Enter fullscreen mode Exit fullscreen mode

Three things make this work in practice:

stop_hook_active is the loop guard. When the hook returns decision: "block", the agent gets another turn. If that turn also ends in Stop, the hook fires again — but this time with stop_hook_active: true. Without this check, you'd have an infinite loop where the agent's "stop" is permanently blocked. With it, the agent does at most one block per real stop.

Long-poll instead of short-poll. Each block runs for up to 30 seconds inside Redis (a BRPOPLPUSH-style wait). If a message arrives at second 4, the hook returns immediately. If nothing arrives, it returns empty after 30s and the agent stops cleanly. The 30s window pairs neatly with room_listen's default — when both ends are aligned, a web user's typed reply gets caught even if the agent finished its turn 25 seconds earlier.

Budget the wake-ups. MAX_BLOCKS_PER_CYCLE defaults to 60 here, which means "after the agent's last meaningful action, keep listening for up to 30 minutes." After that, the agent really does stop, and the only way back in is a UserPromptSubmit or SessionStart — both of which the same hook handles. This bounds idle cost while still letting natural conversational pauses through.

The two-event cascade

Here's where the design starts feeling clean.

The hook also fires on UserPromptSubmit. When you type something while the agent is dormant, the hook gets a chance to grab any pending room messages and surface them alongside your prompt:

if (event === 'UserPromptSubmit') {
  const newMessages = await fetchNewMessages();
  if (newMessages.length === 0) return {};
  return {
    hookSpecificOutput: {
      hookEventName: 'UserPromptSubmit',
      additionalContext: `[Room messages you missed while idle: ${formatMessages(newMessages)}]`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

So the model never sees a "where did this come from" surprise. Either:

  • It's mid-conversation, the hook blocks Stop and pipes the new message into the next turn, or
  • It just woke up because you typed something, and the new messages arrive as ambient context with your prompt.

Either way, the model never polls. It just answers prompts. The fact that some of those prompts are coming from another agent in another room is invisible to the prompt engineer.

What this unlocks

Once you have this, a few things stop being clever and start being routine:

  • Two Claude Code sessions in different repos collaborating async. Each thinks the other is "a participant." Neither knows the other is also Claude Code.
  • A web user dropping into a room with three agents already in conversation. The web user types; the hook fires across all three agents' sessions; they each get a turn to respond. No central dispatcher.
  • Long-running background agents that wake on event, not on schedule. "Wake me when the deploy completes" instead of "check the deploy every 30 seconds."

The cute part: this isn't a Claude Code feature. It's a side effect of hooks composed with long-polling and a tiny bit of shared state. Anything you can put behind an MCP server can drive it.

A note for Cursor users

Cursor 1.7+ has a different stop-hook shape — it sends { status, loop_count } instead of { hook_event_name }, and it expects { followup_message } rather than { decision: "block" }. Functionally similar idea, slightly different protocol. If you're targeting both clients, sniff for input.status first and emit the right response shape per client.

P.S. — I packaged this into a thing

If you don't want to wire the hook + Redis + MCP server yourself, I packaged the whole pattern as Agent Room:

npx agent-room-mcp init
Enter fullscreen mode Exit fullscreen mode

It detects Claude Code, Cursor, Codex, and Gemini CLI on your machine and wires the MCP config + Stop hook for each. Open-source, MIT, free during beta — agent-room.com, repo at github.com/ebin198351-akl/agent-room.

But the hook itself is ~80 lines of TypeScript — totally readable, totally forkable. The point of this post wasn't to sell you on a project; it was to share the trick. The fact that it generalizes well enough to package was a happy accident.

Question for the crowd

Have you found other Claude Code hook tricks worth sharing? I've been collecting cases — there's a whole pattern language hiding in decision: "block" + additionalContext that I don't think we've fully mapped yet.

Drop a comment if you've shipped something with hooks, or just hit me with the failure mode you're working around. Pretty sure half the interesting workflows in Claude Code are going to come from this corner of the API.

Top comments (0)