Build a chat endpoint
Build this endpoint when a browser or mobile client needs to talk to an Agent. Your backend owns authentication, Agent and Session authorization, Attribution, and the Blazing Agents API key.
Outcome
The endpoint accepts one newest user message, starts or resumes an authorized Session, and returns the native UI message stream unchanged. On the first Turn, the backend stores the server-minted Session ID for the next request.
Before you begin
Complete Connect your app and create a server-side SDK client. Your backend also needs a trusted mapping from each application chat to an allowed Agent and, after the first successful Turn, its Session ID. Review Sessions and Turns, Generation and streaming, Tenancy and end-user Attribution, and Security and credentials.
Accept and authorize the request
Parse exactly one newest message. Resolve the signed-in application
principal, authorize the application chat, and derive agentId, userId,
and any stored sessionId from backend state. The browser-provided
application chat ID is only a lookup key; do not accept platform IDs or
userId from it without authorization.
In this example, parseNewestUserMessage strictly validates the body and
its one user UIMessage. The other named helpers are application-owned
database operations. createSessionTurnWithLease and
recoverProvisionalTurn atomically acquire a persisted per-chat lease
before creating a Session, reject a concurrent contender with application
status 409, and keep the lease until the returned stream completes or is
canceled.
import { BlazingAgents, BlazingAgentsError, type ChatMessageInput } from "@blazing-agents/sdk";
const client = new BlazingAgents({ apiKey: process.env.BLAZING_AGENTS_API_KEY! });
export async function handleChat(request: Request, chatId: string) {
const [principal, input] = await Promise.all([requirePrincipal(request), parseNewestUserMessage(request)]);
const chat = await authorizeChat(principal, chatId);
const { id: sessionId, provisional: wasProvisional = false, recoveryInFlight = false } = chat.session ?? {};
if (recoveryInFlight) return new Response("Chat recovery in progress", { status: 409 });
const turnInput = {
agentId: chat.agentId,
message: input.message,
...(input.trigger ? { trigger: input.trigger } : {}),
...(input.messageId ? { messageId: input.messageId } : {}),
signal: request.signal, userId: principal.stableId,
} satisfies ChatMessageInput;
if (!sessionId) return await createSessionTurnWithLease({
agentId: chat.agentId, chatId, principal, create: () => client.chat(turnInput),
});
try {
const result = await client.chat({ ...turnInput, sessionId });
if (wasProvisional) try { await markSessionEstablished(chatId, sessionId); }
catch (mappingError) { try { await result.toResponse().body?.cancel(mappingError); } finally { throw mappingError; } }
return result.toResponse();
} catch (error) {
if (!wasProvisional || input.trigger === "regenerate-message" ||
!BlazingAgentsError.isInstance(error) || error.code !== "not_found") throw error;
return await recoverProvisionalTurn({
agentId: chat.agentId, chatId, expectedSessionId: sessionId, principal, create: () => client.chat(turnInput),
});
}
}Start or resume the Session
Omitting sessionId makes client.chat() call the create route. Blazing
Agents mints the ss_... ID, and result.sessionId resolves it from the
response Location header. Supplying the authorized stored ID resumes that
Session; an unknown or deleted ID returns not_found rather than silently
starting another Session.
The Session row materializes only after its first successful Turn, so store
a new ID with explicit provisional state. A successful later resume proves
that it materialized and marks it established. Only a not_found from a
non-regeneration resume of that known provisional ID may start one
replacement create.
Both create helpers must atomically reread authorization and mapping state,
then set recoveryInFlight before calling create. The initial helper
requires no stored Session; the recovery helper requires the expected
provisional ID. A mismatched or already leased mapping returns 409
without starting a Turn. The winner stores the minted ID as provisional
while leaving the lease active, then returns a byte-preserving response
wrapper. That wrapper releases the lease only when the response body
completes, errors, or is canceled. If creation or storage fails, it cancels
any started response and releases the lease before rethrowing. Give the
lease a bounded expiry longer than the maximum Turn duration for
process-failure recovery. Other not_found errors propagate.
Return the stream
result.toResponse() claims the response body once and returns the native
AI SDK SSE stream with the required headers. Return it unchanged. Passing
request.signal propagates client cancellation to the Turn.
A Turn has already started when client.chat() resolves. The create helpers
cancel the response if saving its new provisional mapping fails. If marking
an existing provisional mapping established fails, the endpoint claims and
cancels the response body before rethrowing. Cancellation prevents that
Turn from continuing detached from failed application state.
Errors before streaming reject client.chat() as typed SDK errors. Errors
after streaming begins arrive as native { type: "error", errorText }
chunks. Let the frontend transport handle those chunks instead of parsing
or translating the protocol.
Verify the endpoint
Send two messages to the same authorized application chat:
curl -iN http://localhost:3000/api/chats/chat-42 \
-H 'content-type: application/json' \
-H 'cookie: app_session=local-dev-session' \
-d '{"message":{"id":"user-1","role":"user","parts":[{"type":"text","text":"Say hello"}]}}'
curl -iN http://localhost:3000/api/chats/chat-42 \
-H 'content-type: application/json' \
-H 'cookie: app_session=local-dev-session' \
-d '{"message":{"id":"user-2","role":"user","parts":[{"type":"text","text":"What did I ask?"}]}}'Replace the example cookie with a valid local application session accepted
by requirePrincipal. Both responses should be 200, with multiple SSE
events including text deltas. After the first response settles, confirm
that the application record for chat-42 contains one ss_... ID and that
the second request reuses it and marks it established. While the initial
stream is open, a concurrent request must receive 409. To test recovery,
abort the first stream, then send the second request: its one allowed retry
should atomically replace the old provisional ID with a newly minted
provisional ID. While that replacement stream is open, a concurrent third
request must also receive 409; after completion or cancellation, the
lease must clear. A regeneration not_found must not replace the mapping.
Production notes
- Authorize the application chat on every request. A tenant API key can reach every Agent and Session in that tenant.
- Forward abort signals. Every initial or recovery create must hold a per-chat
lease through stream settlement and reject contenders with
409. Recover a provisional ID only after an authorized non-regeneration resume returnsnot_found. - Cancel a started response before surfacing an application mapping-write failure.
- Show safe stream errors to the End-user, but log diagnostic context only on the backend.
- Store the Session ID against your authorized application chat. Attribution helps filtering and reporting; it is not a Session ownership check.
Related capabilities
- Sessions and Turns
- Generation and streaming
- Tool approvals
- Tenancy and end-user Attribution
- Security and credentials