Cereal docs
Sessions and UI

Event hooks and the dead-letter queue

What Cereal POSTs to you, when it waits for the answer, and what happens to a delivery that will not land.

Cereal has three hooks. It waits for two of them, which are documented where the wait matters: the decision hook before a gated tool call, and the sandbox prepare hook before an agent's first command on a machine. This page covers event hooks and their delivery queue.

Subscribing

A connection may subscribe to session events. Name the endpoint and the events on the connection, or let defineAgent do it from hooks.onSessionEvent:

await client.connections.upsert({
  slug: "research-agent",
  url: "https://app.example.com/api/cereal/mcp",
  auth: { type: "cereal-signed" },
  hooksUrl: "https://app.example.com/api/cereal/hooks",
  hookEvents: ["agent.message", "tool.call.started", "subagent.completed"]
})
export const agent = defineAgent({
  agent: { /* … */ },
  hooks: {
    onSessionEvent: async (event, ctx) => {
      if (event.type === "agent.message") await recordTranscript(ctx.session.id, event)
    }
  }
})

Event hooks support durable events. Use the session stream for live text deltas.

A connection registered by cereal dev has no hooksUrl at all: deliveries arrive as frames on the same outbound socket and are answered by the same handler. See local development.

The envelope

{
  "type": "cereal.hook",
  "version": 2,
  "phase": "session.event",
  "connection": "research-agent",
  "session": {
    "id": "ses_…", "agentId": "agt_…", "agentVersion": 3,
    "parentSessionId": "ses_…",
    "metadata": { "customerId": "cus_42" },
    "identity": { "scope": "user", "id": "u_412", "display": "Ada Lovelace" },
    "sandbox": { "id": "sbx_…", "state": "ready", "apiUrl": "https://app.trycereal.ai", "token": "…" }
  },
  "event": { "type": "agent.message", "seq": 12, "…": "…" }
}

Every delivery to a cereal-signed connection is signed with cereal.purpose: "hook" (see MCP and request signing), and agent.handlers verifies it before your callback runs. session.metadata and session.identity are trustworthy for the same reason a tool handler's ctx.session is: they are in the body, and the body is bound to the signature. session.parentSessionId is how you tell a sub-agent's work from the conversation the user is having. session.sandbox is present when the session has a live machine, and carries a short-lived sandbox token minted for this delivery.

The outbox

Event delivery is at least once. Use the delivery ID to deduplicate work in your handler. Cereal retries failed deliveries with backoff; after the attempt budget is exhausted, the delivery becomes dead and a hook.dead_letter event records it.

Fix the endpoint failure before redelivering. Decision and sandbox prepare hooks have their own timeout and failure behavior; see their guides above.

The dead-letter queue

await client.hooks.stats({ since })                 // pending / delivered / dead per connection
await client.hooks.deliveries({ status: "dead", connectionId, sessionId, since, cursor })
await client.hooks.redeliver({ id })
await client.hooks.redeliverAll({ status: "dead" })  // bounded per call; returns `more`
await client.hooks.discard({ id })

Use the returned cursor to page through deliveries. redeliverAll defaults to dead, since a bulk requeue of delivered would re-POST a customer's history. discard deletes one row, which is what makes the queue clearable.

The control plane's Hooks page is this queue: a health strip, the rows with status, attempts, next attempt and last error, redeliver and discard per row, and a red count on the navigation item while any are dead.

Retention

A delivered row is kept seven days by default and a dead one thirty; a pending one is never pruned, however old, because it is still going to be attempted. See retention.

On this page