Gates
Read this. The ADK will not protect you; you will.
The ADK cannot tell you how to make your tools safe. It cannot tell you how to protect your application, how to enforce permissions, how to validate authority, how to verify identity, or how to draw the line between what the model is allowed to propose and what your business is allowed to do. Those are decisions only your application can make — and getting them wrong is how agents end up deleting production data, leaking credentials, or executing actions on behalf of users who should not have been trusted to request them.
What the ADK can do is give you the primitive that every one of those defences is built on, and tell you, in plain language: use it. Gates are that primitive. If your agent calls any tool whose side effect you would not let a stranger trigger, every part of this page applies to you.
A gate is a cooperative suspension point inside a turn. Middleware (or, more often, a tool handler) calls await ctx.waitFor(gate); the runner opens a TurnGate, emits turnGateOpen on the observability bus, and returns a promise that resolves when the gate settles. Settlement happens from outside the awaiter — a human clicks Approve, a webhook fires, an authorization service responds, a timeout elapses. The gate is the seam between the agent's decision to act and the system's decision to allow it.
What a gate is, and what it is not
A gate is a promise the ADK owns the lifecycle of. The runner gives you four guarantees:
- One settlement. A gate settles exactly once — resolved, rejected, aborted, or timed out. Subsequent calls to
TurnGate.resolve/TurnGate.reject/TurnGate.abortno-op. - Turn-level abort wiring. The turn's
AbortControlleris wired into every gate it opens. When the turn aborts, every open gate rejects withE_TURN_GATE_ABORTED. - Optional schema validation on resolve. If the gate carries a schema,
gate.resolve(value)validates first. Failed validation throwsE_INVALID_TURN_GATE_RESOLUTIONsynchronously in the resolver's context — the promise stays unsettled and the gate stays open. - Observability emissions on both ends.
turnGateOpenfires at construction with the full gate instance;turnGateClosedfires on settlement with thegateId,turnId,result, andsettledAt.
That is the entire contract. The ADK has no opinion about:
- Who can resolve. Authorization is your contract. The gate object is freely passable.
- How an operator sees the request. The
TurnGate.payloadis yours. Render it in a UI, push it to a queue, write it to a database — the ADK does not look at it. - How the resolver finds the gate. You can capture the gate by closure, store it in a registry keyed by
TurnGate.id, route byTurnGate.reason, or anything else. The ADK exposesidandreasonprecisely so you can. - What "denied" means. A denied approval is
gate.reject(new PermissionDeniedError(...))orgate.resolve({ approved: false })— the shape is yours. - Durability. The gate is in memory. If your process dies, the gate dies. See Durability below.
The minimum usable shape
const result = await ctx.waitFor<{ approved: boolean; note?: string }>({
reason: 'tool_approval',
payload: {
tool: 'delete_account',
args: pendingCall.args,
requestedBy: ctx.stash.get('actorId'),
},
schema: validator.object({
approved: validator.boolean().required(),
note: validator.string().optional(),
}),
timeout: 5 * 60 * 1000,
createdAt: DateTime.now(),
id: crypto.randomUUID(),
})
if (!result.approved) {
ctx.nack(new E_TOOL_PERMISSION_DENIED({ reason: result.note }))
return
}ctx.waitFor is not setTimeout
The promise can hang forever if no settlement path is wired. The timeout is the only built-in escape hatch the ADK provides. If your gate does not have a timeout and does not have a settlement path that you have personally traced from caller to resolver, the gate will eventually leak a hung turn.
The canonical applications
Tool execution is the single biggest gate site — every side effect a tool performs is a candidate for a gate. The other three canonical applications are external-system handoffs (webhooks, job queues), mid-turn human review of model output, and rate-limit / quota pauses.
→ Continue reading: Canonical gate applications
Settlement semantics
Four ways a gate can settle: resolved, rejected, aborted, or timed out. Every one of them emits turnGateClosed on the observability bus. Schema validation runs before resolved-settlement; a schema failure leaves the gate open.
→ Continue reading: Settlement semantics
What "suspends" actually means
The middleware pipelines are sequential. A gate awaited before next() blocks every downstream middleware in the same pipeline; a gate awaited after next() blocks only the post-step. Where the gate is opened decides what blocks.
→ Continue reading: What suspends actually means
Durability is not the gate's problem
The TurnGate is an in-memory promise. It does not survive process restarts. Durable HITL flows must persist the gate's TurnGate.payload and TurnGate.id themselves, recover them on restart, and re-open a fresh gate that the operator-side resolution can route to.
→ Continue reading: Durability is not the gates problem
Observability
turnGateOpen fires synchronously at construction; turnGateClosed fires on settlement. Track latency by joining the two events on TurnGateClosedEvent.gateId. Both ends live on the observability bus, never the functional bus.
→ Continue reading: Observability
What plugs in around a gate
A gate is a primitive. The pieces that turn it into a feature are seams elsewhere in the loop — tool handlers, output middleware, persistence, observability.
→ Continue reading: What plugs in around a gate
Where to go next
- Tools — where most gates open. The handler is the contract surface that side effects pass through.
- Turn Runner — TurnContext — where
ctx.waitForlives in the context surface. - Pipelines — which pipeline opens which kind of gate when the gate target is not a tool.
- Events — full payload shape for
turnGateOpen/turnGateClosed. - Failure —
E_INVALID_TURN_GATE_RESOLUTION,E_TURN_GATE_ABORTED,E_TURN_GATE_TIMEOUT,E_INVALID_INITIAL_TURN_GATE_VALUE.