Docs menu
Chat UI: client, useChat & AG-UI
Three ways to put a durable agent in front of a user: a typed REST/SSE client, Vercel AI SDK useChat compatibility, and an AG-UI adapter — none of which asks you to write your own UI library.
What it is for#
The server already exposes every agent over REST and SSE. What is left is the client half, and there is no single right answer: some apps want a typed client, some already run useChat, some plug into an existing AG-UI surface. All three read the same stream.
One concern is shared by all of them and is worth stating up front. A durable tool can return an internal sentinel instead of a result — the run suspended for approval, or a guard blocked the call. Those carry internal fields and are not a wire contract, so they must never reach a browser verbatim. The masking that replaces them lives in one helper shared by the live stream and by history reconstruction, so the two cannot drift.
Typed client (@gnldev/client)#
GnlClient wraps the server's endpoints: listAgents, run, resume (same runId plus approvals — the suspended tool is released), stream as an async generator, streamTo with handler callbacks, plus listRuns and getRun for the journal timeline. It is framework-agnostic; @gnldev/client/react adds hooks.
import { GnlClient } from '@gnldev/client';
const client = new GnlClient({
baseUrl: 'http://localhost:3000/api',
headers: { authorization: `Bearer ${token}` },
});
const { runId } = await client.run('support', { prompt: 'Where is my order?' });
for await (const ev of client.stream('support', { runId, prompt: 'and the refund?' })) {
if (ev.event === 'text-delta') process.stdout.write(ev.data.text);
}
// an approval came back from the operator — same runId, the suspended tool is released
await client.resume('support', runId, { 'call-7': true });The React entry point exposes useGnlAgent and a useChat hook built on the client's own accumulator, so message state is assembled for you.
import { useChat, useGnlAgent } from '@gnldev/client/react';
const { messages, send, streaming } = useChat(client, 'support');Vercel AI SDK useChat (@gnldev/chat-adapter)#
If your UI already runs the AI SDK's useChat (v5), this package speaks its protocol. toUIMessageStreamResponse turns a durable run into a UI message stream. For the endpoint there are two ways: mount the chat format on the REST API, or run the standalone createChatRoute.
On the REST API (recommended). chatSurface() in surfaces puts the chat format on createRestApi. The API's auth decides who the caller is, which organization they are in and whose run this is — the same gates as every REST route — so there is no second identity resolver to write. A request with no valid credential is refused before the model runs.
import { createRestApi } from '@gnldev/server';
import { chatSurface } from '@gnldev/chat-adapter';
import { roleAuth } from '@gnldev/auth';
import type { CreateGnlConfig } from '@gnldev/durable';
declare const config: CreateGnlConfig;
export const api = createRestApi(config, {
auth: roleAuth({ endUsers: { secret: process.env.GNL_END_USER_SECRET!, orgId: 'acme' } }),
surfaces: [chatSurface()], // POST /agents/:name/chat — useChat({ api: '/agents/pay/chat' })
cors: { origins: ['https://app.example.com'] }, // if the browser calls this API directly
});Standalone route. createChatRoute has no auth of its own. The host authenticates the request (a session cookie, a verified token) and tells the route who is calling through identify: (req) => Principal | undefined — the same function @gnldev/agui, @gnldev/mcp and @gnldev/server take. An AuthProvider already is one: identify: (req) => auth.authenticate(req). In production the route refuses to start without identify; identify: () => undefined says, explicitly, that there is no per-user identity. The 0.6 identity option now throws at construction.
import { createChatRoute, toUIMessages } from '@gnldev/chat-adapter';
import type { Principal } from '@gnldev/auth';
// Your session lookup: who this request is, as YOUR server established it.
declare function userOf(req: Request): Promise<string | undefined>;
app.route('/api', createChatRoute({ gnl }, {
identify: async (req): Promise<Principal | undefined> => {
const id = await userOf(req);
return id ? { kind: 'subject', id, roles: [] } : undefined;
},
}));
// POST /api/agents/:name/chat — useChat({ api: '/api/agents/pay/chat' }) works unchanged.
// restore the conversation after a reload — rebuilt from the journal
const history = toUIMessages(await journal.readRun(runId));The principal decides whose run it is. A subject is that user and reaches only its own runs and threads; an operator is staff in its organization; an application speaks for one of its users and names that user in the body's resourceId — read for an application only, and naming nobody is a 400. The organization is principal.orgId, never the body's. If identify answers nothing, the caller is unknown and reaches no user's data. A credential that was presented and rejected (a revoked token, a deleted user) gets 401 and is not served as anonymous, when the provider flags the rejection — @gnldev/auth-ee's does, your own identify calls markCredentialRejected(req), the free roleAuth does not yet.
toUIMessages is the other half: it rebuilds the message history from the journal, so a page reload does not lose the conversation — the transcript is reconstructed from what actually happened, not from client-side state. An interrupt surfaces as a data-gnl-interrupt chunk, which is how the UI knows to render an approval prompt instead of a result.
AG-UI / CopilotKit (@gnldev/agui)#
AG-UI is the protocol CopilotKit and several other front-ends speak. createAguiRoute exposes a single endpoint an AG-UI HttpAgent can post to; pipeAguiStream is the lower-level piece, converting the run's stream into AG-UI events. The package defines the event types itself and takes no @ag-ui/* dependency.
Identity works exactly as for the chat route. The recommended way is aguiSurface() on the REST API — createRestApi(config, { auth, surfaces: [aguiSurface()] }), at /agents/:name/agui — where the API's auth decides who the caller is. The standalone createAguiRoute takes identify, refuses to start in production without it, and accepts identify: () => undefined as the explicit opt-out.
import { createAguiRoute } from '@gnldev/agui';
import { roleAuth } from '@gnldev/auth';
const auth = roleAuth({ endUsers: { secret: process.env.GNL_END_USER_SECRET!, orgId: 'acme' } })!;
const route = createAguiRoute(config, {
identify: (req) => auth.authenticate(req), // an end user's signed token → that user, in acme
});
// POST /agents/:name/run → AG-UI SSE — an AG-UI HttpAgent can consume this directlyAPI#
GnlClientlistAgents, run, resume, stream (async generator), streamTo (callbacks), listRuns, getRun. Options: baseUrl, headers, fetch (for Node <18 or test mocks).
useGnlAgentReact hook over GnlClient for a single agent.
useChatReact chat hook built on the client's accumulator — message state is assembled for you.
toUIMessageStreamResponseTurns a durable run into an AI SDK useChat (v5) UI message stream response.
toUIMessagesRebuilds chat history from the journal, so a reload restores the conversation.
createChatRouteA standalone Hono chat endpoint for useChat. Takes identify: (req) => Principal | undefined — required in production.
chatSurfaceThe useChat format as a surface of createRestApi (surfaces: [chatSurface()]), at /agents/:name/chat — the API's auth decides who the caller is.
maskSentinelOutputReplaces the internal suspend/blocked sentinels with a client-safe shape; shared by the live stream and history so they cannot drift.
createAguiRouteA single POST endpoint an AG-UI HttpAgent can consume. Takes identify — required in production.
aguiSurfaceThe AG-UI format as a surface of createRestApi (surfaces: [aguiSurface()]), at /agents/:name/agui.
pipeAguiStreamStreams a run as AG-UI events (the AG-UI counterpart of pipeAgentStream).
__gnl_suspend sentinel and a blocked one its own marker. Both carry internal detail and raw guard reasons. maskSentinelOutput replaces them with { pending: 'approval' } / { blocked: true } and hands the raw interrupt back to the caller separately, so the UI can render an approval prompt without the internals leaking into it.pipeAgentStream rather than importing it. That file carries the resumable-stream id contract and is pinned by process-kill and exactly-once tests; adding a sink parameter to it would have been the riskier change. The copy is covered by parallel tests so the two shapes stay in step — and it is written down here because a reader deserves to know it is a copy.