---
title: Error Handling
description: Classify ggui failures across HTTP, JSON-RPC, and the renderer's ggui:observe channel — and recover from each at its layer.
---

:::note[No wrapper SDK]
ggui has no client SDK wrapper. Errors arrive on whichever transport you're already using: HTTP status codes from the MCP transport, JSON-RPC error objects inside MCP responses, and typed `error` frames on the live-channel WebSocket. The same patterns apply against `mcp.ggui.ai` and a local `ggui serve` — only the URL and auth header change. See the [OSS Quick Start](/oss-quickstart/) for the local-first path.
:::

ggui surfaces failures at three layers. Handle each at its layer:

1. **HTTP** — the MCP transport returns `401` / `403` / `429` / `5xx` before the JSON-RPC body is even parsed.
2. **JSON-RPC** — a `tools/call` reaches the server but the server returns a `-32xxx` error code (or a tool-level failure with `isError: true`).
3. **Live channel** — a failure _after_ a successful render. Action-validation rejections ride the live-channel WebSocket as typed `error` frames carrying `code: 'CONTRACT_VIOLATION'` (numeric `-32020`) and surface in the renderer, not the agent; nothing lands on the consume buffer.

## Layer 1 — HTTP errors from the MCP transport

These come back as `IsHttpError` / response status from whichever HTTP client your MCP SDK uses. Treat them as transport failures — the server hasn't even looked at your JSON-RPC payload yet.

| Status        | Meaning                             | Retry?                           |
| ------------- | ----------------------------------- | -------------------------------- |
| `401`         | Bad / expired API key               | No — fix config                  |
| `403`         | Key valid but app not authorized    | No — fix config                  |
| `429`         | Rate-limited (`Retry-After` header) | Yes, after `Retry-After` seconds |
| `5xx`         | Transient server failure            | Yes, with exponential backoff    |
| network error | DNS / connection / TLS failure      | Yes, with exponential backoff    |

The `Retry-After` header (when present) is authoritative — honor it verbatim. See [`/api/rate-limits/`](/api/rate-limits/) for the 429 response shape (body + header) and a raw-HTTP backoff recipe.

## Layer 2 — JSON-RPC errors from the server

A 200 HTTP response can still carry a JSON-RPC error. The MCP SDKs surface these as thrown errors with a numeric `code`; raw HTTP callers see `{ "error": { "code": -32xxx, "message": "..." } }` in the response body.

| Code     | Name              | When                             | Retry?                   |
| -------- | ----------------- | -------------------------------- | ------------------------ |
| `-32700` | Parse Error       | Invalid JSON in request          | No — fix the call        |
| `-32600` | Invalid Request   | Not a valid JSON-RPC object      | No — fix the call        |
| `-32601` | Method Not Found  | Unknown tool name                | No — fix the call        |
| `-32602` | Invalid Params    | Missing / invalid tool arguments | No — fix the call        |
| `-32603` | Internal Error    | Server-side failure              | Yes, with backoff        |
| `-32001` | Unauthorized      | Invalid token or app ID          | No — fix config          |
| `-32002` | Session Not Found | Session expired or reaped        | Re-handshake + render    |
| `-32003` | App Not Found     | App ID does not exist            | No — fix config          |
| `-32004` | Production Failed | UI generation failed             | Yes — try simpler intent |
| `-32005` | Capability Denied | Requested capability not allowed | No — fix config          |
| `-32013` | Rate Limit Exceeded | Platform rate limit hit        | Yes, after backoff       |

