GuidesBackend and frontend integration

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

SDK and REST reference

On this page