Error handling
Every code Jonaki throws, what causes it, and how to fix it.
Every error Jonaki throws is a JonakiError with a stable code, structured
details, and an actionable hint.
import { JonakiError, run } from "@rohhitrz/jonaki";
try {
await run(agent, "hi");
} catch (error) {
if (JonakiError.is(error, "BUDGET_EXCEEDED")) {
console.log(error.code); // "BUDGET_EXCEEDED"
console.log(error.details); // { maxCostUSD: 0.1, spentUSD: 0.16, turn: 2 }
console.log(error.hint); // "the run reached its cost ceiling…"
}
}Messages are multi-line and readable:
JONAKI_TOOL_INPUT_INVALID
tool: get_weather
received: { "cty": "Guwahati" }
expected: { city: string }
hint: the model sent a malformed argument; jonaki will repair and retryEvery code
| Code | Cause | Fix |
|---|---|---|
NO_PROVIDER | No API key found, and neither an explicit provider nor a recognisable model prefix was given. | Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY — or pass provider, or a model like claude-sonnet-4-5. |
INVALID_CONFIG | An Agent was constructed without a name. | Give it a name; it identifies the agent in traces and handoff tool names. |
TOOL_INPUT_INVALID | The model produced arguments that failed Zod validation twice in a row. | Sharpen the tool's description and field .describe() text; simplify the schema. Single failures are repaired automatically. |
TOOL_FAILED | A tool threw, or the run was cancelled mid-tool. Also used when a paused run is resumed without a decision. | Read details.error. A throwing tool is reported to the model, so this only surfaces when the run is stopped for another reason. |
TOOL_TIMEOUT | A tool exceeded timeoutMs (default 30s). | Raise timeoutMs, or make the tool honour ctx.signal and return early. |
MAX_TURNS_EXCEEDED | The agent kept calling tools without finishing, hitting maxTurns (default 10). | Raise maxTurns, narrow the task, or check for a tool that always fails and sends the model in circles. |
BUDGET_EXCEEDED | Accumulated cost reached maxCostUSD before a model call. | Raise maxCostUSD, reduce turns, or use a cheaper model. Checked before the call, so nothing is spent past the ceiling. |
GUARDRAIL_BLOCKED | A guardrail returned { action: "block" }. | details names the guardrail, stage, and reason. Change the input or relax the guardrail. |
OUTPUT_INVALID | The final message did not match the output schema, even after one repair. | details.received holds the raw text. Simplify the schema, add .describe() hints, or make optional fields nullable. |
HANDOFF_DEPTH_EXCEEDED | Agents chained deeper than maxHandoffDepth (default 3). Also raised if a run names an agent unreachable from the root. | Raise maxHandoffDepth, or check for a handoff cycle. |
PROVIDER_ERROR | Every model in the fallback ladder failed. | details.failures lists what each said — usually credentials, a bad model id, or rate limits. |
RUN_TIMEOUT | The whole run exceeded timeoutMs. | Raise timeoutMs or reduce the work per run. |
RUN_ABORTED | The caller aborted via signal. | Expected when you cancel deliberately. |
STORE_ERROR | Misconfiguration, a missing better-sqlite3, an unreadable file, or an unknown runId. | details names the store and path. session without store is the most common cause. |
REPLAY_DIVERGED | A recording no longer matches the code: a tool was deleted, or the run needs more turns than were recorded. | Restore the tool, or re-record with JONAKI_RECORD. |
Narrowing
JonakiError.is is a type guard and takes an optional code:
import { JonakiError } from "@rohhitrz/jonaki";
if (JonakiError.is(error)) {
// error.code, error.details, error.hint are all available
}
if (JonakiError.is(error, "RUN_TIMEOUT")) {
// narrowed to that specific failure
}Plain instanceof works too:
import { JonakiError } from "@rohhitrz/jonaki";
if (error instanceof JonakiError) {
console.log(error.code);
}What does not throw
Two things people expect to throw but do not:
- A tool that fails. The error is reported to the model as a tool result so
it can recover. Look for
status: "error"steps in the trace. - A run that pauses for approval. It returns with
status: "awaiting_approval"and no output.
import { run } from "@rohhitrz/jonaki";
const result = await run(agent, "refund $850", { store });
if (result.status === "awaiting_approval") {
// not an error — a decision is needed
}Failures are still persisted
When a run fails with a store attached, the failed state is saved before the error is thrown — so the trace survives:
import { run } from "@rohhitrz/jonaki";
try {
await run(agent, "hi", { store, maxTurns: 2 });
} catch {
const failed = await store.loadRun(runId);
console.log(failed?.error); // { code: "MAX_TURNS_EXCEEDED", message: "…" }
console.log(failed?.steps); // everything that happened first
}Retrying
Codes tell you what is worth retrying:
import { JonakiError, run } from "@rohhitrz/jonaki";
const RETRYABLE = new Set(["PROVIDER_ERROR", "RUN_TIMEOUT"]);
async function runWithRetry(input: string, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await run(agent, input);
} catch (error) {
const retryable =
JonakiError.is(error) && RETRYABLE.has(error.code) && attempt < attempts;
if (!retryable) throw error;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250));
}
}
}Do not blindly retry BUDGET_EXCEEDED, GUARDRAIL_BLOCKED, or
MAX_TURNS_EXCEEDED — those mean the run did what you asked it to.
Listing codes at runtime
import { JONAKI_ERROR_CODES } from "@rohhitrz/jonaki";
console.log(JONAKI_ERROR_CODES.length); // 15