Skip to content
GitHub

Hooks API

Call your own HTTP endpoint or MCP tool when a thread starts, a message arrives, a tool runs, or the reply is ready, and feed the result to the agent.


On this page

Agent hooks let you run your own code at five points in a conversation — when a thread starts, when a user message arrives, before and after each tool call, and when the reply is ready — and hand the result back to the agent as context, block a message or a tool call, or ask the agent to keep working. Use them to load per-user context from your systems before the model answers, to enforce a policy on incoming messages or on which tools the agent may call, to annotate tool results, or to check a reply before it goes out.

The hooks document uses the Claude Code hooks schema: the same event names, matcher groups, handler list, timeout, and JSON output, so a hook server written for Claude Code or Codex can be pointed at Runbear unchanged. Runbear narrows the schema to what it runs: five events, and http and mcp_tool handlers (no shell commands run on Runbear's workers).

Authenticate with a bearer API key from the Runbear Manage API Keys menu. Full request/response schemas live in the OpenAPI reference.

Endpoints#

EndpointPurpose
GET /v1/agents/{agentId}/hooksRead the agent's hooks document ({ "hooks": {} } when none is set)
PUT /v1/agents/{agentId}/hooksReplace the agent's hooks document as one unit
DELETE /v1/agents/{agentId}/hooksRemove the document and the header secrets it referenced; idempotent

PUT and DELETE require an API key with the manageAgents capability. Through the API, hooks are managed on Anthropic and Claude Agent SDK agents. Classic agents run hooks as well, but their document is managed from the dashboard only.

Once the feature is enabled for your organization, the same document can be edited in the dashboard under Agent → Settings → Hooks, a JSON editor over exactly this format with the same validation and secret handling as PUT.

Removing the document (DELETE, or Remove hooks in the dashboard) also stops replaying the context earlier hooks added to a thread: its next message is sent without those blocks, which costs one prompt-cache miss on that thread.

The hooks document#

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "context-server",
            "tool": "load_user_context",
            "input": {
              "slack_user_id": "${channel_user_id}",
              "first_user_message": "${prompt}"
            }
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.example.com/runbear",
            "headers": { "Authorization": "Bearer …" },
            "timeout": 3,
            "failClosed": true
          }
        ]
      }
    ]
  }
}

Each event maps to a list of matcher groups; each group holds the handlers that run when its matcher applies.

FieldMeaning
matcherWhich invocations the group applies to, evaluated the way Claude Code does. Omitted, empty, or * matches everything; A|B is an exact-name list; anything else is an unanchored regular expression. SessionStart matches on how the session started, which is always startup on Runbear; PreToolUse and PostToolUse match on the tool name as the model sees it (see Tool names); UserPromptSubmit and Stop have no matcher and a value there is ignored, as in Claude Code
hooks[].typehttp or mcp_tool
urlhttp only. Must be https and resolve to a public address — a private or loopback address is refused when the hook runs
headershttp only. Request headers, typically an authorization token. See Header secrets
servermcp_tool only. The app namespace of one of the agent's Custom MCP integrations. The integration must exist when the document is written
toolmcp_tool only. The tool to call on that server
inputmcp_tool only. Arguments for the tool; string values may use ${field} templates. See Templates
timeoutSeconds. Defaults to 5 on SessionStart and 3 on every other event; at most 10. Every hook sits on the reply's critical path, so keep it short
failClosedUserPromptSubmit and PreToolUse only. Block the reply, or deny the tool call, when this handler fails instead of ignoring the failure

A document may hold at most 8 handlers per event across all groups. PUT validates the whole document and returns 422 with error: "invalid_hooks" and a per-field issues list on any violation, or error: "unknown_mcp_server" when an mcp_tool handler names a server that is not a Custom MCP integration on the agent. Unknown events and unknown handler fields are rejected rather than ignored, so a typo cannot be saved as a hook that silently never fires.

Events#

SessionStart runs once per thread, when the first message arrives and before the agent replies to it. Whatever context the handlers return is added to the agent's system prompt for the life of the thread — later messages and later participants in the same thread reuse it without calling the hook again. SessionStart is context-only: it always fails open, and failClosed is rejected on it.

UserPromptSubmit runs for every user message, before the model sees it. Handlers can add context to that message or block the reply. The result is stored with the message and replayed byte-for-byte on every later turn of the thread, so the model's view of earlier turns never changes.

PreToolUse runs before each tool call the model makes, with the tool's name and arguments. A handler can allow the call, deny it, or rewrite its arguments. A denied call never runs: the model receives the reason as the tool's error result and can relay it to the user. Tools the model provider runs inside its own request never pass through Runbear and are not hookable: on Anthropic agents, web search and code execution; on Classic agents, Vertex google_search and code_execution. On Claude Agent SDK agents the SDK's built-in tools (including its web search and fetch) are ordinary tool calls and are hooked.

