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.