Platform deployments also reserve the `-32010` range (`-32010` generation quota, `-32011` app limit, `-32012` concurrent-session limit, `-32020` contract violation). Full table with descriptions: [`/api/mcp-protocol/#error-codes`](/api/mcp-protocol/#error-codes).

Tool-level failures (the tool ran but returned an error result) come back as a successful JSON-RPC response with `isError: true` on the `tools/call` result content. Inspect `result.content` for the failure detail. On the OSS server the `ggui_*` tools surface their domain failures this way — `isError` results whose typed error classes carry string codes like `handshake_not_found` and `session_not_found` — while the numeric `-32xxx` table above is the protocol-level canonical set.

### Retry with exponential backoff (raw `@modelcontextprotocol/sdk`)

The SDK throws on HTTP failures and on JSON-RPC errors alike; you classify by `error.code` (JSON-RPC) or by reading the HTTP status off the underlying response. The pattern below works for both layers.

```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "my-agent", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("http://127.0.0.1:6781/mcp"), {
  requestInit: { headers: { Authorization: "Bearer dev" } },
});
await client.connect(transport);

async function callWithRetry<T>(
  name: string,
  args: Record<string, unknown>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const result = await client.callTool({ name, arguments: args });
      if (result.isError) {
        // Tool-level failure — payload is in result.content.
        throw new Error(`Tool ${name} returned isError: ${JSON.stringify(result.content)}`);
      }
      return result.structuredContent as T;
    } catch (error) {
      if (attempt === maxRetries) throw error;

      // JSON-RPC error: error.code is a -32xxx number.
      const code = (error as { code?: number }).code;

      // Permanent — surface immediately.
      if (code === -32001 || code === -32003) throw error; // auth / app config
      if (code === -32600 || code === -32601 || code === -32602) throw error; // bad request

      // Render expired — caller replays at the render layer (see below).
      if (code === -32002) throw error;

      // Rate-limited (HTTP 429) — honor Retry-After if the SDK surfaces it.
      const retryAfter = (error as { retryAfter?: number }).retryAfter;
      if (retryAfter != null) {
        await sleep(retryAfter * 1000);
        continue;
      }

      // Transient (5xx, network, -32603, -32004) — exponential backoff.
      await sleep(Math.min(1000 * 2 ** attempt, 10_000));
    }
  }
  throw new Error("unreachable");
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
```

:::tip[Claude Agent SDK callers]
With `@anthropic-ai/claude-agent-sdk`, MCP failures surface inside the `SDKMessage` stream — tool results carry `is_error: true` and the failure detail lands as the tool-result content block. The agent loop sees them just like any other tool result and can decide whether to retry, ask the user, or abort. The same `-32xxx` code semantics apply.
:::

### Recover from an expired render (`-32002`)

`-32002 Session Not Found` is the protocol-level canonical code for "the session is gone". On the OSS server, though, expiry never surfaces as a numeric JSON-RPC error from the `ggui_*` tools — the call succeeds at the JSON-RPC layer and returns `isError: true` with the failure detail in the result content:

- **`handshake_not_found`** (from `ggui_render`) — handshake records are single-use and TTL'd (10 minutes); the supplied `handshakeId` was unknown, already consumed, or expired. (A render _consumes_ its handshake — handshakes aren't bound to renders.)
- **`session_not_found`** (from `ggui_consume` / `ggui_update` / `ggui_emit` / `ggui_get_session`) — the `sessionId` was never minted, expired via TTL, or belongs to another app.

Recovery is the same in every case: re-run `ggui_handshake` → `ggui_render`, which mints a fresh `sessionId`. (Other `isError` results from `ggui_render` — contract violations, schema mismatches — leave the handshake **alive**: fix the arguments and retry on the _same_ `handshakeId`.)

```typescript
function errorText(result: { isError?: boolean; content?: unknown }): string {
  const blocks = (result.content ?? []) as Array<{ type: string; text?: string }>;
  return blocks
    .filter((b) => b.type === "text")
    .map((b) => b.text)
    .join(" ");
}

async function resilientRender(intent: string, contract: object, props: Record<string, unknown>) {
  const mintHandshake = async () => {
    const hs = await client.callTool({
      name: "ggui_handshake",
      arguments: { intent, blueprintDraft: { contract } },
    });
    return (hs.structuredContent as { handshakeId: string }).handshakeId;
  };

  // Negotiate, then render. `ggui_render` takes { handshakeId, props } —
  // accept the suggestion as-is (no `override`).
  let result = await client.callTool({
    name: "ggui_render",
    arguments: { handshakeId: await mintHandshake(), props },
  });

  // Expired / already-consumed handshake → mint a fresh
  // handshake → render pair (a fresh sessionId comes with it).
  if (result.isError && /handshakeId .* not found/i.test(errorText(result))) {
    result = await client.callTool({
      name: "ggui_render",
      arguments: { handshakeId: await mintHandshake(), props },
    });
  }
  return result;
}
```

## Layer 3 — typed failures on the live channel

The transport- and JSON-RPC-level errors above fire when the **request** fails. A separate layer surfaces **after** a successful render: when the renderer dispatches an action that fails validation (undeclared action name, payload rejected by the declared `actionSpec[name].schema`), the server answers with a typed `error` frame carrying `code: 'CONTRACT_VIOLATION'` — and nothing lands on the consume buffer.

These ride the live-channel WebSocket alongside the renderer — not the agent-side MCP poll — so there is no JSON-RPC error to catch on the agent. The renderer observes them and surfaces an error activity row.