PostToolUse runs after each tool call that succeeded, with the arguments the tool ran with and its result; a call the runtime marked as an error (a denied call, a tool that threw) is not hooked, while a tool that reports a failure inside an otherwise normal result still is. Handlers can add context the model reads next to the result. The tool has already run, so a block cannot undo it: its reason is fed back to the model as guidance to act on, as in Claude Code. Always fails open.

Stop runs when the agent's reply is ready, with the reply text. A handler can let the reply go out or, with decision: "block", send reason back to the agent and have it continue working — at most once per turn. On the continuation the input carries stop_hook_active: true and a further block is ignored, so a handler that checks the flag never loops. Always fails open.

Tool names#

PreToolUse and PostToolUse matchers are evaluated against tool_name, the name the tool is registered under for the model. That name depends on where the tool comes from and on the agent's runtime, so treat a tool_name you have observed as the source of truth: add a PostToolUse group with no matcher whose handler logs tool_name, run the agent once, then narrow the matcher.

  • Anthropic agents register every tool with characters other than letters, digits, and _ replaced by _. Runbear's own tools: runbear_file_search, runbear_slack_send_message. A Custom MCP tool: the integration's server namespace, _, the tool's name on the server, after that replacement — server context-server, tool lookup → context_server_lookup. An app integration action: its key in upper case — SLACK_SEND_MESSAGE.
  • Claude Agent SDK agents use the SDK's form mcp__<server>__<tool>. Runbear's own tools sit under a server named runbear: mcp__runbear__authorize_integration. A Custom MCP tool: mcp__context-server__lookup, where <server> is the namespace trimmed, with any run of characters other than letters, digits, and - collapsed to _, leading and trailing _ removed (__crm__ → crm), plus a _2, _3, … suffix when that name is already taken by another server — another Custom MCP integration, an app or managed integration, or Runbear's own runbear server (a Custom MCP integration named runbear becomes runbear_2). An app integration action: mcp__pipedream_<app>__<action>. The SDK's built-in tools keep their SDK names: Bash, Read, WebSearch.
  • Classic agents register names as they are: runbear_file_search, runbear-slack-send-message, context-server_lookup, SLACK-SEND-MESSAGE.

A matcher made only of letters, digits, _, -, whitespace, |, and , is an exact-name list, so refund matches a tool named exactly refund and nothing else. To match across spellings, write a regular expression — a matcher with any other character (., [, ^, …) is one, and it is unanchored: refund.* matches every tool whose name contains refund, send[-_]message matches both send-message and send_message, and ^mcp__context-server__ matches every tool of that server on a Claude Agent SDK agent.

Example: gate a tool and check the reply#

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "issue_refund|delete_record",
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.example.com/runbear/tool-policy",
            "headers": { "Authorization": "Bearer …" },
            "failClosed": true
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "context-server",
            "tool": "review_reply",
            "input": {
              "reply": "${last_assistant_message}",
              "already_continued": "${stop_hook_active}"
            }
          }
        ]
      }
    ]
  }
}

The PreToolUse group's matcher names two tools, so the policy endpoint is called only when the model tries to call issue_refund or delete_record; every other tool runs without a hook. To refuse a call the endpoint answers with a deny:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Refunds over $500 need a human approver."
  }
}

With failClosed: true, an unreachable policy endpoint denies the call rather than letting it through. The Stop handler receives the finished reply; returning { "decision": "block", "reason": "The reply does not cite the ticket number." } sends the agent back to work once, and when already_continued is true the tool should return {} so the reply goes out.

What your handler receives#

Both handler types receive the same JSON input. Field names follow Claude Code's hook input so a handler written for Claude Code reads it unchanged; the Runbear-specific fields are additive. Every event carries the shared fields; each event adds its own.

