---
title: "Build your own surface"
description: "Use @guuey/agent-client to build a fully custom chat surface on your deployed agent — streaming, per-turn status, thread continuity, guest or signed-in identity."
---

Guuey agents come with surfaces out of the box — the shareable page on [app.guuey.com](https://app.guuey.com), the [embeddable widget](/embed/), and the preview in [Studio](https://studio.guuey.com). When you want a chat surface that is fully yours — your components, your layout, your product — build it on the client SDK.

:::note[Want the default UI with your branding instead?]
[`@guuey/chat`](/chat/) sits on top of this SDK and ships the complete transcript experience — streaming markdown, tool activity, generative-UI cards, designed failure states — as a drop-in component with an override ladder. This page documents the layer beneath it: start with the kit, drop down here when you need the raw client.
:::

## What the SDK is

[`@guuey/agent-client`](https://www.npmjs.com/package/@guuey/agent-client) (MIT) is a typed client for a deployed agent's streaming contract:

- **`POST /agent/invoke`** — a Server-Sent-Events stream, folded into a flat transcript for you.
- **`GET /threads/:id/messages`** — the persisted-history read, so returning users continue their conversation.

It ships three entry points:

| Import                          | Contents                                                          | React? |
| ------------------------------- | ----------------------------------------------------------------- | ------ |
| `@guuey/agent-client`           | Streaming helpers, history reader, web adapters, all public types | No     |
| `@guuey/agent-client/react`     | The `useAgentInvoke` hook                                         | Yes    |
| `@guuey/agent-client/transport` | Just the SSE transport and guest-identity wire pieces             | No     |

The root subpath never imports React. The `/transport` subpath exists for builds that want **only** the streaming transport: its import graph carries no UI-host machinery at all, so a minimal integration (or a strict bundle-size budget) can take the transport without the rest of the package's dependencies riding along. Platform couplings — where the thread id is stored, how ids are generated, how the network request carries identity — are injected as adapters, so the same core runs in a Next.js page and a React Native app.

```sh
npm install @guuey/agent-client
```

You'll need two values from your deploy:

- **Your app id** — shown by `guuey apps list`.
- **Your agent's endpoint URL** — printed by `guuey deploy` when it finishes
  (`Live at https://…`), and shown any time after by `guuey apps get` (the
  `Endpoint:` line for the app's live deployment).

## Allow your domain

Your agent accepts browser requests from `localhost` and Guuey's own surfaces automatically. Before your surface goes to production, allow its domain (this is the browser-origin allowlist — to serve the hosted chat page from a hostname you own, see [Your own domain](/domains/)):

```sh
guuey apps update <appId> --domains "yourapp.com"
```

`--domains` sets the app's **complete** comma-separated allowlist — it replaces what was stored, so include every domain you serve from (an empty value clears the list). A bare domain covers `https://yourapp.com` and every subdomain. To pin an exact origin, include the scheme: `--domains "https://chat.yourapp.com"`. (That subdomain-and-localhost leniency applies to the agent endpoint's CORS check only — the embedded widget's frame check reads the same list more strictly; see [Embed & share](/embed/#1-allow-your-sites-domain).)

Then verify it the way a browser will see it:

```sh
guuey apps check --origin https://chat.yourapp.com
```

This sends a real CORS preflight to your live agent endpoint and prints the verdict — a misconfigured origin otherwise surfaces in the browser only as an unexplained `Failed to fetch`.

## Give visitors a stable identity

A custom surface should carry its own anonymous identity: a random secret your page mints once, stores, and sends on every request. Without it, each request can look like a brand-new visitor, and the conversation loses its thread. The secret must be exactly 64 lowercase hex characters — anything else is ignored.

Treat the secret like a session credential: whoever holds it _is_ that anonymous user. Never log it or put it in a URL. And keep the storage key stable — changing it orphans every returning visitor's conversations.

## Minimal chat surface (React)

```tsx
"use client";

import { useMemo, useState } from "react";
import { createWebAdapters } from "@guuey/agent-client";
import { useAgentInvoke } from "@guuey/agent-client/react";

const APP_ID = "your-app-id";
const AGENT_URL = "https://your-agent-endpoint"; // printed by `guuey deploy`

/** 64 lowercase hex chars, minted once and persisted. Returns null if storage is blocked. */
function getGuestSecret(): string | null {
  try {
    const key = `guuey:guest-secret:${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() {
  const adapters = useMemo(() => createWebAdapters({ getGuestSecret }), []);
  const { messages, send, status, activeTool, error } = useAgentInvoke({
    endpointUrl: AGENT_URL,
    appId: APP_ID,
    adapters,
  });
  const [draft, setDraft] = useState("");

  return (
    <div>
      {messages.map((m, i) => (
        <p key={i} data-role={m.role}>
          {m.text}
        </p>
      ))}
      {status === "connecting" && <p>Waking your agent…</p>}
      {status === "using-tool" && <p>Using {activeTool}…</p>}
      {error && <p role="alert">{error}</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          void send(draft);
          setDraft("");
        }}
      >
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Ask anything"
        />
        <button disabled={status !== "ready"}>Send</button>
      </form>
    </div>
  );
}
```

`messages` grows as text streams in — the last assistant entry updates in place, so rendering the array is all a live transcript takes.

## The turn lifecycle

`status` tells you exactly where the current turn is, so your UI can say something honest at every phase:

| `status`     | Meaning                                                                                                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`      | No turn in flight — the composer is open.                                                                                                                                     |
| `connecting` | Request sent, no response yet. Deployed agents stay up, so this is normally brief — but it still spans the round trip, so swap to a "still working" note after a few seconds. |
| `thinking`   | The agent is awake and working, but no text is flowing yet.                                                                                                                   |
| `using-tool` | A tool is running; `activeTool` carries its name.                                                                                                                             |
| `responding` | Assistant text is streaming in.                                                                                                                                               |

Failures never occupy `status` — they land in `error` and the status returns to `ready`, so the composer re-enables. Structured errors from the platform (for example, a plan limit being reached) arrive with their human-readable message in `error`, ready to display, and the machine-readable code in `errorCode`. Branch on `errorCode` for behavior — the vocabulary is exported as `AGENT_ERROR_CODES` and `CLIENT_ERROR_CODES`, so you compare against constants instead of re-typing string literals — and show `error` to the user.

The hook also returns `errorCode`, `abort()` (stop the in-flight turn, keeping partial text), `reset()` (clear the conversation and start a fresh thread), and `threadId`.

## Resilience defaults

Recovery behaviors are on by default, tuned so a surface built on the hook survives real-world networking without extra code:

- **Cold-start retry.** A bare `503` before any output — the shape a just-waking agent can return — is retried inside the transport (3 attempts with backoff, honoring `Retry-After`). The retry only ever happens **before the first byte**: once anything has streamed, nothing is re-sent, so a turn can never run twice. To tune or disable it, supply your own transport in the adapters — `fetchStreamTransport(req, token, secret, { coldStartRetry: false })` is the same function the default adapters use.
- **Saturation retry.** A `503` refusal with code `POD_SATURATED` — the pod is at its concurrent-turn cap right now — is retried **once**, honoring the pod's `Retry-After` hint (default 15 s, capped at 30 s). A second saturation surfaces as an error, so a genuinely overloaded agent shows instead of looping; a `DRAINING` refusal is deliberately not retried. Like the cold-start retry, it never fires after the first byte.
- **Stall recovery.** If a stream goes silent mid-turn while the agent actually finished — a dropped connection that never errors — the hook notices (25 s without bytes), checks persisted history _without touching the live stream_, and if the completed reply is there, adopts it seamlessly: the turn finishes as if it had streamed. If the turn is genuinely still running, the stream is left alone. After repeated silent windows with no persisted completion, the turn fails honestly (`errorCode: "STREAM_STALLED"`) instead of a forever-frozen cursor. Tune or disable via the hook's `stallRecovery` option (`stallRecovery: false`).

**Redeploys are not outages.** Your agent's pod is replaced with a rolling update — the replacement must answer its readiness probe before the old pod is retired, and the old pod finishes in-flight turns before it exits — so a redeploy does not interrupt a surface that is mid-conversation, and a fresh turn during the roll lands on whichever pod is serving. If you want an explicit readiness signal (a health widget, a "warming up" state, a pre-flight before the first turn), poll **`GET {invoke-origin}/readyz`** on the same origin you invoke on: `200` while the agent is serving, `503` while it is draining for a rollout or a colocated tool is degraded. It is unauthenticated, cheap, and reads the same signal the platform's own probe reads.

For signed-in surfaces, prefer the transport's `getBearer` option over minting a token once per turn: it is resolved **per attempt**, so a retry that happens after a backoff wait re-reads a fresh token instead of replaying one that may have expired. `createWebAdapters({ getAccessToken })` already wires this for you.

## Conversation continuity

The hook persists the thread id per app (in `localStorage` on web) and replays it on the next message, so the agent keeps its context across page reloads — a returning visitor picks up where they left off.

Repainting _earlier turns_ in your UI is a separate read. Clients fetch the persisted transcript with the exported `fetchThreadHistory` helper against `https://api.us-east-1.guuey.com/v1`, authenticating with the same guest secret (as an `x-guuey-guest` header) or bearer token used for chat — `createWebAdapters({ apiBaseUrl })` wires this read for you. From a browser on your own domain, that cross-origin read is admitted by the same allowlist as the live stream: put the domain on the app's `--domains` and the transcript repaints on reload.

## Turn and thread semantics

Three stream behaviors worth knowing, so your surface — and your tests — read a turn correctly (in `invokeTurn` terms: the generator yields `session` first, then `message` events, then `done`):

- **The `session` event marks admission, not completion.** It is the first event of every turn — the agent is awake and the turn is accepted. On a cold start it arrives late; everything before it is startup wait, not lost output. The thread id rides on this event: capture it there and replay it on the next request to stay in the same conversation (the React hook does both for you).
- **One thread is one conversation, server-side.** The agent keeps its context within a thread; `reset()` — or omitting the thread id — starts a fresh one.
- **Two `threadId`s reach you — only the `session` event's is durable.** The conversation id you persist and replay is the one on the **`session`** event. AgJSON `turn.*`/`message.*` frames also carry a `threadId` field, and today's normalizers fill it with the _framework's_ session/run id (Claude's `session_id`, ADK/OpenAI run ids) — it changes across turns and never identifies your conversation. Fold with the session event's id; treat the frame-level one as framework-internal. (Precise version, per the AgJSON maintainers: the spec defines the root `threadId` as the durable persistence key — the frame values are normalizers filling that slot with framework ids, and a normative "host supplies the durable id" rule is queued upstream; until it lands, the `session` event is your source of truth.)
- **Tool results carry the payload twice — including as a string.** An MCP tool result's data arrives as `structuredContent` _and_ as a JSON-stringified copy inside `content[].text` (sometimes nested — a stringified value inside an already-stringified string). If you redact, anonymize, or transform tool results, walking the structured side alone misses every id inside the string twin: parse strings-that-are-JSON or scrub both representations. The same exposure applies to any capture/cassette tooling you point at the wire.
- **A repeated ask in the same thread may add nothing new.** The agent already answered, so a near-empty follow-up ("as above", no new card) is correct behavior, not a dropped turn. When you measure streaming or rendering behavior, vary the _thread_, not just the prompt — a same-thread repeat can pass vacuously.

## Generative-UI cards in your surface

Agents using ggui produce interactive views, and a custom surface can mount them: [`@guuey/mcp-apps-host`](https://www.npmjs.com/package/@guuey/mcp-apps-host) ships the host role. `attachViewHost(iframe, config)` answers the view's spec-standard handshake (framework-agnostic), and `<GuueyView mount>` (from `@guuey/mcp-apps-host/react`) owns the whole lifecycle — iframe creation, sandboxing, negotiation, labeled states. The easiest path is [`@guuey/chat`](/chat/), which wires all of this into its transcript; use the primitives directly when you're building your own card layer.

Two things worth knowing before you mount agent-generated content:

- **The sandbox default is a security invariant.** Views mount with `sandbox="allow-scripts"` and deliberately **without** `allow-same-origin` — with it, agent-generated HTML would run as _your_ origin, which is an XSS by construction. Widening the sandbox is an explicit, named opt-in; prefer leaving it alone.
- **The default `srcdoc` mount inherits your page's CSP.** That's fine for most products, but it means the view's network egress is whatever your page allows. For strict egress confinement, `<GuueyView sandboxPageUrl>` supports a two-origin mount: the frame loads a sandbox relay page you serve from a separate origin with its own per-request CSP, and the view's document is delivered to it — the untrusted content then lives under _that_ page's policy, never your page's.
  **Picking between them:** if you don't operate a second origin, staying on `srcdoc` is a legitimate, reviewable posture — the sandbox attribute still blocks same-origin access, and your page's CSP bounds egress. Reach for `sandboxPageUrl` when you need per-view egress rules (e.g. different network grants per tool channel) or when your page's CSP is broader than you want untrusted views to inherit.

## Signed-in users (bring your own auth)

Two ways to give your surface identified users. If your product already authenticates users with an OIDC-compatible identity provider, your agent can verify their tokens directly — this section. If you have a backend that knows who is logged in but no IdP, mint short-lived tokens for them from that backend instead: [Sign in your own users](/surface-identity/) — the same endpoint an identified widget embed uses, and the same identity across both surfaces.

Configure the app with your issuer:

```sh
guuey apps update <appId> --auth-mode byo \
  --issuer-url https://auth.yourapp.com --audience your-api-audience
```

Then hand the SDK your user's token instead of a guest secret:

```ts
const adapters = createWebAdapters({
  getAccessToken: async () => await yourAuth.getToken(),
});
```

The platform verifies the token against your issuer's published keys — it never sees your signing secrets — and the user's conversations, memory, and files follow their identity.

:::caution[Forward the ID token, not the access token]
Verification checks the token's audience against the `--audience` you configured. With most IdPs (Cognito included) that value is your app client id — which appears in the **ID token**'s `aud` claim, not the access token's. If turns fail with `Missing Audience. Expected: <your-client-id>`, you are forwarding the access token; return the ID token from `getAccessToken` instead.
:::

The same issuer binding also lets the agent's own [standalone page](/page-sign-in/) sign visitors in with a redirect to your IdP — register a client whose id is the `--audience` above and pass it as `--oidc-client-id`; no code on your side.

Supply **one** identity mode per surface: `getAccessToken` for signed-in users, or `getGuestSecret` for anonymous ones. Passing both is a hazard — a momentarily unavailable token silently downgrades the user to an anonymous identity, and those turns land in a different thread.

## Connecting third-party accounts

An agent whose manifest declares an MCP server with `"credential": "oauth"` (Linear, Notion, GitHub — any server with its own OAuth authorization server) never holds that server's token itself. Guuey brokers it: before each turn the platform checks whether **this user** has connected **that server** to **this agent**, and if not, the turn runs without that server's tools and the agent asks — as a card in the transcript, the same grant-mode card cross-app profile consent uses:

> **Trip Planner wants to use your Linear account** — _Always allow_ · _Allow this chat_ · _Not now_

`@guuey/chat` renders it out of the box (web and React Native). Picking a mode **opens the server's sign-in** — nothing is posted back to the agent; the "answer" is the redirect. The kit builds the link from the card's declaration (`authConfig.authorizationUrl`) and appends two query parameters:

- `mode=always|once` — the grant the user picked, fixed _before_ the identity-provider hop;
- `returnTo=<where your surface lives>` — where the broker sends the user back afterwards, with `?connected=<serverName>` on success or `?error=<reason>` on failure.

On return the surface strips those two parameters from the address bar and shows a one-line notice ("Connected linear. The agent can use it from your next message."). There is deliberately no client-side state to carry across the redirect: the **next turn's** pre-turn check finds the connection and the tools simply appear. "Not now" dismisses the card; nothing is recorded and the agent asks again next time.

What each surface does with `returnTo`:

- **`<GuueyChat>`** defaults `returnTo` to the current page (stale return params removed) and navigates in place; when it is rendered inside another origin's frame it opens a new tab instead (identity providers refuse to render framed). Override with `oauthReturnTo` / `onOAuthAuthorize` if your chat lives somewhere the user should not land.
- **Custom web surfaces** using `<Transcript>` + `useTranscriptInputs`: call `oauthPromptAction({ item, action, answerHitlPrompt })` first in your `onPromptAction` (it returns `false` for every non-OAuth prompt, so your existing consent handling runs unchanged) and mount `useOAuthReturn()` for the notice — both from `@guuey/chat/react`. The pure helpers `oauthAuthorizeAsk`, `oauthAuthorizeHref`, `parseOAuthReturn`, `stripOAuthReturn` (from `@guuey/chat`) are platform-blind if you need to build the link or read the return yourself.
- **React Native**: the broker only accepts a custom URL scheme (`yourapp://oauth/done`) from Guuey's own app builds — for your app, `returnTo` must be an **https** URL on a domain in the app's `--domains` allowlist. Serve a small page there that reopens your app (a universal/app link), build the authorize link with `oauthAuthorizeHref(ask, grantModeId, "https://yourapp.com/oauth/done")`, open it with an auth session (`expo-web-browser`'s `openAuthSessionAsync`), and read the result URL with `parseOAuthReturn`.

`returnTo` is validated by the broker against the app's allowed domains plus Guuey's own surfaces — an origin you have not allowed on the app is refused, so allow your domain first ([Allow your domain](#allow-your-domain)).

**Managing connections.** A connection belongs to the **user**, not to your agent: they authorize a server once and grant it to agents individually. Users see and manage them in the Guuey app under _Settings → Connected services_ — per agent, "all chats" / "this chat only" / blocked (the only place a block is recorded — a dismissed card records nothing), and _Disconnect_, which deletes the tokens Guuey holds and tells the server's authorization server; the next turn that needs the server asks again. As a builder you see per-server **aggregate** health only, on the app's _Tools_ tab: how many users connected, how many granted your agent, the authorization server discovered, the last authorize and the last refresh error — never a user list or a token. For your own identity while developing, `guuey mcp connect <serverName> --app <appId>` starts the same authorize dance from the terminal (the app must be deployed — the broker resolves the server against the live deployment), and `guuey mcp connections` / `guuey mcp connections revoke <id>` manage what you connected.

## Beyond React on the web

- **React Native** — `useAgentInvoke` works unchanged; supply your own adapters (an AsyncStorage-backed thread store and a streaming `fetch` transport) in place of `createWebAdapters`. Every entry point ships TypeScript source with a `react-native` export condition, so Metro transpiles them with your app's Babel config — no extra bundler setup. For the full transcript UI on RN, [`@guuey/chat/native`](/chat-native/) renders the same view-model with RN primitives.
- **No React at all** — the root subpath's helpers (`invokeTurn`, `parseSseEvents`, `fetchThreadHistory`, `fetchStreamTransport`, and the exported types) are plain TypeScript. `invokeTurn` drives one full turn as an async generator of semantic events — start there.

**The exported functions are the stable contract.** The SSE frame grammar underneath them is an internal detail that can change between releases — if you find yourself hand-parsing a frame, reach for `invokeTurn`/`parseSseEvents` instead (and tell us what was missing).

## Using the SDK server-side

The same helpers run in Node — a test harness, a proxy, a server-driven agent call. One platform detail matters: **an invoke stream can outlive Node's default fetch timeouts.** Node's `fetch` (undici) applies per-request timers tuned for short requests, and a long agent turn — a cold start plus a multi-tool answer — can exceed them mid-stream. Give the dispatcher room once, at process startup:

```ts
import { Agent, setGlobalDispatcher } from "undici";

// Allow streamed responses up to 10 minutes.
setGlobalDispatcher(new Agent({ headersTimeout: 600_000, bodyTimeout: 600_000 }));
```