# Error codes (/docs/error-codes)



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](/docs/audit-gates).

<SourceMeta source="{ label: &#x22;src/errors/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/errors&#x22; }" />

## Error shape [#error-shape]

The user-facing throwable is `HarnessCodedError`:

```typescript
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:

```typescript
{ type: "error", error: "Session not found", code: "PROVIDER_ERROR", codeNum: 5006 }
```

<Callout type="info">
  **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*&#x2A; 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
  &#x2A;*`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.
</Callout>

`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 [#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 [#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 [#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 [#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 [#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 [#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 `4001`s with retries. A `4001` on the stream
means the durable-flush retries also failed.

## 5xxx — Provider errors [#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 [#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 [#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 [#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 [#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 [#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 [#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 [#handling-errors-at-the-call-site]

```typescript
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):

```typescript
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 [#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:

```typescript
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 [#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:

```typescript
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.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Stream events" href="/docs/stream-events" description="The `error` event variant and the rest of the stream." />

  <Card title="SessionRuntime" href="/docs/session-runtime" description="Where errors originate." />

  <Card title="Storage" href="/docs/storage" description="The 4xxx range — what the adapter can throw." />
</Cards>
