Build your own surface
read as.mdGuuey agents come with surfaces out of the box — the shareable page on app.guuey.com, the embeddable widget, and the preview in Studio. When you want a chat surface that is fully yours — your components, your layout, your product — build it on the client SDK.
What the SDK is
Section titled “What the SDK is”@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.
npm install @guuey/agent-clientYou’ll need two values from your deploy:
- Your app id — shown by
guuey apps list. - Your agent’s endpoint URL — printed by
guuey deploywhen it finishes (Live at https://…), and shown any time after byguuey apps get(theEndpoint:line for the app’s live deployment).
Allow your domain
Section titled “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):
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.)
Then verify it the way a browser will see it:
guuey apps check --origin https://chat.yourapp.comThis 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
Section titled “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)
Section titled “Minimal chat surface (React)”"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
Section titled “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
Section titled “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
503before any output — the shape a just-waking agent can return — is retried inside the transport (3 attempts with backoff, honoringRetry-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
503refusal with codePOD_SATURATED— the pod is at its concurrent-turn cap right now — is retried once, honoring the pod’sRetry-Afterhint (default 15 s, capped at 30 s). A second saturation surfaces as an error, so a genuinely overloaded agent shows instead of looping; aDRAININGrefusal 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’sstallRecoveryoption (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
Section titled “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
Section titled “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
sessionevent 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
threadIds reach you — only thesessionevent’s is durable. The conversation id you persist and replay is the one on thesessionevent. AgJSONturn.*/message.*frames also carry athreadIdfield, and today’s normalizers fill it with the framework’s session/run id (Claude’ssession_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 rootthreadIdas 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, thesessionevent is your source of truth.) - Tool results carry the payload twice — including as a string. An MCP tool result’s data arrives as
structuredContentand as a JSON-stringified copy insidecontent[].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
Section titled “Generative-UI cards in your surface”Agents using ggui produce interactive views, and a custom surface can mount them: @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, 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 withoutallow-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
srcdocmount 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 onsrcdocis a legitimate, reviewable posture — the sandbox attribute still blocks same-origin access, and your page’s CSP bounds egress. Reach forsandboxPageUrlwhen 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)
Section titled “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 — the same endpoint an identified widget embed uses, and the same identity across both surfaces.
Configure the app with your issuer:
guuey apps update <appId> --auth-mode byo \ --issuer-url https://auth.yourapp.com --audience your-api-audienceThen hand the SDK your user’s token instead of a guest secret:
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.
The same issuer binding also lets the agent’s own standalone page 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
Section titled “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>defaultsreturnToto 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 withoauthReturnTo/onOAuthAuthorizeif your chat lives somewhere the user should not land.- Custom web surfaces using
<Transcript>+useTranscriptInputs: calloauthPromptAction({ item, action, answerHitlPrompt })first in youronPromptAction(it returnsfalsefor every non-OAuth prompt, so your existing consent handling runs unchanged) and mountuseOAuthReturn()for the notice — both from@guuey/chat/react. The pure helpersoauthAuthorizeAsk,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,returnTomust be an https URL on a domain in the app’s--domainsallowlist. Serve a small page there that reopens your app (a universal/app link), build the authorize link withoauthAuthorizeHref(ask, grantModeId, "https://yourapp.com/oauth/done"), open it with an auth session (expo-web-browser’sopenAuthSessionAsync), and read the result URL withparseOAuthReturn.
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).
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
Section titled “Beyond React on the web”- React Native —
useAgentInvokeworks unchanged; supply your own adapters (an AsyncStorage-backed thread store and a streamingfetchtransport) in place ofcreateWebAdapters. Every entry point ships TypeScript source with areact-nativeexport condition, so Metro transpiles them with your app’s Babel config — no extra bundler setup. For the full transcript UI on RN,@guuey/chat/nativerenders 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.invokeTurndrives 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
Section titled “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:
import { Agent, setGlobalDispatcher } from "undici";
// Allow streamed responses up to 10 minutes.setGlobalDispatcher(new Agent({ headersTimeout: 600_000, bodyTimeout: 600_000 }));