Customization & presets
read as.md@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
Section titled “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:
<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
Section titled “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":
<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.)
Strings
Section titled “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
Section titled “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:
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
Section titled “Rung 2 — component overrides”Each content category has one override slot; pass a component map and only those categories change:
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
Section titled “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:
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
Section titled “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:
"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 shares them unchanged.
Resolving generative-UI views at rung 3
Section titled “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:
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.
Rung 4 — the pure tiers
Section titled “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.
import { calmPolicy, planTranscript, transcriptInputsFromHistory } from "@guuey/chat";
const inputs = transcriptInputsFromHistory(await loadThread()); // your history readconst 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. - Mounting generative-UI views without the kit is
attachViewHostfrom@guuey/mcp-apps-host(or<GuueyView>from@guuey/mcp-apps-host/react) — the same primitives the kit’sviewslot uses.