Canonical gate applications
The ADK ships none of these. The gate lives at the consequence boundary: inside the handler that performs the side effect, never in middleware that runs after the model proposes it. Put it anywhere else and side effects escape ungated. Tools lead because that's where bad decisions turn into side effects, bills, and avoidable fires. The gate goes where you put it — these are just the pressure points that come up first.
Gates covers the gate contract and the minimum usable shape; Gate lifecycle covers settlement, suspension, durability, and observability.
1. Gating tool execution (RBAC, approval, elevation)
A tool's Tool.executor is where the agent actually does things. Send the email, drop the table, charge the card, file the ticket, delete the account. Every meaningful side effect lives inside a tool handler — and every meaningful side effect is a candidate for a gate.
Three variants of the same pattern. Gates belong where the agent touches external systems: tool handlers for side effects; turnOutputPipeline only when the gated thing is model output or auth context genuinely exists there:
- RBAC. The actor's identity sits in
ctx.stash. The handler consults an authorization service. If the answer is "no," the handler throws — no gate needed. If the answer is "yes," it proceeds. If the answer is "needs a human peer to elevate," it opens a gate. - Per-call human approval. The handler runs every time, but for certain tools (or certain args — a transfer above a threshold, a delete on a resource owned by another team) it opens an approval gate before performing the side effect. The same tool is fully automatic for safe args and human-gated for sensitive ones.
- Second-factor elevation. The actor is authenticated, but the action requires a fresh credential check. The handler opens a gate whose
payloadis "verify this user," and the resolver is your re-auth flow.
const deleteAccountTool = new Tool<{ accountId: string }>({
name: 'delete_account',
description: 'Permanently delete an account and all its data.',
schema: validator.object({ accountId: validator.string().required() }),
handler: async ({ accountId }, ctx) => {
const actor = ctx.stash.get('actorId') as string
if (!await rbac.can(actor, 'delete_account', accountId)) {
throw new E_TOOL_PERMISSION_DENIED({ actor, action: 'delete_account' })
}
const approval = await ctx.waitFor<{ approved: boolean; note?: string }>({
reason: 'destructive_action_approval',
payload: { actor, tool: 'delete_account', accountId },
schema: validator.object({
approved: validator.boolean().required(),
note: validator.string().optional(),
}),
timeout: 10 * 60 * 1000,
createdAt: DateTime.now(),
id: crypto.randomUUID(),
})
if (!approval.approved) {
throw new E_TOOL_DENIED_BY_OPERATOR({ note: approval.note })
}
await accounts.delete(accountId)
return JSON.stringify({ deleted: accountId })
},
})Infinite Blocking
Omitting a timeout in ctx.waitFor() creates a permanent execution lock that blocks your pipeline indefinitely. If an operator ignores a prompt, a webhook is dropped, or a runner redeploys during the wait, the turn becomes a zombie state that never recovers. Always specify a timeout matched to the hard SLA of your resolver to ensure the execution eventually fails or resumes rather than hanging forever.
Tools are where this matters most
A turn that streams a chatty answer to a user is not where you need gates. A turn that calls a tool that deletes production data is exactly where you need them. The handler is the last line of defence before the side effect happens — gating inside the handler, not in middleware that runs after the model proposed the call, is what makes the gate uncircumventable. Middleware can be misordered; the handler always runs to perform the side effect.
Gates are the authorization seam, not an ambiguity sink
The runner is intentionally ignorant of authorization and it will never grow its own model — gates are the hard boundary where your logic attaches. This seam is useless if your resolver cannot commit to a definitive, strictly-typed binary decision. If you wire in a timeout-as-implied-yes, an untyped shrug, or a blob prayer-cast into a boolean, you have not built a gate. You have merely built deferred ambiguity wearing a resolver's name.
2. External-system handoffs from a tool
A tool dispatches a job to an external worker (a long-running data export, a third-party API with async webhook callback, a manual fulfilment step). The handler needs to wait for completion before the dispatch loop can proceed. It opens a gate, persists the gate's id and payload somewhere durable, and returns the resolved value. A webhook receiver elsewhere in your service finds the gate by id and resolves it when the external job reports completion.
This is the same gate as the approval case, but the resolver is a webhook handler rather than a human. For this to survive a redeploy, persist the intent before you trust the handoff — see durability. The in-memory gate dies with the process, ensuring the incoming webhook finds no resolver and the response drops into the void. Your tool hangs until it times out, leaving your pipeline in a desynchronized coma while the external system claims a success it will never undo.
3. Mid-turn human review of model output
A turn-scoped turnOutputPipeline sees the model produced a draft message that policy says must be reviewed before it is finalized and emitted to the user. The middleware opens a gate, surfaces the draft to a reviewer, and either lets the message through, rewrites it, or rejects the turn based on the resolution. (Note: This gates the final commitment of the message to history and delivery; it does not intercept a real-time token stream if the provider is already emitting).
const turnOutputPipeline: TurnPipelineMiddlewareFn = async (ctx, next) => {
await next()
const last = [...ctx.turnMessages].at(-1)
if (!last || !POLICY.requiresReview(last)) return
const verdict = await ctx.waitFor<{ approve: boolean; revised?: string }>({
reason: 'message_review',
payload: { draft: last.content, presentTo: 'reviewer-pool' },
schema: reviewVerdictSchema,
timeout: 5 * 60 * 1000,
createdAt: DateTime.now(),
id: crypto.randomUUID(),
})
if (!verdict.approve) {
ctx.turnMessages.delete(last)
} else if (verdict.revised) {
last.content = verdict.revised
}
}This sits in middleware rather than a tool because the thing being gated is the model's own output, not an action the model proposed.
4. Rate-limit and quota pauses
A middleware detects that the actor is over a soft quota and policy says to pause rather than reject. It opens a gate with a short timeout and a payload.reason === 'quota_pause'. The same external system that tracks quota resolves the gate when the actor's window resets — or the timeout fires and a downstream middleware catches E_TURN_GATE_TIMEOUT and nacks the dispatch.
This is the smallest of the four and the most likely to be misused. If your "pause" is a hard stop, use ctx.nack() instead. Use a gate only when something outside the turn decides when to resume. When the timeout elapses, ctx.waitFor() throws E_TURN_GATE_TIMEOUT; you must catch it and call ctx.nack() or the turn crashes with an unhandled exception.