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

Build your own surface

read as .md

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

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

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

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

Terminal window
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:

Terminal window
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.

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.

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

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.

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.

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.

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

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

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:

Terminal window
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:

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.

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

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.

  • React NativeuseAgentInvoke 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 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).

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 }));