---
title: "Customization & presets"
description: "The @guuey/chat override ladder — presets and policy knobs, component overrides, your own chrome over <Transcript>, and the pure headless tiers underneath."
---

`@guuey/chat` is built as a ladder. Every rung is a public export, so you customize by **dropping one level down**, never by forking: replace exactly what you need and keep everything else — including future improvements to the parts you didn't touch.

| Rung | You use                                                | You control                                       |
| ---- | ------------------------------------------------------ | ------------------------------------------------- |
| 1    | `<GuueyChat>` props                                    | Preset, policy knobs, strings, theme              |
| 2    | `components={…}` overrides                             | Any category's rendering, rest of the kit intact  |
| 3    | `<Transcript>` + `useTranscript`/`useTranscriptInputs` | All chrome: your composer, header, layout         |
| 4    | `planTranscript` / `invokeTurn` / history assembler    | Everything — the kit becomes a pure data pipeline |

**When to drop down:** stay on rung 1 until a _category of content_ needs to look different (rung 2); go to rung 3 when the _surface around the transcript_ is yours (most products with an existing design system land here — it's how guuey's own widget and Studio consume the kit); rung 4 is for non-React hosts, server-side rendering, or a fully bespoke renderer.

## Rung 1 — presets, knobs, strings

Two presets ship, as policy factories: `calmPolicy()` (the end-user default) and `debugPolicy()` (the builder view — Studio's test chat runs it). The digest of what `debug` changes:

| Category      | `calm`                            | `debug`                                      |
| ------------- | --------------------------------- | -------------------------------------------- |
| Reasoning     | Collapsed one-liner               | Expanded by default                          |
| Tool calls    | One line, args behind an expander | Expanded, args visible                       |
| Tool grouping | On (threshold 2)                  | **Off** — every tool its own row             |
| Data results  | Height-capped                     | Taller cap, byte counts always shown         |
| Prompts       | The ask + actions                 | + raw event payload expandable               |
| Errors        | Family copy                       | + the wire code and source message, verbatim |
| Status line   | Copy only                         | + literal state and elapsed time             |
| Unknown rows  | Type name + size                  | Full pretty-printed payload                  |

Every knob a preset sets is individually overridable via `policy` — applied on top of the preset:

```tsx
<GuueyChat
  endpointUrl="https://your-agent-endpoint"
  appId="your-app-id"
  policy={{
    reasoning: { show: false, expandedByDefault: false },
    toolGroup: { threshold: 4 }, // or false to disable grouping
  }}
/>
```

### Error voice

Platform errors carry stable codes, and the `error` policy group decides the wording per code — `copyByCode` supplies your own copy, `verbatimCodes` renders the platform's own message (written to be user-readable) for the codes you list, or `"all"`:

```tsx
<GuueyChat
  endpointUrl="https://your-agent-endpoint"
  appId="your-app-id"
  policy={{
    error: {
      verbatim: false,
      copyByCode: { QUOTA_EXCEEDED: "This assistant is over its daily budget — back tomorrow." },
      verbatimCodes: ["INVALID_REQUEST"],
    },
  }}
/>
```

Precedence per error: your `copyByCode` entry → the verbatim source message (when the code is listed and the message is non-empty) → the built-in family copy. (`QUOTA_EXCEEDED` is the app's per-plan generation quota — see [Plans & billing](/plans-and-billing/).)

### Strings

Every user-visible string lives in one `ChatStrings` map — status copy, composer labels, group headings, degraded-state labels. Override any subset via the `strings` prop; supplying a full translated map is the i18n path. `humanizeToolName` (exported) is the default tool-title humanizer, replaceable via `policy.tool.humanizeTitle`.

### The imperative handle

Still rung 1: driving the composer programmatically does not require ejecting. `<GuueyChat>` exposes a `GuueyChatHandle` — via `ref`, or via `onReady(handle)` for wiring-style hosts; both deliver the **same stable object**, valid for the component's whole life:

```ts
interface GuueyChatHandle {
  /** Sends through the Send button's exact gate. `false` = refused
   *  (in-flight turn, unavailable chat, blank text) — never bypasses,
   *  never touches the typed draft. */
  send(text: string): boolean;
  /** Sets the draft. `append: true` joins onto a non-empty draft with a
   *  space (never clobbers); focuses unless `focus: false`. */
  prefill(text: string, opts?: { focus?: boolean; append?: boolean }): void;
  focusComposer(): void;
  /** The CURRENT persisted thread id (live read, not a snapshot); `null`
   *  before the first turn is admitted. */
  readonly threadId: string | null;
}
```

Suggested-prompt chips are `handle.send(chip)`; stage-then-edit flows are `handle.prefill(text)`. To key your own per-thread state, read `handle.threadId` on demand, or pass `onThread={(id) => …}` — it fires when the id first hydrates and again on each **distinct** change (a new chat), never for `null` and never twice for the same id. Web-only for now — the native tier ships `<NativeTranscript>` without a native `GuueyChat`, so there is no native surface to hang a handle on yet.

## Rung 2 — component overrides

Each content category has one override slot; pass a component map and only those categories change:

```tsx
import type { ToolItem } from "@guuey/chat";
import type { TranscriptItemContext } from "@guuey/chat/react";

function MyToolLine({ item }: { item: ToolItem; ctx: TranscriptItemContext }) {
  return (
    <div className="my-tool" data-state={item.state}>
      {item.title}
    </div>
  );
}

<GuueyChat endpointUrl="…" appId="…" components={{ tool: MyToolLine }} />;
```

The slots: `userMessage`, `text`, `reasoning`, `tool`, `toolGroup`, `dataResult`, `view`, `viewRef`, `media`, `code`, `citations`, `prompt`, `notice`, `error`, `history`, `compaction`, `unknown`, `status`. Each receives a fully-derived display item (state, title, collapse state, preview text) — your component renders, it never re-derives.

The default components are exported too (`DefaultTool`, `DefaultError`, …), so an override can wrap rather than replace.

### The debug sink

Under `debugPolicy`, `useTranscript` (and `<GuueyChat>`) accept an `onDebugEvent` sink — a typed feed of what the kit actually observes, for wiring into your own log surface or devtools:

```ts
import type { ChatDebugEvent } from "@guuey/chat";

const onDebugEvent = (event: ChatDebugEvent) => {
  // "view-phase"    — a mounted view's handshake transition (incl. "expired")
  // "unknown-block"  — an unrecognized block type rendered as a labeled row
  // "turn-recovered" — a turn adopted from history after a stream stall
  console.debug("[chat]", event);
};

useTranscript({ inputs, policy: debugPolicy(), onDebugEvent });
```

Under `calmPolicy` the sink never fires — debug visibility is the debug preset's job, and end-user surfaces stay quiet by design.

## Rung 3 — your chrome, the kit's transcript

The pattern guuey's own surfaces use: keep your composer, header, and layout; render the transcript through `<Transcript>` with the two hooks doing the wiring:

```tsx
"use client";

import { useMemo, useState } from "react";
import { createWebAdapters } from "@guuey/agent-client";
import { useAgentInvoke } from "@guuey/agent-client/react";
import { Transcript, useTranscript, useTranscriptInputs } from "@guuey/chat/react";
import { calmPolicy } from "@guuey/chat";
import "@guuey/chat/styles.css";

export function MyChatSurface() {
  const adapters = useMemo(() => createWebAdapters(), []);
  const invoke = useAgentInvoke({
    endpointUrl: "https://your-agent-endpoint",
    appId: "your-app-id",
    adapters,
    preserveBlocks: true, // the transcript renders blocks, not just flat text
  });
  const { inputs } = useTranscriptInputs(invoke);
  const policy = useMemo(() => calmPolicy(), []);
  const transcript = useTranscript({ inputs, policy });
  const [draft, setDraft] = useState("");

  return (
    <div className="my-chat">
      <Transcript
        plan={transcript.plan}
        strings={policy.strings}
        onToggle={transcript.toggle}
        onViewPhase={transcript.onViewPhase}
        resolvedMounts={transcript.resolvedMounts}
      />
      {/* Your composer. `invoke.send`, `invoke.abort`, `invoke.status` are the contract. */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          void invoke.send(draft);
          setDraft("");
        }}
      >
        <input value={draft} onChange={(e) => setDraft(e.target.value)} />
        <button disabled={invoke.status !== "ready"}>Send</button>
      </form>
    </div>
  );
}
```

`useTranscriptInputs` owns the live assembly (the escalation clock, the prompt ledger); `useTranscript` owns renderer state (collapse toggles, view phases, locator resolution) and produces the plan. Both are pure React — no DOM assumptions — which is why the [React Native tier](/chat-native/) shares them unchanged.

### Resolving generative-UI views at rung 3

Generative-UI cards arrive as `ui://` locators, and something has to turn a locator into mount material: a **`UiResourceReader`**. `<GuueyChat>` builds one for you from `apiBaseUrl`; a rung-3 host wires its own and passes it to `useTranscript` as `reader`. Without one, locators render as "expired" (labeled, never blank). `createUiResourceReader` from `@guuey/agent-client` is the shipped reader — it tries the pod door first (live-turn cards) and falls through to the persisted read plane. The thread id hydrates after the first turn, so build the reader once and let it read the live id:

```tsx
import { useMemo, useRef } from "react";
import { createUiResourceReader } from "@guuey/agent-client";
import type { UiResourceReader } from "@guuey/mcp-apps-host";

// inside MyChatSurface, after `invoke`:
const threadIdRef = useRef(invoke.threadId);
threadIdRef.current = invoke.threadId;
const reader = useMemo<UiResourceReader>(
  () => async (resourceUri) => {
    const threadId = threadIdRef.current;
    if (threadId === null) return undefined; // nothing to scope the read to yet
    return createUiResourceReader({
      apiBaseUrl: "https://api.us-east-1.guuey.com/v1",
      threadId,
      endpointUrl: "https://your-agent-endpoint", // enables the pod door
      // getAccessToken / guestSecret: the same identity your transport uses
    })(resourceUri);
  },
  []
);
const transcript = useTranscript({ inputs, policy, reader });
```

Identity rule, in one clause: a bearer or a guest secret identifies the read from any origin; cookie identity alone only works same-host (custom domain) — see the [quickstart's identity note](/chat/#give-visitors-a-stable-identity).

## Rung 4 — the pure tiers

The root subpath (`@guuey/chat`) is React-free forever:

- **`planTranscript(inputs, policy, overrides?)`** — the entire rendering decision as a pure function: folded blocks + turn status in, an ordered list of display items out. Deterministic, stable keys across streaming updates. A custom renderer (or a test) consumes this directly.
- **`transcriptInputsFromHistory(load)`** — build inputs from a persisted-thread read with no hook and no DOM: the server-side rendering seam.

```ts
import { calmPolicy, planTranscript, transcriptInputsFromHistory } from "@guuey/chat";

const inputs = transcriptInputsFromHistory(await loadThread()); // your history read
const plan = planTranscript(inputs, calmPolicy());
// plan.items — render anywhere.
```

- Driving a live turn without React is the underlying SDK's `invokeTurn` — see [Build your own surface](/sdk/#beyond-react-on-the-web).
- Mounting generative-UI views without the kit is `attachViewHost` from `@guuey/mcp-apps-host` (or `<GuueyView>` from `@guuey/mcp-apps-host/react`) — the same primitives the kit's `view` slot uses.