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 console 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). 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; pod refusals (POD_SATURATED, DRAINING) in the pod’s own words |
+ 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 |
| Protocol tool rows | Hidden (tool.showToolRows: "non-ggui"): the ggui handshake / render / consume rows stay out of the transcript; the rendered card and any failed ggui row still show |
Shown ("all") |
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 tool: { showToolRows: "all" }, // "non-ggui" (calm default) · "all" · "none" }}/>tool.showToolRows decides which tool calls become rows at all: "non-ggui" (the calm default) hides the ggui protocol narration — anything in the ggui_ namespace, bare or mcp__ggui__* — while the rendered card mount and a failed ggui row (a real refusal) always stay; "all" shows every tool row; "none" shows none. Render-side only — the agent keeps emitting the events. A host that writes a full TranscriptPolicy literal instead of overriding a preset must set it; overrides through calmPolicy() / debugPolicy() need nothing. Under the calm policy the render card stands on its own: no via ggui render line beneath it, because that is the same protocol narration. A non-ggui tool that mounts a view keeps its via <tool> line; under debug neither appears, since the tool’s own row is already on the page.
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: ["POD_SATURATED", "DRAINING", "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. As of @guuey/chat 0.18.3, verbatimCodes defaults to ["POD_SATURATED", "DRAINING"], so a pod’s capacity or draining refusal shows the pod’s own sentence — the concurrent-turn cap and the retry seconds the client cannot know — instead of a generic transient line. A verbatimCodes list you pass replaces that default (keep the two codes in it, as above, to keep the pod’s words), verbatimCodes: [] restores family copy for every code, and the pause-and-retry behaviour is the same whichever wording shows. (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.
Restyling from outside a shadow root (::part())
Section titled “Restyling from outside a shadow root (::part())”When you mount <GuueyChat> inside a shadow root of your own, the page’s stylesheet cannot reach the kit’s classes — by design. What it can reach is a closed set of CSS Shadow Parts, stamped as part attributes on the kit’s structural elements and exported as PARTS:
| Part | Element |
|---|---|
surface |
the outer surface (the theme is stamped here) |
header |
the header row — present only when header is given |
transcript |
the transcript root |
message, message user, message agent |
every message; the visitor’s bubble; the agent’s text |
composer, composer-input, composer-send, composer-stop |
the built-in composer form, its textarea, Send, Stop |
chips, chip |
the suggestion row and each chip |
/* On the shadow host element */my-chat-host::part(message user) { background: #0b5cff; color: #fff;}my-chat-host::part(header) { display: none;}The table is closed and pinned by a test: names are added, never renamed. Everything without a part is internal and may move. Light-DOM mounts keep styling by class name — part is a hook for the shadow-root case, never a selector the kit reads. Pair it with surface="bare" when the transcript should sit directly on the page’s own background.
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.