Browser chat

Put an agent in a page of your own app: the connection, the two access modes and how your backend mints a session token, trusted identity, and the client-side details that silently lose messages if you skip them.

The Spikefrost Team30 Jul 20264 min read

Browser chat is a page in your app talking to the agent directly over a WebSocket. You own the markup, the styling, and the authentication.

This replaces the older embedded-chat widget, which remains only for apps already using it. Build new chat this way: you get real authentication tied to your own users, and the UI is yours rather than an iframe's.

The connection

wss://<your-host>/agents/<agent-class-route>/<sessionId>[?token=...]

The agent route is the kebab-case of the class name — SupportAgent becomes support-agent — and sf deploy prints it. The session id identifies one conversation; reuse it to continue, change it to start fresh.

Both your platform URL and any custom domain work, and upgrade requests bypass the edge cache automatically.

Access: locked by default

An agent's access is "app-token" unless you change it. The browser must present a token minted by your own backend:

import { mintAgentAccess } from "@spikefrost/agents/access";   // light subpath

app.get("/api/chat-token", requireLogin, async (c) => {
  return c.json({
    token: await mintAgentAccess(c.env, {
      agent: "support-agent",                 // kebab route
      session: `user-${c.user.id}`,           // token opens ONLY this session
      claims: { userId: c.user.id, role: c.user.role },   // keep it small
    }),
  });
});

That route sits behind your authentication — a session cookie, SSO, an OTP flow, whatever your app already uses. The platform doesn't impose a login model; it verifies the token you mint.

For genuinely anonymous chat, declare it explicitly:

override access = "public";

Only do that when the agent holds no tools that touch personal data. A public agent is reachable by anyone who can guess a session id.

Identity you can trust

Verified claims become this.identity inside the agent and are injected into the system prompt as verified context. Identity is sticky — a session bound to one user never admits another.

So tools take no identity arguments:

get_order_status: tool({
  inputSchema: z.object({}),                             // no customerId!
  execute: () => getOrderStatus(this.env, this.identity?.userId),
}),

A customerId parameter is a parameter a model can be talked into filling with someone else's id. The token is the only thing that says who this is.

The protocol

Send a message:

{"id":"<uuid>","type":"cf_agent_use_chat_request","init":{"method":"POST",
 "body":"{\"id\":\"<uuid>\",\"trigger\":\"submit-message\",\"messages\":[{\"id\":\"<uuid>\",\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}"}}

Responses arrive as cf_agent_use_chat_response frames whose body is SSE-framed: split on newlines, strip the data: prefix, parse each payload, and append text-delta chunks as they stream.

Three details that bite

These are not edge cases — each one has produced a real user-visible bug.

1. Handle cf_agent_chat_messages, or you will lose answers.

The server sends the full persisted transcript on every connect and after each turn settles. That is the only recovery path, and skipping it is a silent data-loss bug:

A turn runs for 90 seconds. The socket drops halfway. Every delta that arrived while disconnected is gone. The client reconnects, the server immediately re-sends the complete transcript including the finished answer — and a client that only listens for streaming frames discards it. The user stares at an empty chat while the reply sits safely on the server.

Rebuilding the log from this frame is idempotent, so treat it as the source of truth and re-render whenever it arrives.

2. Errors arrive in three different shapes. A non-JSON done:false frame, an SSE error chunk, or a done:true frame with text in body. A parser that JSON.parsees each payload inside a bare try/catch swallows the first kind silently and the turn renders as nothing. Treat any non-JSON, non-[DONE] payload as error text, and keep an error flag that survives across frames so a trailing empty done:true doesn't erase a message you already showed.

3. Completion arrives in three different shapes too — a frame-level done:true, an SSE [DONE], or a chunk of type finish/finish-message/done. A spinner gated only on m.done can spin forever after a long tool call. Handle all three.

Client requirements

  • Always reconnect with backoff. Never a bare new WebSocket with no retry — turns run for minutes and sockets drop.
  • Use a JSON envelope for everything in both directions.
  • Serve the script from a route. The build rejects <script> with inline content:
app.get('/chat.js', (c) => c.body(chatScript, 200, {
  'content-type': 'application/javascript',
}));
  • Re-fetch the token on reconnect. Tokens are short-lived; the conversation isn't.

What not to put in the chat

Approval requests. An approval-gated write parks the turn — the conversation isn't where it gets resolved. Surface pending approvals in your app's dashboard instead. See Approvals.

Testing

  • Use a fresh session id right after deploying. A conversation object can briefly instantiate before its credentials land; the platform resets those automatically, so the first reply may error once.
  • Node before 22 has no global WebSocket — run test scripts on Node 22+ or with the flag.
  • Expect silence during a turn. Real work takes real time. That's not a hang.

Next

Frequently asked questions

How does the browser talk to the agent?

Over a WebSocket to your own host: wss://<host>/agents/<agent-route>/<sessionId>. The agent route is the kebab-case of the class name, printed by sf deploy.

Is chat open to anyone by default?

No — the default is locked. An agent's access is app-token unless you say otherwise, so the browser must present a token minted by your own backend. Declare access public only for anonymous chat with no personal-data tools.

Why must I handle the transcript message type?

Because it's the only recovery path. If a socket drops mid-turn, a client that only reads streaming frames throws away the finished answer when it reconnects, and the user sees nothing while the reply sits safely on the server.

How do I know a turn finished?

Completion arrives in more than one form — a frame-level done flag, an SSE [DONE] marker, or a finish chunk. Handle all of them, or a loading indicator can spin forever after a long tool call.

Can I put the chat script inline in the page?

No — the build rejects inline script content. Serve the JavaScript from a route in your app.