Earlier protocol drafts reserved a `_ggui:contract-error` channel carrying a `ContractErrorPayload` envelope, but it never gained a first-party emitter and was removed in `draft-2026-06-11` — the channel, payload shape, validator, and code union are all deleted. Contract failures now surface on the call that caused them: inbound action violations answer with a `CONTRACT_VIOLATION` (`-32020`) `error` frame on the live channel, and nothing reaches the consume buffer; `ggui_render` / `ggui_emit` validation failures reject the agent's own tool call; push-time schema mismatches reject with `SCHEMA_MISMATCH_ERROR`. The reserved `_ggui:` namespace itself survives — the only first-party reserved channels today are `_ggui:lifecycle` and `_ggui:preview` — and your `streamSpec` MUST NOT declare reserved names.

### Translate errors to user-facing messages

Keep user-visible copy at one layer; never leak stack traces or JSON-RPC codes to end users.

```typescript
function getUserMessage(error: unknown): string {
  const code = (error as { code?: number }).code;
  const status = (error as { status?: number }).status;

  if (status === 401 || code === -32001) return "Authentication failed — contact support.";
  if (status === 429) return "Too many requests — please slow down.";
  if (code === -32002) return "Your session expired.";
  if (code === -32004) return "We couldn't generate that UI — try a simpler request.";
  if (status && status >= 500) return "The server is temporarily unavailable.";
  return "Something went wrong.";
}
```

---

## Renderer-side: faults stay inside the iframe

The canonical web host mounts each render in a **sandboxed iframe** via `<AppRenderer>` (from `@mcp-ui/client`, driven by `useMcpAppsChat` — see [React SDK](/sdk/react/)). That sandbox is the fault boundary: an error thrown by LLM-generated component code is contained to its own iframe and **cannot crash your host React tree**. You don't wrap renders in a host-side error boundary — the origin isolation does that structurally.

Two host-observable failure surfaces matter.

### Transport faults — `<AppRenderer onError>`

`<AppRenderer>`'s own `onError` fires for iframe/transport-level failures (the sandbox bundle failed to load, the runtime failed to boot). It receives a plain `Error`. Log it or show a placeholder in the frame:

```tsx
<AppRenderer
  toolName="ggui_render"
  sandbox={sandbox}
  html={inlinedHtml}
  onReadResource={onReadResource}
  onCallTool={onCallTool}
  onMessage={handleAppMessage}
  onError={(err) => console.warn("[render] AppRenderer error", err)}
/>
```

(This is exactly what the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample does.)

### Structured failures — the `ggui:observe` channel

After a successful mount, ggui's iframe-runtime emits a typed `ObservabilityEvent` to the parent on a dedicated postMessage channel — `{ type: "ggui:observe", event }`. This is where runtime health signals surface, so a host can show an activity row without parsing wire frames. The union is **extensibly-closed** — match the kinds you know, treat the unknown tail as generic:

| `event.kind`              | When                                                                                                                        |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `subscribe-failed`        | A non-fatal subscribe failure the reconnect ladder is handling.                                                             |
| `schema-version-mismatch` | The protocol-version handshake rejected the connection.                                                                     |
| `auth-required`           | A tool the agent calls needs OAuth consent — carries `provider` + `authUrl`. See [Auth-Gated UI](/cookbook/auth-gated-ui/). |

The `ObservabilityEvent` union + its member types are re-exported from `@ggui-ai/react` (`import type { ObservabilityEvent } from "@ggui-ai/react"`). The `{ type: "ggui:observe", event }` envelope is specified in [Bootstrap handshake](/protocol/bootstrap-handshake/).

:::note[Action-validation rejections never reach the agent]
An action that fails validation against the render's `actionSpec` is rejected at receipt with a typed `error` frame on the renderer's channel, **not** the agent's MCP tool loop — there is no JSON-RPC error for the agent to catch, and the event never lands on the consume buffer. The host observes it; the agent simply never sees the event.
:::

:::tip[Self-repair is server-side]
When generated component code fails, regeneration happens inside the server's **generation pipeline**. On self-hosted `ggui serve` that pipeline runs locally with your provider key (set `ANTHROPIC_API_KEY` or visit `/settings`); the hosted backend (coming soon) runs the same pipeline managed. There is no host-side repair component to mount on the `<AppRenderer>` path — the iframe re-mounts with corrected HTML on the next `resources/read`.
:::

---

## See also

- [`/api/mcp-protocol/#error-codes`](/api/mcp-protocol/#error-codes) — full JSON-RPC error-code table, plus `CONTRACT_VIOLATION` (`-32020`)
- [`/api/rate-limits/`](/api/rate-limits/) — HTTP `429` response shape, `Retry-After` semantics, raw-HTTP backoff recipe
- [`@ggui-ai/react` SDK reference](/sdk/react/) — `<AppRenderer>` + `useMcpAppsChat`, the web render host
- [Bootstrap handshake](/protocol/bootstrap-handshake/) — the `ggui:observe` channel + `ObservabilityEvent` catalog
- [Troubleshooting](/troubleshooting/) — common errors and their root causes