Monitor

Check in with the receipts

Evidence-carrying human consent for consequential tool actions. A tool declares checkIn; the ask rides an evidence pack (willDo / read / drivers / trail); the decision lands as a typed record.

An agent is about to issue a $5,000 refund. You don't want a policy to silently allow or block it — you want a person to say yes, and you want them to see WHY the agent is asking: what it will do, what context it read, which context drove the choice. That's a check-in. OpenWorker-class agents check in; agentfootprint checks in with the receipts.

Check-in vs. the other gates

Three independent gates run before a tool executes, in this order:

GateQuestionThis guide?
Permission (Security)Is this call allowed by policy?no
Arg-validationDo the args match the schema?no
Check-inDoes a human consent to this consequential action?yes

Permission is a policy engine (allow/deny/halt, no human). Check-in is consent — a human decides, with an evidence pack in front of them. A call the policy denies never reaches the check-in gate; a call awaiting consent never resolves credentials or executes.

Declare the demand

A tool declares checkIn'always', or a (args, ctx) => boolean predicate for the "only high-effect" case. Configure the evidence pack on the builder with .checkIn({ evidence }) (optional — 'standard' is the default).

return Agent.create({ provider: provider ?? defaultProvider(), model: 'mock' })  .system('You are a refunds assistant. Refund only verified orders; confirm the amount first.')  .tool(    defineTool<{ amount: number }, string>({      name: 'issue_refund',      description: 'Issue a refund to the customer',      inputSchema: {        type: 'object',        properties: { amount: { type: 'number' } },        required: ['amount'],      },      // Demand a human check-in ONLY for big refunds (a predicate; use      // 'always' to gate every call). A refund under the line runs untouched.      checkIn: (args) => args.amount > 1000,      execute: ({ amount }) => `refunded ${amount}`,    }),  )  // Configure the evidence pack that rides the ask (optional — 'standard' is  // the default; 'minimal' ships only `willDo`; or pass your own assembler).  .checkIn({ evidence: 'standard' })  .build();

A tool WITHOUT checkIn is byte-identical to before — no gate, no events, no pause.

The ask carries the receipts

When the demand trips, agent.run() pauses BEFORE the tool executes and returns a RunnerPauseOutcome whose checkIn field is a typed CheckInRequest. isCheckInPause(outcome) distinguishes it from a plain askHuman pause.

const outcome = await agent.run({ message: input });if (isCheckInPause(outcome)) {  // The receipts — render these for the human who decides.  const { tool, args, intent, evidence } = outcome.checkIn;  console.log(`Approve "${tool}"?  args=${JSON.stringify(args)}`);  console.log(`  will do : ${evidence.willDo}`);  console.log(`  because : ${intent ?? '(no stated reason)'}`);  console.log(`  drivers : ${(evidence.drivers ?? []).slice(0, 3).map((d) => d.id).join(', ')}`);  // Persist `outcome.checkpoint` (JSON) until the human answers.}return outcome;

The evidence pack ('standard') has four plain-named fields:

  • willDo — a plain-words claim: the tool description + the rendered args.
  • read — what context this run consumed, one frame per piece (system rules, the task, results from earlier tools).
  • drivers — which context drove THIS choice, ranked. The default scorer is deterministic and makes zero LLM calls; pass your own (e.g. an embedding-backed one wrapping explainChoice) via .checkIn({ scorer }).
  • trail — a compact grouped run-so-far summary.

evidence: 'minimal' ships only willDo (zero cost). The whole pack survives structuredClone + JSON, so it can ride the checkpoint to another process.

The ask can name the screen that collects the decision

Since 9.24.0 a tool can also declare which registered frontend component should collect its check-in decision — ids and props, never markup:

defineTool({
  name: 'issue_refund',
  description: 'refund a customer',
  checkIn: (args) => args.amount > 1000,
  checkInComponent: {
    componentId: 'approve-panel',       // your frontend's registry id
    props: { tone: 'serious' },         // small inline JSON
  },
  execute: ({ amount }) => `refunded ${amount}`,
});

It rides the CheckInRequest as request.component (and PendingAsk.component when the pause is served over a host). Declaring checkInComponent without checkIn is refused at defineTool — a component for a gate that never fires is configuration that lies. A propsRef here must resolve in the RUN's artifact scope when the gate trips; note a check-in fires before execute, so the tool cannot mint the ref mid-call — static declarations usually want inline props, and the mint-then-askHuman flow (see Pause / Resume) is the door for big payloads.

The decision is a record

The human answers with checkInApproved({ by, note? }) or checkInDeclined({ by, note? }), and you agent.resume(checkpoint, decision). On approve the tool executes normally (same iteration semantics as any resume); on decline the model receives "declined by human: <note>" as the tool result and adapts in-loop.

// Attach the built-in store to capture the ask + decision as an audit record.const audit = new CheckInRecorder();agent.attach(audit);const final = await agent.resume(checkpoint, checkInApproved({ by: 'alice@ops', note: 'verified' }));// audit.getDecisions() → [{ toolName: 'issue_refund', approved: true, by: 'alice@ops', ... }]return final;

Both the ask and the decision fire typed events — agentfootprint.checkin.request and agentfootprint.checkin.decision — that any recorder can observe via the emit channel, and the built-in CheckInRecorder captures them as a queryable audit trail (getRequests() / getDecisions() / getStats()). When the ask carried a checkInComponent, the decision event and the recorder's record additionally carry the componentId that collected the answer — the trace says which surface asked, while the decision itself stays the same structured fact.

Resuming a check-in with anything that is not a CheckInDecision — a bare string, an object of your own shape, nothing at all — raises DecisionRequiredError (code: 'ERR_DECISION_REQUIRED'). Nothing executes and the checkpoint is unchanged, so you answer the same one properly and resume again. Over standingAgent it is a 400: the session is fine, the request was not.

The error names which gate is outstanding (gate: 'checkIn' | 'ask'), the tool, the middleware that asked when it was an ask, and received — the shape that arrived, never its contents, because a resume payload is caller data and an error message ends up in logs.

Ask which kind of pause you are holding with pauseDemandsDecision(outcome.pauseData): it returns a ConsentGate ({ kind, toolName?, middleware? }) for a check-in or a middleware ask, and undefined for a plain askHuman / pauseHere pause — where the human's answer is the tool's result and any value is accepted. ConsentGateKind is the closed two-arm union that kind narrows to; a third consent gate would be a deliberate edit, never an arm that appears by surprise.

Before 8.13.0 a mis-shaped resume declined silently and filed the decision against by: 'unknown' — a consent record naming a person who was never asked. Governance never silently invents a decision, for the same reason it never silently drops one.

Process A → checkpoint → Process B

Check-in rides the same JSON checkpoint machinery as pause / resume: the ask happens in one process, the human decides hours later, and a fresh agent resumes from the stored checkpoint. Build the agent fresh in Process B — only the checkpoint (and the decision) cross the boundary.

Anti-patterns

  • Don't reach into outcome.pauseData for a check-in — use outcome.checkIn (typed) and isCheckInPause(outcome).
  • Don't use checkIn as a policy engine. Policy is permissionChecker (deny/halt, no human); check-in is consent (a person decides). They compose — permission runs first.
  • Don't resume a check-in pause with a bare string — pass a CheckInDecision (checkInApproved / checkInDeclined). Since 8.13.0 a malformed resume is refused with DecisionRequiredError rather than silently declined, so a consequential tool never runs by accident and you find out.
  • Don't configure .checkIn() on an agent where no tool declares checkIn — that gate can never be consulted, and build() refuses it. .checkIn() decides HOW the ask is assembled; a tool's checkIn: 'always' (or a predicate) is what MAKES the ask.
  • Don't pair evidence: 'minimal' with a scorer. The scorer ranks drivers, which the minimal pack does not build — build() refuses the pairing rather than resolving a scorer nothing will call.
  • Don't put a toolMiddleware ask and a tool's own checkIn on the same call. They ask different questions — the rule's, and the tool's with the evidence pack attached — so approving one is not answering the other, and a resumed dispatch has no second checkpoint to ask on. Keep one gate per tool. (A selective checkIn whose predicate does not trip for those arguments is fine: since 8.13.0 the demand is evaluated, not merely noticed.)

Run the demo

The flagship demo — the coworker with the receipts — is a runnable "AI coworker" that drafts a weekly status doc, saves it (a save_draft tool with no check-in — real work, no interruption), then reaches for the one consequential action: posting to the team channel. That post_to_channel tool declares checkIn: 'always', so the run pauses and renders the receipts (willDo / read / drivers / trail) in your terminal.

# non-interactive approve (the tool runs)
npm run example examples/features/34-checkin-coworker.ts -- --approve

# non-interactive decline (watch the model adapt: it keeps the draft, posts nothing)
npm run example examples/features/34-checkin-coworker.ts -- --decline --note "not this week"

# interactive — a TTY prompts you to approve or decline
npm run example examples/features/34-checkin-coworker.ts

# real Claude instead of the deterministic $0 mock (only if ANTHROPIC_API_KEY is set)
npm run example examples/features/34-checkin-coworker.ts -- --live

The default provider is a deterministic mock, so it runs for anyone with zero setup; the end of the run prints the CheckInRecorder audit trail (asks + decisions with by / note).

Next steps

On this page