HITL & checkpoints
Human-in-the-loop pause and resume, RunCheckpoint snapshots, SessionPermissionCache, and durable RunCheckpointStore.
HITL & checkpoints
When a policy (or guardrail) returns requires_approval: true, the runner pauses the agent loop, emits an approval trace event (phase: "requested"), and returns AgentResult.status === "paused" with a RunCheckpoint and pending_approvals.
The caller surfaces approvals to a human, then resumes with runAgentResume or runAgentResumeStream, supplying ApprovalResponse[].
Pause flow
sequenceDiagram
participant Runner
participant Policy
participant Human
Runner->>Policy: evaluate(pendingAction)
Policy-->>Runner: requires_approval: true
Runner->>Runner: mint RunCheckpoint
Runner-->>Human: paused + pending_approvals
Human-->>Runner: ApprovalResponse[]
Runner->>Runner: runAgentResume(checkpoint, responses)RunCheckpoint
A durable snapshot of run state at the pause point:
interface RunCheckpoint {
schema_version: 1 | 2;
spec_id: string;
iteration: number;
usage: { prompt: number; completion: number };
root_messages: Message[];
subagent_sessions: SerializedSession[];
pending: PendingApproval[];
checkpoint_id?: string | null;
}pending lists every tool call awaiting a decision. Each PendingApproval carries an id (match this in ApprovalResponse), the tool call payload, and attribution metadata.
Resume API
import { runAgent, runAgentResume } from "@maniac-ai/agents";
const paused = await runAgent(spec, "Delete account 12345");
if (paused.status !== "paused") throw new Error("expected pause");
const result = await runAgentResume(
spec,
paused.checkpoint!,
[{ id: "call_delete", decision: "approve" }]
);
// result.status === "completed"ApprovalResponse.decision is "approve" or "deny". Denied calls return an error result to the model; approved calls execute and the loop continues.
Streaming resume
import { runAgentStream, runAgentResumeStream } from "@maniac-ai/agents";
for await (const env of runAgentStream(spec, query)) {
if (env.type === "paused") {
// surface env.pending to reviewer before terminal result
}
}
for await (const env of runAgentResumeStream(spec, checkpoint, responses)) {
if (env.type === "result") { /* done */ }
}See StreamEnvelope for the tagged union consumed by stream iterators.
SessionPermissionCache
Wrap any inner PermissionPolicy with a TTL cache so repeated identical calls within a session skip re-evaluation.
import { SessionPermissionCache, StaticPermissionPolicy } from "@maniac-ai/agents";
const policy = new SessionPermissionCache(
new StaticPermissionPolicy([/* rules */], { allowed: true, requires_approval: false }),
{ ttlSeconds: 300 }
);Cache key: principal:toolset:tool:hash(args).
Important: requires_approval decisions are never cached — each approval pause is one-shot. After the human approves, call remember() to pin an allow decision for the rest of the session ("always allow this" UX):
policy.remember(pendingAction, { allowed: true, requires_approval: false });RunCheckpointStore
For channel bots and long-lived hosts, persist checkpoints to Memory so approval buttons survive process restarts.
import { RunCheckpointStore } from "@maniac-ai/agents/memory";
const store = new RunCheckpointStore(memory, { claimTtlSeconds: 86_400 });
await store.save("support", checkpoint, { threadId: "slack-thread-123" });
const claimed = await store.claim("support", checkpointId, { threadId: "slack-thread-123" });Lifecycle states: pending → resolving → resolved. Claim TTL prevents double-resolution when multiple workers handle the same approval webhook.
The Slack channel example (packages/agents/examples/channels-slack-bot.ts) combines StaticPermissionPolicy, ChatToolset({ requireApproval: true }), and serveChannels for end-to-end HITL.
Trace events
Approval phases emit approval trace events you can render in chat UIs:
| Phase | Meaning |
|---|---|
requested | Run paused, awaiting human |
resolved | Human responded, resume in progress |
Export via Tracer or OTelTracer.