Resume & approvals
Pause for a human, exit the process, come back later — and never re-execute a finished tool.
A pause is not an error
When a tool needs sign-off, the run returns. It does not throw.
import { Agent, MemoryStore, run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const refund = tool({
name: "issue_refund",
description: "Issue a refund to the customer.",
input: z.object({ amountUSD: z.number() }),
needsApproval: ({ amountUSD }) => amountUSD > 100,
run: ({ amountUSD }) => `Refunded $${amountUSD.toFixed(2)}.`,
});
const agent = new Agent({ name: "Support", tools: [refund] });
const store = new MemoryStore();
const result = await run(agent, "Please refund me $850.", { store });
if (result.status === "awaiting_approval") {
const call = result.state.pendingApproval?.toolCall;
console.log(`needs approval: ${call?.name}`, call?.args);
// The process can exit here. The state is saved.
}Always check status
result.output is undefined when a run pauses. Check
result.status === "awaiting_approval" before using it.
Resuming
resume loads the persisted state and continues from exactly where it stopped:
import { resume } from "@rohhitrz/jonaki";
const done = await resume(result.runId, store, { agent, approved: true });
console.log(done.output); // "Your $850 refund has been processed."Denying instead feeds the refusal back to the model, which can then explain itself rather than the run simply dying:
import { resume } from "@rohhitrz/jonaki";
const denied = await resume(result.runId, store, {
agent,
approved: false,
note: "over the policy limit",
});Why agent is required
Agent config is code, and a store deliberately holds runs and sessions — never behaviour. The persisted state names the agent; you supply it.
A finished tool never runs twice
This is the guarantee that makes resume safe for anything that charges money.
It is not a special case. A tool that completed left its result message in the history, so it is no longer pending and the machine simply moves past it:
import { resume, run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
let charges = 0;
const chargeCard = tool({
name: "charge_card",
description: "Charge the customer's card.",
input: z.object({ amountUSD: z.number() }),
run: ({ amountUSD }) => {
charges++;
return `Charged $${amountUSD}.`;
},
});
// First process: charge_card runs, then a later tool pauses for approval.
const paused = await run(agent, "buy it", { store });
console.log(charges); // 1
// Second process: resume.
await resume(paused.runId, store, { agent, approved: true });
console.log(charges); // still 1 — the card was not charged againResuming after a crash
The same call handles a run whose process died mid-flight. State is persisted after every step, so a supervisor can pick up an orphan:
import { resume } from "@rohhitrz/jonaki";
const orphan = await store.loadRun(runId);
if (orphan?.status === "running") {
const finished = await resume(runId, store, { agent });
}Anything already done is in the trace and will not repeat.
Guardrail approvals
A guardrail returning require_approval pauses the same way, but carries a
guardrail rather than a tool call:
import { Agent, requireApprovalFor, run } from "@rohhitrz/jonaki";
const agent = new Agent({
name: "Finance",
guardrails: [requireApprovalFor([/wire transfer/i], { stage: "input" })],
});
const result = await run(agent, "wire transfer $5000", { store });
result.state.pendingApproval?.guardrail;
// { name: "require-approval", stage: "input", reason: "..." }Approving without a store
If you already hold the state, approve and reject are pure functions:
import { approve, reject, run } from "@rohhitrz/jonaki";
const paused = await run(agent, "refund $850");
if (paused.status === "awaiting_approval") {
const done = await run(agent, approve(paused.state, "signed off by Priya"));
// or: await run(agent, reject(paused.state, "over limit"));
}Both return a new state with the decision recorded in the trace as an
approval step.
A queue-backed flow
import { Agent, createSQLiteStore, resume, run } from "@rohhitrz/jonaki";
const store = await createSQLiteStore({ path: "./approvals.db" });
// Worker: start the run, park it if it needs a human.
export async function startRefund(request: string) {
const result = await run(agent, request, { store });
if (result.status === "awaiting_approval") {
return { needsApproval: true, runId: result.runId };
}
return { needsApproval: false, output: result.output };
}
// Later, from an approval UI — possibly a different process entirely.
export async function decide(runId: string, approved: boolean) {
const result = await resume(runId, store, { agent, approved });
return result.output;
}Edge cases
| Situation | Behaviour |
|---|---|
Run already completed or failed | Returns the stored result; nothing re-runs. |
approved omitted on a paused run | Throws, asking for a decision. |
Unknown runId | STORE_ERROR naming the id. |
| Agent does not match the run | STORE_ERROR showing expected vs received. |