{
  "session_id": "6b3d…",
  "hook_event_name": "UserPromptSubmit",
  "agent_id": "f47ac10b-…",
  "agent_name": "Support",
  "org_id": "0c9e…",
  "thread_id": "6b3d…",
  "turn_id": "1720000000.000100",
  "channel": "slack",
  "channel_id": "C0123456789",
  "channel_user_id": "U0123456789",
  "user_name": "Dana",
  "user_email": "dana@example.com",
  "prompt": "Can I get a refund for order 4821?"
}
FieldMeaning
session_id, thread_idThe thread. session_id is kept for Claude Code parity and equals thread_id
hook_event_nameSessionStart, UserPromptSubmit, PreToolUse, PostToolUse, or Stop
agent_id, agent_name, org_idThe agent and organization the hook is configured on
turn_idIdentifier of the message that triggered this turn, when the channel has one
channelChannel family the thread lives in. Anthropic agents report the connection's channel type (slack, teams, discord, web, api, external_trigger, …); Claude Agent SDK agents report slack, teams, playground, or api for every request that arrives through the API
channel_id, channel_user_idThe channel's own identifiers for the conversation and the sender (a Slack channel and user id, for example), when known
user_name, user_emailThe sender, when the channel provides them
promptSessionStart and UserPromptSubmit. The text of the message being answered. On SessionStart it is the thread's first message, when present
sourceSessionStart only. Always startup — Runbear threads never resume or clear
tool_namePreToolUse and PostToolUse. The tool's name as the model sees it; also the value the group's matcher is evaluated against
tool_inputPreToolUse and PostToolUse. The tool's arguments as a JSON object: on PreToolUse what the model passed, on PostToolUse what the tool ran with (after any updatedInput)
tool_use_idPreToolUse and PostToolUse. Identifies the call, so a PostToolUse handler can pair a result with what PreToolUse saw
tool_responsePostToolUse only. What the tool returned: its text, or its structured result when it had one
stop_hook_activeStop only. true when this reply is already the continuation an earlier Stop block asked for, so a handler can avoid asking again
last_assistant_messageStop only. The reply the agent is about to send — the same field the Claude Agent SDK provides, always populated here since there is no transcript file to read it from

A PreToolUse input, for example, adds the tool fields to the shared ones:

{
  "hook_event_name": "PreToolUse",
  "tool_name": "issue_refund",
  "tool_input": { "order_id": "4821", "amount": 640 },
  "tool_use_id": "toolu_01…"
}

An http handler receives this as the body of a POST with Content-Type: application/json and an X-Runbear-Hook-Event header naming the event, plus any headers you configured. An mcp_tool handler receives it through its input template — see Templates.

What your handler returns#

Handlers answer with the Claude Code JSON output. Runbear reads the following from it:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Dana is on the Enterprise plan; order 4821 shipped on 2026-08-30."
  }
}
{
  "decision": "block",
  "reason": "This channel is for billing questions only."
}
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": { "order_id": "4821", "amount": 640, "requested_by": "U0123456789" }
  }
}
  • hookSpecificOutput.additionalContext — text the agent should see next to the event that fired the hook. Read on SessionStart, UserPromptSubmit, and PostToolUse. An empty string counts as no context. PreToolUse and Stop are decision events: context returned there is not injected.
  • decision: "block" with reason — what it does depends on the event. On UserPromptSubmit the reply is not generated and reason is shown to the user. On PostToolUse the tool has already run, so reason is fed back to the model next to the tool result. On Stop the reply is held and the agent continues with reason as its instruction, once per turn. On PreToolUse a top-level block is Claude Code's older spelling of deny and is honoured as one (approve likewise means allow). On SessionStart a block is ignored.
  • hookSpecificOutput.permissionDecision — PreToolUse only. allow lets the call run; deny stops it and hands permissionDecisionReason to the model as the tool's error result. ask is treated as deny: Runbear has no per-call approval prompt, so a handler that would prompt the user in Claude Code denies here, with its reason, and the model can ask the user itself. defer is a CLI-only value and is ignored.
  • hookSpecificOutput.updatedInput — PreToolUse only. Replaces the tool's arguments; the tool validates them the way it validates the model's own.

Everything else in the Claude Code output (continue, suppressOutput, systemMessage, …) is accepted and ignored, so a hook written for Claude Code never fails to parse.

For an http handler, only a 2xx response with a JSON object body carries a result. A 2xx with an empty body is a successful no-op. Any other status, a body over 1 MB, or a redirect is a failure. Redirects are not followed.

For an mcp_tool handler, the tool's text content is read the way Claude Code reads a command hook's output: JSON when it looks like a JSON object, otherwise the text is the context itself. A tool that returns plain text therefore needs no wrapper on SessionStart, UserPromptSubmit, or PostToolUse; on PreToolUse and Stop plain text carries no decision and is ignored. A tool result flagged isError is a failure.

How results are merged#

All handlers registered for an event run concurrently (up to 4 at a time) and every one of them runs to completion. Their outputs are merged the way Claude Code merges them:

  • Every additionalContext is kept, concatenated in handler order (the order handlers appear in the document, across groups).
  • On UserPromptSubmit, PostToolUse, and Stop, a single block blocks. The first blocking handler in document order supplies the reason.
  • On PreToolUse, the strictest permission wins — deny over ask over allow — with the first reason given for that decision in document order. A failClosed handler that fails counts as deny at its own position, so an earlier handler's explicit reason is kept over the generic notice. updatedInput is taken from the last handler that set one, and dropped when the call is denied. On Classic agents the conversation history keeps the model's original arguments while the tool ran with the rewritten ones; Anthropic agents record the rewritten arguments.
  • Any one context string is capped at 10,000 characters; longer values are truncated with a marker.
  • On UserPromptSubmit, a context string identical to one already injected earlier in the thread is not injected again, so a handler may statelessly return every fragment relevant to the question on every message and only new fragments reach the model. On Anthropic agents the comparison covers the messages still in the uncompacted window; after a context compaction a fragment summarized away may be injected again.

