---
url: 'https://adk.nht.io/the-loop/gates.md'
description: >-
  ctx.waitFor and TurnGate — the cooperative suspension primitive that
  human-in-the-loop, RBAC, and external-approval flows are built on.
---

# Gates

## LLM summary — Gates

* **Position the ADK takes:** the ADK cannot make tools safe, enforce permissions, validate authority, or verify identity for you. It supplies the primitive — `TurnGate` via `ctx.waitFor` — and tells you to use it. Any answer about agent safety, RBAC, or human-in-the-loop must route through gates, not through invented runner features.
* A **gate** is a cooperative suspension point. `await ctx.waitFor(rawGate)` stalls the active pipeline until the gate settles. Placement determines the blast radius: the pipeline stops exactly where you invoke the wait.
* **Suspension is sequential pipeline behavior, not magic.** Middleware pipelines progress via `await next()`, so awaiting a gate before `next()` blocks the entire downstream chain while awaiting after `next()` blocks only the return path. In tool handlers, the gate stalls that specific dispatch iteration without affecting other concurrent turns on the same runner. Because the pipeline *is* the execution flow, the turn's progress is frozen at the suspension point; there is no scenario where the 'awaiter' suspends but the turn continues past it. Every component downstream of the suspension point is halted.
* Created via `ctx.waitFor()`. Middleware cannot construct `TurnGate` directly — the class is exported as a type only; the runner is the sole constructor site. This is deliberate: the runner injects `turnId` and `abortSignal` so the gate participates in turn-level cancellation.
* Four settlements, each emits `turnGateClosed` on the observability bus: resolved (optional schema validates first; failed validation throws `E_INVALID_TURN_GATE_RESOLUTION` synchronously in the resolver's context and leaves the gate open), rejected (`gate.reject(err)`), aborted (turn-level abort signal or `gate.abort()` → `E_TURN_GATE_ABORTED`), timed out (`E_TURN_GATE_TIMEOUT`).
* A gate is a **primitive**, not a feature. The ADK owns the suspension lifecycle, the abort wiring, the schema check on resolve, and the open/closed events. It owns **nothing** about who is allowed to resolve, how the operator sees the request, where the gate ID is stored, how a UI re-attaches after a reload, or what an RBAC denial looks like — that is your contract.
* Gates do **not** persist. The `TurnGate` instance lives in memory inside the runner closure. If the process dies, every open gate dies with it. Durable HITL flows must persist the gate's `payload` and `id` themselves, recover them on restart, and re-open a fresh gate that the operator-side resolution can route to.
* The canonical applications are **gating tool execution** (RBAC, human approval, second-factor elevation), **external-system handoffs** (queue completion, webhook callback), **mid-turn human review of model output**, and **rate-limit / quota pauses**. Tool execution is the single biggest gate site — every side effect a tool performs is a candidate for a gate. The ADK does not ship any of these — it ships the seam they all sit on.
* Common mistake: treating `gate.resolve(value)` as the place to do work. Resolution is a *signal* — the work that produced `value` happens elsewhere (a UI submit handler, a webhook receiver, a job-queue worker). The middleware on the other side of `waitFor` is what acts on the resolved value.
* Common mistake: assuming the gate timeout owns the SLA. A timed-out gate rejects the awaiter but does *not* cancel the operator-side request. You must invalidate or revoke the pending request on your side too.

::: danger 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`](../api/), 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`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#resolve) / [`TurnGate.reject`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#reject) / [`TurnGate.abort`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/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`](./failure).
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`](./failure) 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`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#property-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`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#property-id), route by [`TurnGate.reason`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#property-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](./gates/durability-and-plugs#durability-is-not-the-gates-problem) 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
}
```

::: warning `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](./gates/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](./gates/lifecycle#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](./gates/lifecycle#what-suspends-actually-means)

## Durability is not the gate's problem

The [`TurnGate`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate) is an in-memory promise. It does not survive process restarts. Durable HITL flows must persist the gate's [`TurnGate.payload`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#property-payload) and [`TurnGate.id`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate#property-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](./gates/durability-and-plugs#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`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnGateClosedEvent#property-gateid). Both ends live on the observability bus, never the functional bus.

→ Continue reading: [Observability](./gates/durability-and-plugs#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](./gates/durability-and-plugs#what-plugs-in-around-a-gate)

## Where to go next

* [Tools](./tools) — where most gates open. The handler is the contract surface that side effects pass through.
* [Turn Runner — TurnContext](./turn-runner#turncontext) — where `ctx.waitFor` lives in the context surface.
* [Pipelines](./pipelines) — which pipeline opens which kind of gate when the gate target is not a tool.
* [Events](./events#observability-events) — full payload shape for `turnGateOpen` / `turnGateClosed`.
* [Failure](./failure) — `E_INVALID_TURN_GATE_RESOLUTION`, `E_TURN_GATE_ABORTED`, `E_TURN_GATE_TIMEOUT`, `E_INVALID_INITIAL_TURN_GATE_VALUE`.
