Plane docs
Guides

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 onError policy decides, which is the honest answer: a thrown exception means you did not reach a decision. { decision: "deny" } is how you deny.
  • onError and onTimeout both default to deny. That is the opposite of a fail-open hook, and deliberate: the hook is the permission system here and it runs unattended.
  • ctx.deadlineAt is the instant Plane abandons the request (ten seconds by default). An endpoint that knows it will be slow should answer ask rather 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.

On this page