Failures and timeouts#

Hooks fail open by default: a handler that times out, cannot be reached, answers with a non-2xx status, or returns something Runbear cannot parse is ignored, and the operation proceeds with whatever the other handlers returned — the reply is generated, the tool call runs with its original arguments, the reply goes out. Failures are logged and visible in the agent's traces.

Set failClosed: true on a UserPromptSubmit or PreToolUse handler to invert that for a handler that acts as a policy gate. On UserPromptSubmit a failure blocks the reply and the user sees a generic notice that a policy check configured by your organization did not allow the request; on PreToolUse a failure denies the tool call and the model receives the same generic notice as the tool's error result. SessionStart, PostToolUse, and Stop cannot fail closed; failClosed is rejected on them.

The default timeouts (5 s for SessionStart, 3 s for every other event) are deliberately a fraction of Claude Code's: a user is waiting on the other end, and the tool events fire once per tool call. A handler may raise its timeout to at most 10 seconds.

Budget the tool events with care: PreToolUse and PostToolUse each add one round trip per tool call, handlers run four at a time, and a turn can make many tool calls in sequence. With the defaults a tool-heavy turn adds a few seconds per call; with eight handlers at the 10 s ceiling it can add up to 40 s per call. Keep tool-event handlers fast and few.

Where the context lands#

UserPromptSubmit context is placed next to the user message it was fetched for, inside a <hook_context> block the agent is told to use as background rather than answer or repeat. The block is stored with that message and replayed on every later turn of the thread exactly as it was first rendered, which keeps the prompt cache intact and means a hook that fails on one message does not alter how earlier messages were seen.

SessionStart context is added to the system prompt as a Session Context entry, once per thread. For organizations with the session-start hook feature, its outcome is also reported in the Traces API sessionStartHook field.

PostToolUse context is appended to the tool result the model reads, after the tool's own text and inside the same <hook_context> block, so a model that has learned the block next to a user message reads it the same way next to a tool result. A block reason on PostToolUse follows it in its own <hook_feedback> block, framed as guidance the model must act on rather than background. On Claude Agent SDK agents both are handed to the SDK as the hook's additionalContext, which the SDK places next to the result. Nothing is cached for the tool events or Stop: a tool call and a finished reply happen once, and their outcome is carried by the turn's own messages.

mcp_tool handlers run only on turns where the agent's MCP servers are connected. On Anthropic and Classic agents, a turn the agent answers because a user (not the agent) was mentioned connects no MCP server: UserPromptSubmit, PreToolUse, PostToolUse, and Stop run their http handlers and skip their mcp_tool handlers for that turn, and a SessionStart that has any mcp_tool handler is deferred in full to the thread's first ordinary turn so none of its handlers is lost. Claude Agent SDK agents connect their MCP servers on every turn, so nothing is skipped there.

Templates for MCP tools#

An mcp_tool handler's input is rendered against the hook input before the call. String values may reference input fields as ${field}; dot paths are allowed, so ${tool_input.order_id} reads one argument of the call on the tool events and ${last_assistant_message} reads the reply on Stop.

{
  "type": "mcp_tool",
  "server": "context-server",
  "tool": "load_user_context",
  "input": {
    "slack_user_id": "${channel_user_id}",
    "first_user_message": "${prompt}",
    "source": "runbear"
  }
}
  • A value that is exactly one placeholder is replaced by the referenced value and keeps its JSON type — "${tool_input}" passes the whole arguments object, "${stop_hook_active}" a boolean.
  • A string that embeds placeholders ("user ${user_name} on ${channel}") is interpolated as text.
  • A top-level key whose whole-value placeholder resolves to nothing is dropped rather than sent as null, so an optional field the channel cannot supply (for example user_email on Slack) is simply omitted from the call.
  • Keys the tool's input schema does not declare are dropped with a warning, so a template written for one tool version does not fail the call when a slot is renamed.

Header secrets#

http header values are stored in your organization's vault, never in the document itself, and never returned. GET shows every header value as "<redacted>". To change a document without re-sending the token, send "<redacted>" for that header on PUT — it keeps the value already stored for the same handler URL and header name (matched case-insensitively). A "<redacted>" value for a header that has nothing stored is rejected. Secrets for headers that a PUT drops, and every secret on DELETE, are deleted from the vault in the same transaction. content-type, content-length, host, and x-runbear-hook-event cannot be overridden.

HIPAA agents#

Hooks run for HIPAA-mode agents. For those agents the hook invocation itself is traced without content — only which handler ran, its status, and its latency are recorded. Context a hook returns becomes part of the prompt and is handled like any other prompt content for that agent.