Error codes
Structured 1xxx–9xxx error codes the runtime emits — what each range means, how to surface them, and what the recovery hint tells you.
Every error the runtime emits carries a structured code field
plus a recovery hint. Codes are organized into ranges by
subsystem so a single switch statement can group them
meaningfully without enumerating every value.
The same code that lands in an error stream event also lands
on the thrown HarnessCodedError instance for synchronous code
paths. For audit:* gate failures (a different surface — CI-time
rather than runtime), see Audit gates.
Error shape
The user-facing throwable is HarnessCodedError:
import { HarnessCodedError, isHarnessCodedError } from "@pleach/core";
class HarnessCodedError extends Error {
errorCode: HarnessErrorCode; // e.g. "SESSION_NOT_FOUND"
code: number; // e.g. 2001
message: string; // "[2001] Session not found"
recovery: string; // human recovery instruction
docs: string; // anchor link
context: Record<string, unknown>; // ctor-supplied diagnostic context
}In stream form:
{ type: "error", error: "Session not found", code: "PROVIDER_ERROR", codeNum: 5006 }Stream code (string) vs codeNum (numeric). On the thrown
HarnessCodedError, code is always the numeric catalog value (e.g. 2001).
On the stream error event, code is a string discriminator a host may
key on behaviorally (a graph fallback trigger, an exhaustion signal) — so it is
not the numeric catalog code. The stream event also carries a dedicated numeric
codeNum field (present when a catalog code is known at the emit site) that
is the catalog value — use it for range dispatch
(Math.floor(codeNum / 1000)) and numeric matching, and treat code as an
opaque host-signal string. This keeps both values available without overloading
the host trigger.
isHarnessCodedError(err) is the type-guard. getErrorInfo("SESSION_NOT_FOUND")
and getErrorInfoByCode(2001) look up the catalog entry without
constructing an instance. HarnessError (interface, exported from
the same barrel) is a separate shape — it's the persisted row used
by ErrorPropagator for centralized error tracking, not the
throwable.
Typed subclasses
In addition to HarnessCodedError, several subsystems throw
domain-specific Error subclasses you can catch by class:
| Subclass | Thrown from | Catch when |
|---|---|---|
TenantIdEmptyError | runtime.tenant facet | Constructing a runtime without tenantId in tenant-scoped mode |
HarnessModuleLoaderUnregisteredError | runtime/moduleLoaderRegistry | A host adapter forgot to call setHarnessModuleLoader |
ReplayDivergenceError / ReplayCacheMissError / ReplayUnknownEventError | @pleach/replay | Deterministic-replay parity broke |
NotImplementedError | @pleach/replay, @pleach/mcp | A surface is declared but unwired |
GatewayFamilyDeniedError / GatewayFamilyExhaustedError / GatewayTransportMissingError | @pleach/gateway | Multi-key routing failed |
ReservedNamespaceError / UnnamespacedIdError / DuplicateContributionIdError | prompts/types | Plugin contributed a prompt id that collides with a reserved namespace |
HarnessCodedError itself is instanceof Error, so the catch-all
path still works.
Ranges
| Range | Category | Typical handling |
|---|---|---|
| 1xxx | Tool errors | Retry once, then surface to the user with the tool name |
| 2xxx | Session errors | Re-create or re-load the session |
| 3xxx | Sync errors | Conflict UI; let the user pick |
| 4xxx | Storage errors | Surface as "could not save"; queue retry |
| 5xxx | Provider errors | Fallback cascade; surface family-exhausted to the user |
| 6xxx | Checkpoint errors | Skip the checkpoint operation; log; continue |
| 7xxx | Validation errors | Treat as programmer error; fix the input shape |
| 8xxx | Interrupt / approval errors | Timeout, rejection, or cancel of an approval gate |
| 9xxx | Async job errors | Backend dispatch / execution failures for long-running jobs |
| 10xxx | Caller-auth errors | The runtime caller's own identity/credential (distinct from the provider key in 5xxx and the session in 2xxx) |
| 11xxx | Runtime / infra errors | Cross-cutting: transport network, upstream service, cost budget, abort, control-flow |
1xxx — Tool errors
| Code | Meaning | Recovery |
|---|---|---|
| 1001 | Tool not found | Check the tool registry; tool may have been disabled mid-session |
| 1002 | Tool validation failed | Parameter validation failed; check arg types against the tool's schema |
| 1003 | Tool execution failed | Inspect cause; one retry usually safe |
| 1004 | Tool timeout | Lengthen the timeout if the tool is legitimately slow; otherwise surface |
Tool errors surface on the stream as tool.failed first; the
generic error event fires only when the failure escapes the
tool boundary (e.g. the registry itself errored).
2xxx — Session errors
| Code | Meaning | Recovery |
|---|---|---|
| 2001 | Session not found | The id may be stale or the session was deleted |
| 2002 | Session conflict | Another writer modified the session; fetch the latest state and retry |
| 2003 | Session expired | Re-create; carry over what you need into the new session |
| 2004 | Session locked | Another writer holds the lock; wait + retry, or surface "open elsewhere" |
2004 is the one to handle carefully — it usually means the same
session is open in another tab. The user has context the runtime
doesn't.
3xxx — Sync errors
| Code | Meaning | Recovery |
|---|---|---|
| 3001 | Sync network error | Transient transport failure; changes are queued locally and retried |
| 3002 | Sync conflict unresolved | Conflict reached a state the merger couldn't resolve; manual pick |
| 3003 | Sync version mismatch | Local version vector is behind; pull the latest and retry |
3002 is the canonical "two devices wrote to the same session and
the merger declined to choose" case. The accompanying
sync.conflict event carries the resolution the merger chose;
3002 fires when it declined. 3001 is a plain transport failure —
retry once connectivity returns.
4xxx — Storage errors
| Code | Meaning | Recovery |
|---|---|---|
| 4001 | Storage write failed | Queue + retry with backoff; surface after N attempts |
| 4002 | Storage read failed | Retry once; fall back to last-known state |
| 4003 | Storage quota exceeded | Delete old sessions or checkpoints to free up space |
Writes route through the durable-flush pipeline, which already
handles transient 4001s with retries. A 4001 on the stream
means the durable-flush retries also failed.
5xxx — Provider errors
| Code | Meaning | Recovery |
|---|---|---|
| 5001 | Provider not configured | Set up the provider credentials in environment variables |
| 5002 | Provider auth failed | Check API-key validity; the key may have expired or been revoked |
| 5003 | Provider rate limited | Cascade; if every in-family rung is rate-limited, surface family-exhausted |
| 5004 | Model not found | Check the model id; it may have been deprecated |
| 5005 | Context window exceeded | Compact context (context.summarized event) and retry once |
| 5006 | Stream error | The provider response stream was interrupted; retry the request |
| 5007 | Provider timeout | The provider took too long; retry with a smaller context |
| 5008 | Provider cascade exhausted | Every in-family rung failed; switch to a different model family or wait for capacity |
A 5003 rarely escapes the cascade — the family-strict fallback
walks every in-family rung first. When the consumer sees one as an
error event, every rung has failed; the terminal signal is
5008 (cascade exhausted), carried by the typed
ProviderFamilyExhaustedError / RetryFamilyExhaustedError. 5001
(not configured) and 5002 (auth failed) are terminal — no rung
succeeds if credentials are missing or invalid. 5006 (stream error)
and 5007 (timeout) are the transient mid-stream failures the retry
loop attempts before giving up to 5008. The runtime emits
family-exhausted separately so a UI can ask the user to pick a
different family explicitly.
6xxx — Checkpoint errors
| Code | Meaning | Recovery |
|---|---|---|
| 6001 | Checkpoint not found | Refresh the checkpoint list; id may be stale |
| 6002 | Checkpoint corrupt | The checkpoint envelope was corrupt or schema-incompatible; use an earlier one |
| 6003 | Checkpoint restore failed | State restoration failed; try a different checkpoint |
Checkpoint errors never block the turn — the runtime skips the
failing operation and continues. A 6001 on a manual
runtime.checkpoints.rollback call is the one case where the consumer
needs to surface the failure.
7xxx — Validation errors
| Code | Meaning | Recovery |
|---|---|---|
| 7001 | Invalid input | Programmer error; fix the call site |
| 7002 | Missing required field | Programmer error; pass the required field |
| 7003 | Type mismatch in input | Programmer error; check value types against the schema |
7xxx codes mean "the runtime was called incorrectly." They
shouldn't appear in production once the integration is shaken
out. Schema-version drift between the package and the database
surfaces as 2006 (Session schema mismatch), not as a 7xxx — if
you see 2006 in production, the schema bundle has advanced past
what's applied; re-run npx pleach init --apply and apply the
new migrations.
8xxx — Interrupt / approval errors
| Code | Meaning | Recovery |
|---|---|---|
| 8001 | Interrupt approval timed out | The user did not respond in time; the tool call was cancelled |
| 8002 | Tool call rejected by user | The user declined; surface the decline and offer a rephrase |
| 8003 | Interrupt cancelled | The interrupt request was cancelled before a decision was made |
The 8xxx range surfaces approval-gate outcomes, not bugs. Don't
alert on 8002 — user rejections are normal flow. Distinguish
in dashboards the same way the subagent terminalStatus
discriminator does: 8001 (timeout) and 8003 (cancelled) are
operational signals; 8002 is product feedback.
9xxx — Async job errors
| Code | Meaning | Recovery |
|---|---|---|
| 9001 | Job not found | The job may have expired or been deleted — check the job id |
| 9002 | Job dispatch failed | Backend service is unhealthy; retry the dispatch |
| 9003 | Job execution failed | Check job logs for error details; the job may need to be retried |
| 9004 | Job cancelled | The job was cancelled before completion; resubmit if the result is still needed |
The 9xxx range surfaces failures from the long-running job
substrate. A 9002 typically means the backend dispatcher
(Modal, queue worker, etc.) is unreachable — retry with backoff.
A 9003 is the job itself failing; the runtime can't recover,
the caller decides whether to retry or surface. 9004 (cancelled)
is not a failure — it's a user/operator action; don't alert on it.
10xxx — Caller-auth errors
| Code | Meaning | Recovery |
|---|---|---|
| 10001 | Caller auth required | Sign in or supply a valid API credential before retrying |
| 10002 | Caller credential invalid | The presented credential was rejected — check the key value; this is a credential problem, not an expired session, so do not force a full sign-out |
| 10003 | Caller forbidden | Authenticated but not authorized; request the required scope/role |
The 10xxx range is the runtime caller's own identity — distinct
from 5002 (the LLM provider key) and 2003 (the harness session).
10002 is the one to handle carefully: a rejected API key is a
credential problem, not a session expiry, so the affordance is
"fix your key", not "sign in again". A host maps its transport-level
401/403 reasons into these via a contributeErrorClassifiers plugin.
11xxx — Runtime / infra errors
| Code | Meaning | Recovery |
|---|---|---|
| 11001 | Network error | Transient transport failure; retry |
| 11002 | Service unavailable | Upstream returned 5xx/unavailable; retry after a short delay |
| 11003 | Cost budget exceeded | Raise the budget ceiling or reduce the work requested for the turn |
| 11004 | Aborted | The request was cancelled (caller, interrupt, or signal); retry if unintended |
| 11005 | Agent loop error | The turn could not converge; inspect the last steps and retry, possibly simpler |
The 11xxx range is cross-cutting infra not owned by a single
subsystem. 11001/11002 are transient (retry); 11003 is a
policy stop (budget); 11004 is usually intentional (user abort —
don't alert); 11005 is the turn-level "couldn't make progress"
catch-all.
Handling errors at the call site
for await (const event of runtime.executeMessage(sessionId, prompt)) {
if (event.type === "error") {
// Range-dispatch on the NUMERIC codeNum (the stream `code` is a string host
// signal — see the callout above). codeNum is absent when no catalog code was
// known at the emit site, so it falls through to the generic branch.
const range = Math.floor((event.codeNum ?? 0) / 1000); // 2 for 2xxx, etc.
switch (range) {
case 2: return reload();
case 3: return surfaceConflictUI();
case 5: return showFamilyExhaustedDialog();
case 8: return surfaceApprovalOutcome(event);
case 9: return surfaceJobFailure(event);
default: return showGenericError(event.error);
}
}
}For thrown errors (storage construction failures, config errors):
import { isHarnessCodedError } from "@pleach/core";
try {
const session = await runtime.createSession(config);
} catch (err) {
if (isHarnessCodedError(err) && err.code === 7002) {
// missing required field (VALIDATION_MISSING_REQUIRED) — fix the caller
}
throw err;
}Catching a model-not-found error
A 5004 fires when the requested model id no longer resolves —
usually because the model was deprecated or rotated out from under a
pinned config. There is no dedicated safety-refusal code in the enum;
a provider-side refusal surfaces through the ordinary provider-error
path (5006 stream error / non-clean finishReason) after the
family cascade gives up. Translate a 5004 into a user-readable
message and offer a next step:
for await (const event of runtime.executeMessage(sessionId, prompt)) {
if (event.type === "error" && event.codeNum === 5004) {
showToast({
title: "That model is no longer available.",
body: "The requested model id could not be resolved — it may have been deprecated.",
action: { label: "Pick another model", onClick: openModelPicker },
});
return;
}
}Transient vs terminal dispatcher
Not every code deserves a retry. Group 1xxx / 4xxx / parts of
5xxx as retryable; treat 2xxx / 6xxx / 7xxx as terminal
for the current call site:
import type { HarnessCodedError } from "@pleach/core";
const TRANSIENT = new Set([1003, 1004, 4001, 4002, 5003, 5006, 5007, 9002]);
const TERMINAL = new Set([2001, 2003, 5001, 5002, 6001, 7001, 7003, 8002]);
function dispatch(err: HarnessCodedError) {
if (TRANSIENT.has(err.code)) return { retry: true, after: 500 };
if (TERMINAL.has(err.code)) return { retry: false, surface: err.message };
return { retry: false, surface: "Unexpected error", log: err };
}The split tracks the recovery column in the range tables above — keep it in one place so call sites don't drift.