Skip to content
Early preview — you found Guuey before launch · official launch soon

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.

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
}}
/>

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.)

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.

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.

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.

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.

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.

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 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.
  • 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.