Gating tool calls and human approval
A hook that decides, and a wait that can last hours.
policies.toolCalls resolves per call — byTool[name], then the configured hook,
then default — into allow, deny or ask.
policies: {
toolCalls: {
default: "allow",
byTool: { create_return: "hook", delete_account: "deny" }
},
decisions: { expiresInSeconds: 3600, onExpire: "deny" }
}Every field is optional with a documented default, so "policies": {} is a complete
permissive policy. One asymmetry worth knowing: a byTool literal beats the hook
(it is a statement about that tool) while byTool: "hook" beats a default that
would not have called one (it is a request for a decision).
The decision hook
This is the only hook Plane waits for. It runs before the tool does and Plane obeys the answer, and it is the only place in the system that sees the model's proposed arguments and the caller's identity at the same time.
export const plane = definePlane({
agent: {
slug: "support-bot",
// `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: { lookup_order: "allow" } },
decisions: { expiresInSeconds: 3600, onExpire: "deny" }
}
// …
},
hooks: {
onToolCall: async (call, ctx) => {
if (call.name !== "create_return") return // returning nothing is `allow`
if (ctx.session.metadata?.tier === "basic") {
return {
decision: "deny",
message: "Returns on this plan go through an agent. Offer to open a ticket."
}
}
// Idempotent on `toolCallId`: Plane 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: "Approve a $82.40 return on A-1001?", channel: "slack:#approvals" }
}
}
}
})It sees every call the agent makes — a builtin, a third-party server's tool, a
sandbox tool, one of yours — not only the ones this app serves. To watch rather than
gate, subscribe to tool.call.started with onSessionEvent.
Three things it is easy to get wrong:
- Throwing is not a deny. It produces a 500 and the version's
onErrorpolicy decides, which is the honest answer: a thrown exception means you did not reach a decision.{ decision: "deny" }is how you deny. onErrorandonTimeoutboth default todeny. That is the opposite of a fail-open hook, and deliberate: the hook is the permission system here and it runs unattended.ctx.deadlineAtis the instant Plane abandons the request (ten seconds by default). An endpoint that knows it will be slow should answeraskrather than be cut off.
What ask does
The turn ends. Plane appends tool.call.pending, writes
session.status(idle, requires_action) and returns. No connection is held open, no
promise is parked in memory — so a restart during a four-hour approval loses nothing,
and a thousand pending approvals are not a thousand resident sessions. Calls that were
allowed in the same step run before the turn parks, so partial progress is kept.
Answering
await plane.approvals.pending(sessionId) // what is waiting, with its arguments
await plane.approvals.allow(sessionId, toolCallId, { actor: "ops@example.com" })
await plane.approvals.deny(sessionId, toolCallId, { message: "Declined by Ada." })or on the raw client:
await client.sessions.decide({
id: sessionId,
toolCallId,
decision: "allow",
input: { orderId: "A-1002" }, // run it with different arguments
message: "Corrected the order id." // 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 plane.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 the one case that surprises people: a newer user message supersedes every
pending approval. That is what the customer means, and it is the anti-wedge.
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, Plane 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
plane_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, and 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.