Skip to content
5 min read · 941 words

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:

  1. One settlement. A gate settles exactly once — resolved, rejected, aborted, or timed out. Subsequent calls to TurnGate.resolve / TurnGate.reject / TurnGate.abort no-op.
  2. Turn-level abort wiring. The turn's AbortController is wired into every gate it opens. When the turn aborts, every open gate rejects with E_TURN_GATE_ABORTED.
  3. Optional schema validation on resolve. If the gate carries a schema, gate.resolve(value) validates first. Failed validation throws E_INVALID_TURN_GATE_RESOLUTION synchronously in the resolver's context — the promise stays unsettled and the gate stays open.
  4. Observability emissions on both ends. turnGateOpen fires at construction with the full gate instance; turnGateClosed fires on settlement with the gateId, turnId, result, and settledAt.

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.payload is 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 by TurnGate.reason, or anything else. The ADK exposes id and reason precisely so you can.
  • What "denied" means. A denied approval is gate.reject(new PermissionDeniedError(...)) or gate.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

ts
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.waitFor lives 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.
  • FailureE_INVALID_TURN_GATE_RESOLUTION, E_TURN_GATE_ABORTED, E_TURN_GATE_TIMEOUT, E_INVALID_INITIAL_TURN_GATE_VALUE.