---
title: "Drop-in chat UI"
description: "Mount @guuey/chat's <GuueyChat> and get the transcript UI guuey's own surfaces use — streaming, tools, generative-UI cards, every failure state designed."
---

[`@guuey/chat`](https://www.npmjs.com/package/@guuey/chat) (MIT) is the default chat UI for guuey agents — the same transcript tier guuey's own surfaces run: the embeddable widget, Studio's test chat, and the Portal apps all render through this package. Mount it with your agent's endpoint and you get a production chat surface; keep customizing until only the parts you care about are yours.

The design bar it holds: **an agent transcript stays readable in every weird case** — a 40-second turn running a dozen tools, a generative view mounting mid-stream, a failed tool, a cold-started agent, a dropped connection. Each of those is a designed state, not an accident.

```sh
npm install @guuey/chat
```

## Ten lines

```tsx
"use client";

import { GuueyChat } from "@guuey/chat/react";
import "@guuey/chat/styles.css";

export function AgentChat() {
  return (
    <GuueyChat
      endpointUrl="https://your-agent-endpoint" // printed by `guuey deploy`
      appId="your-app-id"
    />
  );
}
```

Both values come from your deploy: the endpoint is printed by `guuey deploy` (and shown by `guuey apps get`), the app id by `guuey apps list`. Before your page goes to production, [allow its domain](/sdk/#allow-your-domain) and verify with `guuey apps check --origin` — same rules as any custom surface.

## What you get for free

- **Streaming text** as sanitized markdown — raw HTML from the model is structurally unrepresentable in the rendering pipeline, not merely escaped.
- **Tool activity as calm one-liners** — a running tool is a live status line; consecutive finished tools collapse into one expandable "Ran N tools" row; a tool that produces something visible (a generative-UI card, media, a prompt) always renders in place, never buried in a collapse.
- **Generative-UI cards mount sandboxed** — agents using ggui render interactive views inline, in a locked-down iframe, with the negotiation states labeled ("no host answer" instead of a blank frame).
- **Cold starts are narrated** — "Connecting…" escalates to "Starting your agent…" with a patience note, driven by the platform's real turn lifecycle.
- **Dropped connections recover themselves** — if a stream goes silent while the agent actually finished, the UI adopts the completed reply from persisted history instead of freezing on a cursor.
- **Errors arrive as human copy** — coded platform errors (quota, auth, transient) render family-appropriate wording with an action slot, never a raw code string.
- **A composer that knows the turn state** — Enter sends, Shift+Enter newlines, IME composition is respected, Send becomes Stop mid-turn, and stopping keeps the partial reply marked "Stopped."
- **Accessibility as a default** — streaming text and status announce via live regions, every collapsible is keyboard-operable, prompts manage focus, animations respect reduced-motion.
- **Scroll behavior that holds** — pinned to the latest message while streaming, released the moment the reader scrolls up, with a jump-to-latest affordance; long transcripts render windowed.

Everything above is the `calm` preset — the end-user default. Unknown or future content types render as labeled, collapsed rows: **never blank, never raw JSON**.

## Give visitors a stable identity

The default setup carries identity via cookies, which is best-effort for cross-origin embeds. For durable anonymous continuity, mint a guest secret and hand it to the component — the same [identity model as the underlying SDK](/sdk/#give-visitors-a-stable-identity):

```tsx
"use client";

import { GuueyChat } from "@guuey/chat/react";
import "@guuey/chat/styles.css";

/** 64 lowercase hex chars, minted once and persisted. Returns null if storage is blocked. */
function getGuestSecret(): string | null {
  try {
    const key = "guuey:guest-secret:your-app-id";
    const existing = window.localStorage.getItem(key);
    if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
    window.localStorage.setItem(key, secret);
    return secret;
  } catch {
    return null;
  }
}

export function AgentChat() {
  return (
    <GuueyChat
      endpointUrl="https://your-agent-endpoint"
      appId="your-app-id"
      apiBaseUrl="https://api.us-east-1.guuey.com/v1"
      getGuestSecret={getGuestSecret}
    />
  );
}
```

`apiBaseUrl` is what turns on the batteries-included read paths: transcript history on reload, and resolution of generative-UI views by read (the component builds a reader over the same identity for you). Signed-in users work the same way as the SDK's [bring-your-own-auth](/sdk/#signed-in-users-bring-your-own-auth): pass `getAccessToken` instead of `getGuestSecret`. If you construct your own `adapters` or `reader`, they always win over these defaults.

:::note[Views resolve by read — bring an identity the read plane can see]
Generative-UI views mount by reading their `ui://` locator, and that read is authenticated. A **bearer** or a **guest secret** identifies the read on every environment and from any origin. Pure cookie identity (no `getGuestSecret`, no `getAccessToken`) also works — **but only same-host**: the guest cookie is host-only and `SameSite=Lax`, so it rides along when your page and the API share a host (a custom domain), and is never sent cross-site to the absolute `api.<region>.guuey.com` host. Cross-origin embeds — the widget iframe, native apps, a page on your own domain calling the absolute API host — need the guest secret; without it, cookie-only visitors there see views as "expired" (labeled, never blank). When both a cookie and a guest header are present on a same-host read, the cookie decides — the same precedence the chat stream uses.
:::

## First customization

Three dials cover most products, all as props:

```tsx
<GuueyChat
  endpointUrl="https://your-agent-endpoint"
  appId="your-app-id"
  preset="debug" // expanded tools + wire detail — for internal tools and admin views
  strings={{ composerPlaceholder: "Ask about your booking…" }}
  policy={{ toolGroup: { threshold: 4 } }}
/>
```

- **`preset`** — `"calm"` (default, end-user) or `"debug"` (everything expanded, wire codes visible — what Studio's test chat uses).
- **`strings`** — every piece of UI copy in one override map, which is also the i18n seam.
- **`policy`** — per-category behavior knobs (grouping thresholds, collapse defaults, error wording, result size caps).

When props stop being enough, the same package exports every layer underneath — replace one component, keep the rest; or take the headless view-model and render everything yourself. That ladder is the next page: [Customization & presets](/chat-customization/).

## Suggested prompts

Chips (or any host-driven send) stay on the batteries-included path through the imperative handle — a `ref` (or the `onReady` callback, same object):

```tsx
import { useRef } from "react";
import { GuueyChat, type GuueyChatHandle } from "@guuey/chat/react";

function Assistant() {
  const chat = useRef<GuueyChatHandle>(null);
  return (
    <>
      <div className="chips">
        {["What can you do?", "Plan my week"].map((p) => (
          <button key={p} onClick={() => chat.current?.send(p)}>
            {p}
          </button>
        ))}
      </div>
      <GuueyChat ref={chat} endpointUrl="https://your-agent-endpoint" appId="your-app-id" />
    </>
  );
}
```

`send` runs through exactly the Send button's gate — it returns `false` (and does nothing) while a turn is in flight, when chat is unavailable, or for blank text — and it never touches a half-typed draft. `prefill(text, { append })` and `focusComposer()` ride the same handle for stage-then-edit flows, and `handle.threadId` (or the `onThread` prop) tells you which thread you're on without wrapping storage. Details: [Customization & presets](/chat-customization/).

## Theming

`<GuueyChat theme={…} mode="dark">` takes a serializable theme object — see [Theming](/chat-theming/). The default theme is brand-neutral; the guuey look ships as a second exported constant.

## Strict Content-Security-Policy hosts

If your page ships a strict CSP, two things are worth knowing before the first generated view renders.

**Views need origins your policy may not list.** A generative-UI view boots a runtime bundle and opens a live channel to its MCP host — under a `script-src`/`connect-src` that omits those origins, the frame stays blank and the kit can only say "this view didn't start". Two aids:

- Pass the view's declared origins so the host can name the cause: `<GuueyChat viewProps={{ cspOrigins: { resourceDomains: ["https://assets.<mcp-host>"], connectDomains: ["https://<mcp-host>", "wss://<mcp-host>"] } }}>`. When your **page** loads or connects to one of those origins and your policy refuses it, the `no-handshake` label upgrades to the exact allowance — _"add `script-src-elem https://assets.<mcp-host>`"_ — and, under `preset="debug"`, the same verdict reaches [`onDebugEvent`](/chat-customization/#the-debug-sink) as `view-phase.diagnosis`. (The upgraded label shows under the default calm preset too; the sink only fires under the debug preset.)
- Know the tripwire's reach: it listens on **your document**, so it sees violations your page incurs on the view's origins (a runtime bundle you load at page level, a channel you open on the view's behalf). A `srcdoc` view's _own_ blocked loads report inside the frame's opaque origin, where nothing outside can listen — for those, the CSP console message names the blocked URL, and the fix is the same allowance. (Views mounted through a `sandboxPageUrl` relay carry their own policy and are unaffected by yours.)

**One `script-src eval` report is not the view.** `@guuey/chat` reaches zod v4, which probes `new Function("")` once at boot to decide whether to compile fast parsers — under a policy without `'unsafe-eval'` that probe is caught, harmless, and still **reported** as a `securitypolicyviolation` (zod's own source says so). It is a false positive on exactly the channel the tripwire watches. Silence it by opting zod out of the probe **before its first schema parses** — as a side-effect module that is your entry's _first_ import:

```ts
// csp-jitless.ts — import this FIRST in your entry
import { z } from "zod";
z.config({ jitless: true });
```

```ts
// main.tsx
import "./csp-jitless";
import { GuueyChat } from "@guuey/chat/react";
```

A later call runs after the probe and changes nothing.

## Widget or kit?

The [two-script widget embed](/embed/) is the no-code path: guuey's origin, guuey's updates, zero build. `@guuey/chat` is for when the chat should be **inside your product** — your bundle, your router, your design system. Both render the same transcript tier; you are not trading capability for control.