> ## Documentation Index
> Fetch the complete documentation index at: https://obversa.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Callback Gates

> Stop the run for one question a person, an agent or a service must answer, and carry on with the answer.

Stop the run at one question, send it to whoever answers, and carry on from
that exact step when the answer comes back in the shape you asked for. This
is the mechanism under a person's decision, human-in-the-loop as a step,
and it serves an agent or a service the same way. For the idea of a check
that fails a step and runs it again, read [Feedback loops](/docs/concepts/feedback-loops);
this page is the gate itself.

The smallest gate posts one question, claims it, answers it and replays the
history:

```ts examples/callback-gate.ts (excerpt) {1-10} theme={null}
const definition: CallbackGateDefinition = {
  gateId: 'release-approval',
  gateVersion: 1,
  decisionText: 'Approve release abc123?',
  responseSchema: {
    type: 'object',
    properties: { approved: { type: 'boolean' } },
    required: ['approved'],
  },
  input: { revision: 'abc123' },
};

const client = createCallbackClient();
const request = createCallbackGate(definition);
client.post(request);
```

`createCallbackGate` turns the definition into a request with an identity,
and `post` puts it on the client. A gate needs:

* **`gateId` and `gateVersion`**, which name the question across runs.
* **`decisionText`**, the question a person or a router reads.
* **`responseSchema`**, the shape every answer must have. The run branches
  on the typed answer and never asks a model what it meant.

## Route the question

A router takes the question to whoever answers it: a surface in a browser
for a person, an engine call for an agent, an API call for a service. Two
routers can't hold one question at once:

```ts examples/callback-gate.ts (excerpt) {1-2,4,5} theme={null}
const firstClaim = client.claim(request.requestId, 'router-a');
const blockedClaim = client.claim(request.requestId, 'router-b');
if (!firstClaim.ok) throw new Error('The first router did not claim the request.');
const released = client.release(request.requestId, firstClaim.claimToken);
const submitted = await directRouter(
  client,
  request,
  'router-b',
  () => ({ approved: true }),
);
```

The first router claims the request and the second is refused. When the
first releases it with its claim token, the second claims and answers it.
`directRouter` is the simplest router: it claims, asks the function you
give it, and submits. On submit the run checks that the required fields
are there and have the right types, then records the answer. Check any
deeper rule in your host before it submits. If a router dies between claim
and answer, the question is still there and still claimed until the claim
is released.

## Wait attended or unattended

An `approval()` step waits in one of two ways, and both record the question
the same way. Attended: a person is at the run, so the process stays up and
waits. Set `onCallback: 'wait'` for this; it's never the default, and a run
exits unless you ask it to wait, whichever client it has. Unattended:
nobody is attached, so the run records the question and exits. A schedule
starts the same file again with `resume: true`, the run reads its record
first, and if the answer has arrived the step carries on. If it hasn't, the
run exits again with the same recorded pause and asks nothing twice.

The answer arrives through the stored callbacks client, made with
`createStoredCallbackClient` and passed to `run()` as `callbacks`. Without
it, `run()` gives each run a fresh in-memory client, and its questions end
with the run. Nothing in the record says which mode asked the question.

A team review that uses a callback gate waits only unattended. It posts its
question and pauses straight away, even with `onCallback: 'wait'`. Start the
run again with `resume: true` after the answer arrives.

## Replay the history

The client's history is the record of the question. Replaying it rebuilds
the pauses, so a fresh process knows what is still waiting:

```ts examples/callback-gate.ts (excerpt) {9} theme={null}
const sameQuestion = createCallbackGate({
  ...definition,
  presentation: { theme: 'dark' },
});
const changedQuestion = createCallbackGate({
  ...definition,
  input: { revision: 'def456' },
});
const replayed = replayCallbackClient(client.history());
```

The request digest covers the gate id and version, the question text, the
response schema and the input. Change any of them and it's a new question,
so an answer to the old one doesn't carry over. Presentation hints, such as
a theme or a pane placement, are outside it: changing only how a question
is shown keeps it the same question. Run the file with
`npx tsx callback-gate.ts`; it runs offline in under a second:

