v7 to v8 Migration Guide — FrontendHandler Removal
This guide covers the v8 release of @helix-agents/ai-sdk. v8 is a focused breaking release that removes the deprecated FrontendHandler class + createFrontendHandler factory and their Cloudflare convenience wrapper. All replacement APIs (handleChatStream, buildSnapshot, getUIMessages, createCloudflareChatHandler) shipped in v7 and have been the recommended path since then; v8 simply deletes the old surface.
If you migrated to the new APIs as part of the v6 → v7 upgrade, v8 is a no-op for you — bump the version, run npm install, and check your build. If you are still using createFrontendHandler directly or the Cloudflare convenience factory, this guide is for you.
Table of contents
- Overview / motivation
- What was removed
- Migration cookbook
- Observable behavior changes
RetryOptions.moderemoved- Validation checklist
Overview / motivation
The v7 stateless suspension redesign introduced a single orchestrator function — handleChatStream — that handles every dispatch path the old FrontendHandler class was juggling internally (seven paths in total: fresh session, continuing with a new user message, resume after submit, abandonment recovery, active-stream re-attach, completed retry, stale-runId rejection). Once that orchestrator landed, FrontendHandler had nothing left to do except shuffle deps + parameters into the orchestrator.
v7 kept the class as a deprecated convenience wrapper to soften the migration. v8 removes the wrapper.
The replacement surface is intentionally functional + dependency-injected:
handleChatStream(deps, params)returns aResponse(web standard).buildSnapshot(deps, params)reads durable state + stream metadata and returns a frontend snapshot.getUIMessages(deps, params)reads conversation history and returns AI SDK UI messages.
There is no dispatch class. deps is a plain object literal ({ executor, stateStore, streamManager, agent, ... }) so consumers can swap individual deps in tests without subclassing.
What was removed
Classes / factories
FrontendHandler— the dispatch classcreateFrontendHandler({...})— its factorycreateCloudflareFrontendHandler({...})— Cloudflare DO convenience
Types
FrontendHandlerOptionsFrontendRequestCloudflareFrontendHandlerOptionsAgentConfigOrMinimal
Tests deleted (covered by replacement suites)
packages/ai-sdk/src/__tests__/handler-factory.test.ts(77 cases) — replaced bypackages/ai-sdk/src/__tests__/handler/snapshot.test.ts,get-messages.test.ts, and the cross-runtime e2e suites underpackages/e2e/src/__tests__/*which exercisehandleChatStreamend-to-end across the JS / Temporal / Cloudflare DO / DBOS runtimes.packages/ai-sdk/src/__tests__/handler/cloudflare-handler.test.ts— replaced bypackages/ai-sdk/src/__tests__/cloudflare/cloudflare-chat-handler.test.ts.- Six
*.integ.test.tsfiles inpackages/ai-sdk/src/__tests__/integration/that exclusively tested FrontendHandler behavior (block-id-collision, content-replay, handler-pipeline, message-loading, snapshot-partial-content, snapshot-stability). The equivalent code paths are covered by the e2e suites and the new smoke tests forbuildSnapshot/getUIMessages.
What survived
FrontendHandlerError(base error class) — still re-exported and used by route handlers + the express adapter's catch blocks.FrontendResponse— still used bybuildSSEResponseand the express adapters. The name is kept for backwards-compat with existing consumers.MinimalAgentConfig— used byBuildSnapshotDeps.MinimalExecutor,MinimalStreamManager,MinimalStateStore— the minimal interface types still describe the dependency shape for adapter implementations.
Migration cookbook
A. Direct createFrontendHandler → handleChatStream + helpers
Before (v7):
import { createFrontendHandler } from '@helix-agents/ai-sdk';
const handler = createFrontendHandler({
executor,
stateStore,
streamManager,
agent: myAgent,
contentReplay: { enabled: true },
});
// POST /chat
app.post('/chat', async (req, res) => {
const response = await handler.handleRequest({
method: 'POST',
body: await readJson(req),
});
pipeToExpress(response, res);
});
// GET /chat?streamId=...
app.get('/chat', async (req, res) => {
const response = await handler.handleRequest({
method: 'GET',
streamId: req.query.streamId,
resumeAt: Number(req.query.resumeAt) || undefined,
});
pipeToExpress(response, res);
});
// Snapshot endpoint
app.get('/chat/:id/snapshot', async (req, res) => {
const snapshot = await handler.getSnapshot(req.params.id);
res.json(snapshot);
});
// Messages endpoint
app.get('/chat/:id/messages', async (req, res) => {
const result = await handler.getMessages(req.params.id);
res.json(result);
});After (v8):
import { handleChatStream, buildSnapshot, getUIMessages } from '@helix-agents/ai-sdk';
import { createWebResponseExpressMiddleware } from '@helix-agents/ai-sdk/adapters/express';
const deps = {
executor,
stateStore,
streamManager,
agent: myAgent,
contentReplay: { enabled: true },
};
// POST or GET on /chat — handleChatStream returns a web Response.
app.use(
'/chat',
createWebResponseExpressMiddleware(async (req) => {
const body = req.method === 'POST' ? await readJson(req) : undefined;
return handleChatStream(deps, {
sessionId: body?.sessionId ?? req.query.sessionId,
messages: body?.messages,
// ... other params per the v7 docs
});
})
);
// Snapshot endpoint
app.get('/chat/:id/snapshot', async (req, res) => {
const snapshot = await buildSnapshot(deps, { sessionId: req.params.id });
res.json(snapshot);
});
// Messages endpoint
app.get('/chat/:id/messages', async (req, res) => {
const result = await getUIMessages(deps, { sessionId: req.params.id });
res.json(result);
});Key differences:
handleChatStreamreturns a webResponse, not aFrontendResponseobject — use the newcreateWebResponseExpressMiddleware/pipeWebResponseToExpressadapters to plug into Express.buildSnapshot/getUIMessagestakedepsas the first arg andparamsas the second; both are pure functions over the deps.- There is no
handler.handleRequest({...})dispatch — POST vs GET vs submit-tool-result is dispatched insidehandleChatStreambased on the inboundmessages/ resume protocol. deps.agentis typed asAnyAgentConfig | MinimalAgentConfig. Pass the full agent definition in-process; passMinimalAgentConfig({ name, type }) when the agent lives in a remote runtime and only routing info is available (e.g. behind a DO).
B. Cloudflare DO → createCloudflareChatHandler
Before (v7):
import { createCloudflareFrontendHandler } from '@helix-agents/ai-sdk/cloudflare';
const handler = createCloudflareFrontendHandler({
namespace: env.AGENT_DO,
agentName: 'planner',
contentReplay: { enabled: true },
});
const response = await handler.handleRequest({
method: 'POST',
body: await c.req.json(),
});After (v8):
import { createCloudflareChatHandler } from '@helix-agents/ai-sdk/cloudflare';
const handler = createCloudflareChatHandler({
namespace: env.AGENT_DO,
agentName: 'planner',
contentReplay: { enabled: true },
});
const body = await c.req.json();
const response = await handler.handleChat({
sessionId: body.sessionId,
messages: body.messages,
});
// Snapshot + messages helpers also live on the handler:
const snapshot = await handler.getSnapshot({ sessionId });
const messages = await handler.getMessages({ sessionId });The Cloudflare factory wires the same DO client trio (DOFrontendExecutor, DOStateStoreClient, DOStreamManagerClient) into deps. The DO clients themselves are unchanged from v7.
C. Express adapter
If your code uses pipeToExpress (which took a FrontendResponse), keep using it — FrontendResponse is still exported and buildSSEResponse still produces it. The only new option is the web Response-based pair, both exported from @helix-agents/ai-sdk/adapters/express:
export function pipeWebResponseToExpress(
response: Response,
res: import('express').Response
): Promise<void>;
export function createWebResponseExpressMiddleware(
handler: (req: import('express').Request) => Promise<Response>
): import('express').RequestHandler;pipeWebResponseToExpress(response, res)forwards a webResponse(status, headers, and SSE body) into the ExpressServerResponse.createWebResponseExpressMiddleware(handler)is a middleware factory: it callshandler(req), pipes the resultingResponseviapipeWebResponseToExpress, and forwards errors tonext(err)— soFrontendHandlerErroris surfaced through the standard Express error-handler chain.
Use the web Response variants when consuming handleChatStream directly (which returns a web Response).
Observable behavior changes
Items 1–3 changed between the legacy FrontendHandler and the new handleChatStream. They are pre-existing in v7 (the orchestrator shipped in v7) but consumers who only used FrontendHandler would not have noticed them until they migrate. Items 4–5 are framework-wide observable changes that arrive with this version train regardless of which frontend API you use. Audit tests for these differences before upgrading to v8:
1. Missing-stream response: 204 → 200 + empty SSE
When a GET request hits a streamId that doesn't exist (or has already ended and been GC'd), the legacy FrontendHandler returned HTTP 204 (No Content). handleChatStream returns HTTP 200 with an empty SSE body that closes the stream without emitting any agent events.
Both signal "no content" to clients; the change is purely transport-level. AI SDK v6 useChat handles both the same way.
Test impact: assertions like expect(response.status).toBe(204) must be relaxed to expect([200, 204]).toContain(response.status) or updated to expect 200. The e2e suites use the relaxed form.
2. No ValidationError class on bad-request rejection
The legacy FrontendHandler threw a typed ValidationError (code: 'VALIDATION_ERROR') when required fields were missing on a request (e.g., GET without streamId). handleChatStream throws a plain Error from the underlying input validation — the error class is now an implementation detail and may change between minor versions.
Test impact: assertions like expect(...).rejects.toThrow(ValidationError) must drop the class check and use expect(...).rejects.toThrow() (assert rejection only). Route-handler catch blocks should still use FrontendHandlerError for the typed-status mapping — that base class is unchanged.
3. generateMessageId is now derived for multi-turn de-duplication
The legacy FrontendHandler exposed a custom generateMessageId option on StreamTransformerOptions that overrode the message-id stamping in the transformer. handleChatStream derives a deterministic id from currentRun.startUIMessageCount (e.g. msg-1, msg-3) so multi-turn sessions don't render duplicate assistant bubbles.
The custom callback still runs when no current run exists (the fallback path). For path 6 (active-stream re-attach with content replay), a client-supplied existingMessageId header overrides both — but only when content replay is enabled.
A client-supplied existingMessageId (X-Existing-Message-Id) is honored only on continuation paths — HITL/tool-result resume (path 3) and active-stream attach (path 5). A genuine fresh turn (a new user message that starts a new run) always derives msg-${startUIMessageCount} and ignores any client-supplied existingMessageId, because the new assistant turn must get its own id. (Earlier builds reused the supplied id on the fresh-turn path, which made useChat dedup the new bubble away when consumers sent the prior assistant's id via the recommended getter form.) The in-progress ("partial") message in buildSnapshot is likewise stamped from startUIMessageCount, so its id agrees across the SSR snapshot, the live resume stream, and the committed converter output.
Test impact: apps that depended on a custom id generator for multi-turn sessions will see the derived ids instead. If you need a custom generator, fork the transformer (StreamTransformerOptions.generateMessageId); the orchestrator only overrides the id when it can derive a stable one.
4. New StreamEvent union member: 'truncated'
The StreamEvent wire union (@helix-agents/core) gained a fifth member, StreamTruncatedEvent ({ type: 'truncated'; truncatedAtStep: number; atSequence?: number }). Push transports — the Cloudflare Durable Object SSE/WS path — broadcast it on cleanupToStep (G4 truncation surfacing).
Test / code impact: consumers with an exhaustiveswitch (event.type) over StreamEvent — especially with an assertNever/default: throw arm — must add a case 'truncated', or the new variant trips the default. Most consumers can simply ignore it: truncation is also surfaced as a thrown StreamTruncatedError from the reader; the wire event is an additional push-transport signal, not the only one. Use the new isStreamTruncatedEvent(event) guard to narrow. (On the AI SDK CF path, parseDOSSEStream yields the parallel non-terminal { type: 'truncated', ... } item — narrow with isParsedTruncated.)
5. Temporal listRuns now grows per turn
SessionStateStore.listRuns() on the Temporal runtime now returns one run record per execute()/resume() turn, matching JS, DBOS, and both Cloudflare runtimes. Previously a continuation execute() on an existing (e.g. completed) session skipped writing a run record, so Temporal under-reported a multi-turn session as a single run.
Test impact: a consumer relying on Temporal returning 1 run for a multi-turn session now sees N runs (one per turn). Update assertions that pinned the old single-run count.
RetryOptions.mode removed
The mode field ('from_checkpoint' | 'from_start') has been removed from RetryOptions across all runtimes (core, runtime-js, runtime-temporal, runtime-cloudflare). Passing mode now produces a TypeScript compile error.
Migration
'from_checkpoint' callers — drop the field; the new default behavior is identical (restore from the latest checkpoint, or a specific checkpointId if provided):
// Before:
await executor.retry(agent, sessionId, { mode: 'from_checkpoint', message });
// After:
await executor.retry(agent, sessionId, { message });'from_start' callers — also drop the field. The new runtime no longer supports an explicit fresh-start mode; instead, if no checkpoint exists, retry() restarts fresh automatically:
// Before:
await executor.retry(agent, sessionId, { mode: 'from_start' });
// After:
await executor.retry(agent, sessionId, {});
// or, with a replacement message:
await executor.retry(agent, sessionId, { message });New behaviors to be aware of
- Genesis fallback — when no checkpoint exists for the session,
retry()now restarts fresh (from the triggering message) instead of throwing. This replaces the oldmode: 'from_start'path and makes it the automatic fallback. - Explicit unresolvable
checkpointIdthrows — if you pass acheckpointIdthat cannot be resolved,retry()throws rather than silently falling back to a fresh start. Remove or correct the stale id to use the latest checkpoint.
Validation checklist
After upgrading to v8:
- [ ]
npm installsucceeds andtsc --noEmitpasses — no lingeringFrontendHandler/createFrontendHandlerimports. - [ ]
grep -r "createFrontendHandler\|FrontendHandler\b" src/returns either zero matches or onlyFrontendHandlerErrorreferences in catch blocks (the error class is kept). - [ ] Snapshot + messages endpoints return the same shapes (use
buildSnapshot+getUIMessages; signatures match the deletedFrontendHandlermethods). - [ ] GET-with-missing-streamId returns 200 (empty SSE) and clients handle it. If clients hard-coded a 204 check, relax to
200 | 204or update to expect 200. - [ ] If you relied on a custom
generateMessageIdfor multi-turn sessions, confirm the new derived ids don't break your store / UI.
If anything is unclear, check the changeset (.changeset/ai-sdk-frontend-handler-removal.md) or open an issue.