jonaki

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 again

Resuming 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

SituationBehaviour
Run already completed or failedReturns the stored result; nothing re-runs.
approved omitted on a paused runThrows, asking for a decision.
Unknown runIdSTORE_ERROR naming the id.
Agent does not match the runSTORE_ERROR showing expected vs received.

On this page