```json Output theme={null}
{
  "requestId": "release-approval#1#3a32b8f32a48c2b1ddc28ecec2c3035d4619311acf96364200e8c25efe94cd05",
  "digest": "3a32b8f32a48c2b1ddc28ecec2c3035d4619311acf96364200e8c25efe94cd05",
  "sameQuestionId": true,
  "changedQuestionId": true,
  "blockedKind": "claimed",
  "released": true,
  "submitted": true,
  "events": [
    "callback-requested",
    "callback-claimed",
    "callback-released",
    "callback-claimed",
    "callback-submitted"
  ],
  "replayedPending": 0
}
```

The same question with a new theme kept its id; the same question about a
new revision got another. The second router was blocked while the first
held the claim, then answered after the release. The replay left nothing
pending.

## Add a person's decision

`approval` wraps the gate for the common case of one question in a
workflow. It asks through the run's callbacks client. A yes passes the
step. A no goes to the step you name as its `target`, with the person's
note as the finding. No answer pauses the run with the request pending.
[A person decides](/docs/patterns/approval) shows it in a running file, and a
host answers through the same client, routers and stored history.

Put a gate where a person must say yes: before a release, before a
destructive change, before spending money. Give it a
[surface](/docs/concepts/surfaces) and the run pauses with the question open in
a browser. For a decision about a proposed change, bind the approval to the
evidence with [proof acceptance](/docs/reviewing/proof-acceptance): an approval
gate can bind its answer to proof, input artifacts, output bytes,
permissions and a workspace anchor, and the host checks those still match
before it acts.

## Failure

* **An answer of the wrong shape** is refused at submit, and the question
  stays open.
* **A router that dies** leaves the question claimed. Another router can
  take it once the claim is released with its token.
* **An answer doesn't restart an exited run.** The host reads the decision
  first, so it can decide an approval needs a second look, then resumes.

<Accordion title="Full file">
  ```ts examples/callback-gate.ts theme={null}
  import {
    createCallbackClient,
    createCallbackGate,
    directRouter,
    replayCallbackClient,
    type CallbackGateDefinition,
  } from '@obversa/runtime';

  const definition: CallbackGateDefinition = {
    gateId: 'release-approval',
    gateVersion: 1,
    decisionText: 'Approve release abc123?',
    responseSchema: {
      type: 'object',
      properties: { approved: { type: 'boolean' } },
      required: ['approved'],
    },
    input: { revision: 'abc123' },
  };

  const client = createCallbackClient();
  const request = createCallbackGate(definition);
  client.post(request);

  const firstClaim = client.claim(request.requestId, 'router-a');
  const blockedClaim = client.claim(request.requestId, 'router-b');
  if (!firstClaim.ok) throw new Error('The first router did not claim the request.');
  const released = client.release(request.requestId, firstClaim.claimToken);
  const submitted = await directRouter(
    client,
    request,
    'router-b',
    () => ({ approved: true }),
  );

  const sameQuestion = createCallbackGate({
    ...definition,
    presentation: { theme: 'dark' },
  });
  const changedQuestion = createCallbackGate({
    ...definition,
    input: { revision: 'def456' },
  });
  const replayed = replayCallbackClient(client.history());

  console.log(JSON.stringify({
    requestId: request.requestId,
    digest: request.digest,
    sameQuestionId: sameQuestion.requestId === request.requestId,
    changedQuestionId: changedQuestion.requestId !== request.requestId,
    blockedKind: blockedClaim.ok ? null : blockedClaim.kind,
    released: released.ok,
    submitted: submitted.ok,
    events: client.history(request.requestId).map((event) => event.kind),
    replayedPending: replayed.listPending().length,
  }, null, 2));
  ```
</Accordion>

## Next steps

* [A person decides](/docs/patterns/approval): the step built on this gate, with
  a no that runs the writer again.
* [Proof-bound acceptance and approval](/docs/reviewing/proof-acceptance): bind
  the answer to the exact bytes it judged.
* [The record](/docs/concepts/record): what a resumed run skips, repeats and
  asks again.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.