Cereal docs
Sessions and UI

Gating tool calls and human approval

Allow, deny, or pause a tool call for a person to decide.

policies.toolCalls resolves per call into allow, deny or ask, reading byTool[name], then the configured hook, then default.

policies: {
  toolCalls: {
    default: "allow",
    byTool: { publish_brief: "hook", delete_account: "deny" }
  },
  decisions: { expiresInSeconds: 3600, onExpire: "deny" }
}

An empty policies object allows tool calls by default. A literal byTool decision wins over the hook; "hook" delegates that tool's decision to your callback.

The decision hook

Cereal waits for the decision hook before executing a gated tool call. The callback receives the proposed arguments and session identity. Your application should still check authorization inside the tool itself.

export const agent = defineAgent({
  agent: {
    slug: "research-agent",
    // `hook` is not written here: `sync` points it at this app's connection
    // because `onToolCall` is defined. `byTool` is the latency valve.
    policies: {
      toolCalls: { default: "allow", byTool: { read_source: "allow" } },
      decisions: { expiresInSeconds: 3600, onExpire: "deny" }
    }
    // …
  },
  hooks: {
    onToolCall: async (call, ctx) => {
      if (call.name !== "publish_brief") return          // returning nothing is `allow`

      if (ctx.session.metadata?.tier === "basic") {
        return {
          decision: "deny",
          message: "This workspace cannot publish briefs. Keep the result in this session."
        }
      }

      // Idempotent on `toolCallId`: Cereal may call you twice for one call (a
      // transport retry, or a resumed turn re-evaluating the step).
      await slack.postApproval({ key: call.toolCallId, sessionId: ctx.session.id })
      return {
        decision: "ask",
        ask: { prompt: "Publish this research brief?", channel: "slack:#approvals" }
      }
    }
  }
})

The hook can gate your tools, third-party MCP tools, and sandbox tools. Explicit per-tool decisions bypass it. Provider-executed tools and the delegation tools are not gated by it. For event notifications, subscribe to tool.call.started with onSessionEvent.

Return { decision: "deny" } to reject a call. Throwing an error invokes the configured onError policy instead. Both onError and onTimeout default to deny. ctx.deadlineAt states the deadline; return ask if a person or slower process must decide.

What ask does

Cereal records tool.call.pending and sets the session to idle with stop reason requires_action. The decision remains pending across browser disconnects and restarts. Allowed calls in the same step finish before the turn pauses.

Answering

await agent.approvals.pending(sessionId)     // what is waiting, with its arguments
await agent.approvals.allow(sessionId, toolCallId, { actor: "ops@example.com" })
await agent.approvals.deny(sessionId, toolCallId, { message: "Declined by Ada." })

or on the raw client:

await client.sessions.decide({
  id: sessionId,
  toolCallId,
  decision: "allow",
  input: { title: "Launch readiness", body: revisedBrief, sourceIds: ["launch-plan"] },        // run it with different arguments
  message: "Updated the brief and its citations."   // attached to the result as a note
})

A denial's message is what the model sees as the call's result, so "no, use the other account" reaches it rather than being dropped.

Both throw ordinary outcomes you should catch:

try {
  await agent.approvals.allow(sessionId, toolCallId)
} catch (error) {
  if (error instanceof DecisionNotPendingError) return reply("That was already answered.")
  if (error instanceof DecisionExpiredError) return reply("That expired. Ask again.")
  throw error
}

DecisionNotPendingError covers duplicate clicks, a Slack button racing a web card, and a message sent with mode: "steer", which supersedes pending approvals. An enqueued message leaves them pending.

Expiry

decisions.expiresInSeconds bounds the wait. onExpire: "deny" wakes the model with a denial; cancel_turn completes the calls and idles on decision_expired without a model call, because an abandoned thread should not cost one.

Resuming

When you answer, Cereal re-runs only what is left: everything already settled is a tool.call.completed row it skips. A denied call never dispatches. A call inside a sys_execute plan resumes the stored plan with the settled steps reused.

In the control plane

The session view shows a pending approval as a card above the composer with the arguments and Allow / Deny. The transcript row shows a "waiting for approval" badge rather than a second pair of buttons, so one call can never be answered twice from one screen.

On this page