Deuz SDK 2.4 è uscito — memoria versionata e compilatore di contesto, supervisore degli obiettivi e scheduler durevole, runner Docker locale e stream di chat durevoli. Novità della 2.4
Deuz SDK
Modules

React Hooks

useChat and useObject from @deuz-sdk/react — ordered part rendering, writable state, multimodal input, client-tool round-trips, approval pauses, chat snapshots and the capability handshake.

Il contenuto delle pagine di documentazione è in inglese. Navigazione, ricerca e interfaccia seguono la lingua scelta.

Import from @deuz-sdk/react. The @deuz-sdk/core/react subpath (useChat, useObject, createUseChat and their types) is deprecated since 2.4 and will be removed in 3.0. It still works, frozen at 2.2, and its behaviour is unchanged; a development build (process.env.NODE_ENV !== 'production') logs one console.warn per page, while production builds and runtimes without process stay silent. Every feature since 1.7 lands in the package. The hooks bind the pure chat engine at @deuz-sdk/core/chat to React state; no business logic lives in them.

The 2.3 settlement fixes are not in `@deuz-sdk/core/react`

The frozen hook is unchanged, so it still has the bugs 2.3 fixed in @deuz-sdk/react. A verdict sent when nothing is pending still POSTs a new round and adds an empty assistant bubble, and a double click sends the continuation twice. It also drops the approval's signed token, so it cannot approve calls made with an approvalSigner. It has no reconnect(), and with no onToolCall it drops client tool calls instead of parking them. It sends no reasoning back and drops a tool call's provider metadata, so a Gemini 3 client-tool loop through it can fail with 400 (not live-verified). It cannot drive a native agent route either: it does not read agent frames, so it never sends a run id back. It has none of the 2.4 additions either: no initialSnapshot, exportSnapshot or capability handshake. @deuz-sdk/core/react stays frozen; fixes and new features ship in @deuz-sdk/react only. Migrate to @deuz-sdk/react for verdict matching, resumed client tools, native agent routes and snapshots.

React is an optional peer (^18 || ^19) — install it yourself; nothing else in the SDK depends on it. The hooks are plain TypeScript (no JSX) and SSR-safe: network runs only inside user-triggered callbacks, never at render time.

npm i react

useChat

import { useChat } from '@deuz-sdk/react';

const {
  messages, // UIMessage[] — the render view, each with ordered `parts`
  history, // { ui, canonical } — both views in one object
  status, // 'idle' | 'streaming' | 'error'
  error,
  sendMessage, // (input: ChatInput) => Promise<void>
  regenerate,
  editAndResend,
  stop,
  setHistory, // replace BOTH views
  setMessages, // replace the UI view; re-derives canonical (lossy)
  addToolResult, // answer a parked client tool call
  clearError,
  pendingApprovals, // chat is PAUSED while non-empty
  pendingToolCalls, // parked client tool calls (no onToolCall executor)
  addToolApprovalResponse,
  reconnect,
  usage, // this turn's terminal token usage (1.9)
  finishReason, // 'length' means the answer was truncated (1.9)
  stopDetails, // why the provider stopped early, e.g. a refusal's explanation (2.3)
  agent, // the native agent run behind this turn (2.3)
  steps, // per-step usage/finish (1.9)
  verifications, // verifyStep verdicts (1.9)
  warnings, // CallWarning[] from `warning` parts (1.9)
  falseFinishes, // doneWhen rejections (1.9)
  subAgents, // one frame per delegated agentTool run (1.9)
  cost,
  budgetExceeded,
  dataParts,
  citations,
  plan,
  activity,
  capabilities, // what the server announced in the handshake (2.4)
  exportSnapshot, // () => ChatSnapshotV1: the chat as plain data (2.4)
} = useChat({ api: '/api/chat' });

Options

OptionTypeNotes
apistringRoute returning toDeuzStreamResponse output.
initialMessagesMessage[]Seed canonical history. Read once at mount — see the note below.
initialSnapshotChatSnapshotV1Restore a chat with its ids, cursor, open turn and gates (2.4). Read once at mount; takes precedence over initialMessages. See Snapshots.
headers / bodyrecordsMerged into every request; body fields ride next to messages.
chatIdstringMerged into every request body (server-side ChatStore persistence).
resumeUseChatResumeOptions{ endpoint, auto?, lastEventId?, cursor? } — see Resuming.
generateId() => stringId source for UI messages/turns. Defaults to crypto.randomUUID with a fallback.
throttleMsnumberDefault 0 (1.8 behaviour). Coalesce React commits — see throttleMs.
onToolCall(call) => unknown | Promise<unknown>Client-tool executor. Omit it to park instead — see Client tools.
onData(data: { name, id?, payload }) => voidFires once per data-{name} frame, with the raw frame, before reconciliation. A throw is swallowed.
onError(error: Error) => voidCalled when the stream fails (state also carries the error).
onHttpError'error-part' | 'ignore'Default 'error-part': a non-2xx from api becomes status: 'error' instead of an empty bubble. Forwarded verbatim to readDeuzStream.
fetchtypeof fetchInjectable transport (tests, custom auth).
capabilityHandshakebooleanDefault true (2.4): list what the hook understands in an x-deuz-capabilities header. false makes requests and behaviour exactly 2.3's.

`initialMessages` is read once, deliberately

It is not re-adopted when the prop changes: apps pass an inline array literal, so a new identity arrives on every render, and adopting it would reset the transcript continuously — mid-stream it would clobber the turn being folded. Switching chats or hydrating later is an explicit action: call setHistory. Same rule and remedy as React's own useState.

Two histories, one commit

The hook keeps two views of the conversation and publishes them in a single state commit, so no render can ever see one a frame behind the other:

  • messages — the render-friendly UIMessage[] (accumulated content, reasoning, toolCalls with live state, and ordered parts).
  • history.canonical — the canonical Message[] it POSTs, reconstructing each assistant turn from the streamed parts in arrival order. Since 2.3 that includes every reasoning block, unchanged and in place, and each tool call's provider metadata (a Gemini function call's thoughtSignature, without which Gemini 3 rejects the next request; not live-verified), so a Claude, Gemini or OpenAI tool loop continues with its reasoning (see Reasoning parts). Since 2.3 the signature Gemini can put on a plain text part makes the trip too: the text-delta frame carries it, the UI text part keeps it as providerMetadata, and the canonical turn sends it back on that text (such a text stays a part instead of collapsing to a string). Gemini does not enforce text signatures, but its reasoning may degrade without them (not live-verified). Since 2.3 a call the server ran within a turn is answered right after it: the hook adds the call's result, the one the model saw, as a role: 'tool' message after the assistant turn, as canonicalFromUI does. Before 2.3 the next request carried that tool_use open, which providers reject, and a chat route's approval continuation ran the call a second time, because it settles every call the history leaves open.

history exposes the pair the hook already holds, so persisting a chat should persist history.canonical rather than round-tripping through the lossy canonicalFromUI. Its identity is stable until a view changes. History is immutable — new arrays on every update.

Rendering ordered parts

content / reasoning / toolCalls are buckets. A multi-step run — think → search → "I found 3 papers" → fetch → "here is the summary" — flattens into one reasoning blob, one text blob and a detached list of tool cards, so a UI cannot place a tool card between the two sentences it belongs between.

Since 1.9 each message also carries parts?: UIMessagePart[] in arrival order. No wire change was needed: the canonical stream is already strictly ordered, so arrival order is the interleave.

Turn.tsx
import type { UIMessage, UIToolCall } from '@deuz-sdk/react';

function byId(calls: UIToolCall[] | undefined, id: string) {
  return calls?.find((c) => c.toolCallId === id);
}

export function Turn({ message }: { message: UIMessage }) {
  // No `parts` (a pre-1.9 message, or one restored from old storage)? Render the buckets.
  if (!message.parts) return <Prose text={message.content} />;

  return (
    <>
      {message.parts.map((part, i) => {
        switch (part.type) {
          case 'step-start':
            // A real streamed turn's parts[0] is normally one of these.
            return <StepDivider key={i} step={part.step} />;
          case 'text':
            return <Prose key={i} text={part.text} streaming={part.state === 'streaming'} />;
          case 'reasoning':
            // `encrypted` / `redacted` text is an opaque provider payload, not thinking text.
            return part.encrypted || part.redacted ? null : <Thinking key={i} text={part.text} />;
          case 'tool': {
            const call = byId(message.toolCalls, part.toolCallId);
            return call ? <ToolCard key={i} call={call} /> : null;
          }
          case 'file':
            return <Attachment key={i} mediaType={part.mediaType} data={part.data} url={part.url} />;
          case 'citation':
            return <Source key={i} part={part} />;
          case 'data':
            return <Widget key={i} name={part.name} payload={part.payload} />;
          default:
            return null; // open union — keep a default
        }
      })}
    </>
  );
}

Rules that are easy to get wrong:

  • parts is optional and absent until the first element exists. createAssistantTurn's shape is public surface, and a UIMessage restored from pre-1.9 storage legitimately has none. Render parts when present, the buckets otherwise.
  • step-start is normally parts[0] of a real streamed turn, because the streaming loop pushes one at the top of every iteration. A renderer without a case for it will hit its default on the very first element.
  • Skip reasoning parts flagged encrypted or redacted — the text is an opaque provider payload (an OpenAI encrypted item, an Anthropic redacted thinking block), not display text. Since 2.3 both flags arrive on a streamed turn too, and each model block is its own part (a thinking block can have empty text when the model omits its thinking display). The reasoning bucket holds the readable text only.
  • A tool element carries only { type, toolCallId } — a reference into toolCalls, not a copy, because a call's state mutates over the turn's life and two copies would drift.
  • A file element carries the canonical value verbatim. url is set only when data is already a renderable data: / http(s): src; for bytes, build one yourself. See Files.
  • A citation element is the same object already pushed into turn.citations — one object, no drift.

The buckets are not deprecated and keep their exact 1.8 semantics: content is still the turn's full text, byte-identical.

Rendering a sub-agent run

A delegated run — agentTool() or agent.asTool() — is not in the parent's parts. It lands in a channel of its own, subAgents, one frame per agentPath:

const { subAgents } = useChat({ api: '/api/chat' });

// frame: { agentPath: string[]; afterPart: number; turn: AssistantTurnState }
{subAgents?.map((frame) => (
  <section
    key={frame.agentPath.join('/')}
    style={{ marginLeft: frame.agentPath.length * 16 }}
  >
    <Badge>{frame.agentPath.join(' › ')}</Badge>
    <Turn message={frame.turn.message} />
  </section>
))}

frame.turn is a full AssistantTurnState folded by the same reducer re-entering itself, so the child's text, reasoning, ordered parts, tool cards, citations and activity are as complete as the parent's — <Turn> above is the very same component from the previous section, step-start case and encrypted-reasoning skip included. frame.turn.message.toolCalls holds the child's own calls, so a tool element inside it resolves against the child, not the parent.

  • afterPart is how many of the parent's ordered elements existed when the frame opened — normally right after the tool card for the delegating call. Splice the block in there to reproduce the real interleave; indent or badge by agentPath.length.
  • A 2nd-level sub-agent is a sibling frame with a two-segment agentPath, not a nested one (the wire is single-wrapped), so a renderer never needs recursion of its own.
  • Nothing is misattributed. The child's prose never enters the parent's content / reasoning / toolCalls or its parts, and the child's tool_use never enters the canonical history — a tool_use with no matching tool_result is exactly what 400s the next request.
  • Frames are sealed at every terminal boundary (finish, error, sealAssistantTurn), so no caret keeps blinking inside a finished child block.

Three more turn readouts

useChat also surfaces the other channels the reducer folds. All three are optional and stay absent until the first entry arrives, rather than becoming an empty array:

const { warnings, falseFinishes } = useChat({ api: '/api/chat' });

{warnings?.map((w, i) => <Notice key={i}>{w.message}</Notice>)}
{falseFinishes?.at(-1)?.willRetry === false && <Notice>Gave up before finishing.</Notice>}
  • warnings — CallWarning[], the same payload StreamChatResult.warnings resolves. Turn-scoped.
  • falseFinishes — the doneWhen guard's rejections. A trailing willRetry: false means the guard's budget was spent and the run stopped anyway, which is what lets a UI say "gave up after 3 tries" instead of pretending the answer is final.
  • subAgents — as above.

All of them commit in the same single React commit as everything else, so none of them costs an extra render.

Writable state

Four methods over the single { ui, canonical } state cell, so the two views can never drift. Replacement is always wholesale into new arrays; nothing is spliced.

const { setHistory, setMessages, addToolResult, clearError } = useChat({ api });

setMessages((prev) => prev.filter((m) => m.id !== id)); // delete a turn; the next POST reflects it
setHistory({ ui: [], canonical: [{ role: 'system', content: SYS }] }); // switch chats
  • setHistory(update) replaces both views in one commit — the honest primitive, because only the canonical one is POSTable. Use it to switch chats, hydrate after mount, or edit a rendered message while keeping a system prompt and provider round-trip data the UI view cannot carry.
  • setMessages(update) is sugar over it: the canonical view is re-derived with canonicalFromUI, which is lossy — system messages, Message.providerMetadata and UI-only state do not survive, and a message without ordered parts loses its attachments entirely. Drive setHistory directly when your history has any of those. Since 2.3 it re-derives the leg that resumed an approval the way the hook's history holds it: the approved call's result goes right after the call it answers, and the resumed turn does not repeat the call. Before 2.3 it sent the call a second time without its input, which no provider accepts.
  • Both drop pending approvals and parked tool calls — they were anchored to the transcript you just replaced. Since 2.3 they also drop the in-memory resume cursor, which addresses that transcript's stream: after a chat switch, the next reconnect() reads the new chat from the persisted cursor (resume.cursor) or from its start, never from the old chat's position. They do not reset the turn readouts (cost, citations, …); the next turn does, exactly as sendMessage already did.
  • clearError() dismisses the error (status: 'error' → 'idle') and leaves a live stream alone.

The streaming turn is located in ui by id rather than by overwriting the trailing element, so a mid-stream setMessages can no longer have its result clobbered.

Multimodal input

sendMessage accepts ChatInput = string | { text?: string; parts?: Part[] }:

import { partsFromFiles } from '@deuz-sdk/react';

<input
  type="file"
  multiple
  onChange={async (e) => {
    const parts = await partsFromFiles(e.target.files);
    await sendMessage({ text: 'what is in these?', parts }); // media FIRST, then the question
  }}
/>;

A bare string stays a plain string content, byte-identical to 1.8, so a prompt-cache prefix does not move when you upgrade. partsFromFiles is a null-tolerant wrapper over core's filesToImageParts, which uses Web APIs only (await blob.arrayBuffer()) — no FileReader, no Buffer — so it behaves identically in a browser and on the edge. Images and PDFs both ride this call; see Files.

editAndResend(messageId, input) takes the same ChatInput.

Client tools

When the server streams a tool-call it did not execute (a client tool — a registered key with no execute), the hook has two modes:

With onToolCall — the executor runs, the result is appended to the canonical history, and the hook re-POSTs automatically, looping until a round has no pending client calls. A throwing onToolCall self-heals as an is_error result, mirroring the server loop. Since 2.3 a result that lands after the chat moved on (a new message, regenerate, editAndResend, setHistory or setMessages while the call ran) is dropped: it answers a turn that is no longer current. Before, it was appended after the new exchange and POSTed again.

Without onToolCall — the round-trip parks: the calls appear in pendingToolCalls, status goes idle, and nothing is re-POSTed. Answer each from outside the hook:

{pendingToolCalls.map((call) => (
  <ConfirmCard
    key={call.toolCallId}
    call={call}
    onDone={(output) => addToolResult({ toolCallId: call.toolCallId, output })}
  />
))}

addToolResult feeds the exact path onToolCall's return value takes, so the chat auto-continues once every parked call is answered. A result for an id that is not parked is a no-op — an orphan tool_result would 400 the next request.

Parking is new in 1.9. Before it, a turn producing client tool calls with no executor abandoned the round-trip and left a tool_use with no tool_result.

Client tools after a reconnect

Since 2.3, a turn read back through reconnect() or resume.auto settles exactly like one read from the POST. Its unresolved client calls reach onToolCall, and the hook then re-POSTs, or they park in pendingToolCalls. Before 2.3 the resume path only restored approvals. Such a call reached neither, and the transcript kept a tool_use nobody would answer.

A replay can re-deliver calls this client already handled. The endpoint replays everything after the hook's cursor, and a journal that spans several legs holds the earlier legs too. So the hook dispatches a replayed call at most once per mounted hook:

  • A call whose result is in the replay ran on the server. It never reaches the client. A result answers only the call, or the approval request, that came before it: an OpenAI-compatible server that omits call ids gets tool_0, tool_1, … in every model response, so a later call or gate under the same id stays open, in a replay and on the POST alike.
  • A call the canonical history already answers is not dispatched again. That means a tool_use followed by its tool_result in the current exchange; key order inside the input does not matter. One answer covers one delivery: when a provider without call ids asks the identical call again in a later leg (tool_0 with the same input), a replay that delivers it twice against one answer dispatches it. A replay that resumes after a cursor this hook's own reads reached leaves out the answers to the calls behind it. After a reload the hook cannot tell which answers those are, so it counts them all and never runs a call twice.
  • A call that is parked, or whose onToolCall is still running, is not dispatched a second time.
  • A stop() during the read settles nothing. A call the read had already delivered does not run, and the next reconnect() continues the same turn from the cursor.

That evidence is the canonical history. It survives a reload only if you persist history.canonical and restore it through initialMessages. A call whose result was not saved before the page went away is dispatched again after the reload, because the hook cannot know it ran. So this is at-most-once within a mounted hook, not exactly-once across reloads. Make such tools idempotent, or omit onToolCall and confirm parked calls yourself. A tab that only watches a stream another tab drives should omit onToolCall too, or it runs that tab's calls.

The history holds tool results the client posts

The round-trip POSTs a role: 'tool' message, and so does the history after an approval or a server tool the leg ran. Since 2.3 the default validateChatRequest accepts it, because it answers calls of the assistant turn right before it; before 2.3 a route had to pass { rejectToolResults: false }. Read what the validator cannot tell apart: a forged result in that shape passes too.

Tool approvals

A tool-approval-request part pauses the chat: the request lands in pendingApprovals, the matching toolCalls entry flips to state: 'approval-requested', and nothing is re-POSTed. Record verdicts with addToolApprovalResponse; once every pending approval has one, the hook resumes with approvalResponses in the request body — the server settles the gated calls, the client never fabricates a gated tool_result. The request's signed token is auto-preserved.

The resumed leg executes the approved call and streams its state and result, not the call itself. Since 2.3 the hook adds that result to history.canonical as a role: 'tool' message, so the next request answers the call. Before 2.3 the result reached only the screen, and the turn repeated the call in the history without its input, so the next request carried an open tool_use. The default validateChatRequest accepts that message since 2.3, because it answers a call of the assistant turn right before it; see what that accepts.

{pendingApprovals.map((req) => (
  <ApprovalCard
    key={req.approvalId}
    request={req}
    onDecision={(approved, reason) =>
      addToolApprovalResponse({ approvalId: req.approvalId, approved, reason })
    }
  />
))}

Verdicts answer the open gate

A verdict counts only if it answers an approval in pendingApprovals, the gate the chat is paused on. It must name that approval's approvalId. If it carries a token, it must be that request's token: a token is minted per run and per call, so a different one means the verdict was issued for another run. Anything else is a no-op since 2.3. Nothing is sent, no state changes, and the promise resolves. That is the same contract as addToolResult.

VerdictBefore 2.3Since 2.3
Nothing is pendingA new POST and an empty assistant bubbleNo-op
A repeat of one already sent (a double click)The continuation is sent twiceNo-op
Left over from an earlier turn or a replaced transcriptA new POST, or sent along with the next gate's verdictsNo-op
An approvalId the gate does not holdSent along with the gate's verdictsNo-op
Another run's token for a pending approvalIdAccepted, and sent with the wrong tokenNo-op

Until the last verdict arrives you can change your mind: answering an approval again replaces its verdict. Once every pending approval has one, the hook sends approvalResponses once and closes the gate. A repeated tool-approval-request frame is one entry in pendingApprovals, not two. Denials, reasons and tokens are sent exactly as before.

Without a token, the approvalId is all a verdict has. Since 2.3 core binds it to the call it was issued for: the tool call id followed by a digest of the tool and its arguments (and, for a durable or native run, of the run and the step). An OpenAI-compatible server that omits call ids gets tool_0, tool_1, … from the adapter, so a later gate can reuse an earlier gate's call id, but a verdict from a card that still holds the earlier request no longer answers a new gate with another tool or other arguments. To tie each verdict to the exact request it was shown for, sign approvals with createApprovalSigner and echo the token: addToolApprovalResponse({ approvalId: req.approvalId, approved, token: req.token }). A stale card then carries a stale token, and its verdict is a no-op.

A refused call carries denied on the UIToolCall, so it stops rendering as "getWeather failed":

{message.toolCalls?.map((call) =>
  call.denied ? (
    <Declined key={call.toolCallId} name={call.toolName} why={call.deniedReason} />
  ) : (
    <ToolCard key={call.toolCallId} call={call} />
  ),
)}

The built-in loop sets it for every refusal it settles: a server-mode approveToolCall returning false (denied: true, no reason — the hook's verdict is a bare boolean), an approvalResponses verdict (its own reason, verbatim), a gated call left unanswered, an unanswered client tool, and a token approvalSigner rejected. A tool that threw gains no denial fields at all, which is the whole point. deniedReason arrives already secret-redacted.

state is unchanged — still exactly 'call' | 'result' | 'approval-requested', because these are optional fields, not a fourth state.

Since 2.3 a UIToolCall also carries outcome, from the latest tool-state or tool-result frame for it: 'tool-error', 'permission-denied', 'approval-denied', 'approval-pending' or 'unknown-effect' (see Outcomes). It tells apart what denied alone cannot: a guardrail block ('permission-denied') and an approver's no ('approval-denied') both set denied. It is absent while the call runs and after it succeeds.

Native agent routes

useChat also drives a route that serves a native agent run (streamAgent / resumeStreamAgent) through toDeuzAgentStreamResponse. Nothing is configured on the client. The route's data-agent frames fold into the turn (agent), and when the run suspends, the hook resumes it the way resumeStreamAgent needs:

  • The run's terminal frame is the authority on what it waits for. Its approvals pause the chat (pendingApprovals); its client calls (tools without execute on the server) run through onToolCall or park in pendingToolCalls.
  • One request resumes the run: { messages, runId, approvalResponses?, clientToolResults? }. A native resume proceeds only with a verdict for every pending approval and a result for every pending client call, so after the last verdict the hook runs the client calls first, then sends both together. The calls run inside that round: status is 'streaming' from the last verdict on, stop() while they run cancels the request, and a message sent meanwhile drops the gate, so the run is not resumed. clientToolResults entries are { toolCallId, output, isError? }. The answers also join history.canonical, as on a chat route.
  • The 2.3 settlement rules hold: a verdict counts only for the open gate (F06), and a replayed client call runs at most once per mounted hook (F07). reconnect() reads a run's journal like any other.
  • agent is the run's state for the turn: runId, status ('running', 'suspended', 'completed', 'stopped', 'failed'), the latest partial draft while it runs, and, only once completed, the output the server validated. A stopped run has a reason ('aborted', 'cancelled', 'refusal', …) and, for a refusal, stopDetails. The agent frames stay out of dataParts and onData.
  • A chat route is unchanged. It never sends a frame, so the hook never sends runId or clientToolResults to it. With a core older than 2.3 there are no agent frames to read, and the hook behaves as before.

The route starts a run for a request without runId and resumes the run for one with it:

app/api/agent/route.ts
import { createInMemoryAgentRunStore, resumeStreamAgent, streamAgent } from '@deuz-sdk/core/agent';
import { validateChatRequest } from '@deuz-sdk/core/chat';
import { createMockModel } from '@deuz-sdk/core/testing';
import {
  createInMemoryStreamStateStore,
  negotiateDeuzStreamVersion,
  toDeuzAgentStreamResponse,
} from '@deuz-sdk/core/ui';

const runs = createInMemoryAgentRunStore(); // a durable store in production
const streams = createInMemoryStreamStateStore();
const model = createMockModel({
  responses: [
    // One turn asks for both: an approval and a client call.
    {
      toolCalls: [
        { toolName: 'publish', args: { doc: 7 } },
        { toolName: 'locate', args: {} },
      ],
    },
    { text: 'Published document 7 for Paris.' },
  ],
});
const params = { type: 'object' as const, properties: { doc: { type: 'number' } } };
const options = {
  model,
  bindingId: 'publish-v1',
  tools: {
    publish: {
      description: 'Publish a document.',
      parameters: params,
      needsApproval: true,
      execute: ({ doc }: { doc?: number }) => `published ${doc}`,
    },
    // No `execute`: the browser answers it through `onToolCall`.
    locate: { description: "The user's city.", parameters: params },
  },
};

export async function POST(req: Request): Promise<Response> {
  // The defaults accept the history useChat posts: each tool result answers the turn before it.
  const parsed = validateChatRequest(await req.json());
  if (!parsed.ok) return Response.json({ issues: parsed.issues }, { status: 400 });
  const { rest } = parsed.request;
  const scope = 'tenant-a'; // the signed-in user's tenant, never a field of the body
  const resuming = typeof rest.runId === 'string';
  const runId = resuming ? (rest.runId as string) : crypto.randomUUID();
  if (resuming) {
    // Only this user's run, and only while it waits for answers: another tenant's
    // runId, a second tab or a double submit is refused before a leg starts.
    const stored = await runs.load(runId);
    if (stored?.scope !== scope) return new Response('Unknown run', { status: 404 });
    if (stored.result?.status !== 'suspended') return new Response('Not waiting', { status: 409 });
  }
  const session = { store: runs, runId, scope };
  const stream = resuming
    ? resumeStreamAgent({
        ...options,
        session,
        ...(parsed.request.approvalResponses
          ? { approvalResponses: parsed.request.approvalResponses }
          : {}),
        ...(Array.isArray(rest.clientToolResults)
          ? { clientToolResults: rest.clientToolResults as { toolCallId: string; output: unknown }[] }
          : {}),
      })
    : streamAgent({ ...options, session, messages: parsed.request.messages });
  return toDeuzAgentStreamResponse(stream, {
    runId,
    store: streams, // every leg of the run in one journal: `seq` grows across them
    streamId: runId,
    wireVersion: negotiateDeuzStreamVersion(req),
  });
}

A resume reads the run's history from the store, not from the posted messages, and resumeStreamAgent refuses a runId stored under another scope, so take the scope from your authentication. The route checks both before it resumes: a run is stored without a result while a leg executes, so a second tab or a double submit gets a 409, and useChat shows it as an error. A request that gets past the check anyway (two arriving at once) is refused by the run itself, and toDeuzAgentStreamResponse keeps such a refused leg out of the journal: its client sees the failure, and the leg that runs keeps its event ids. A client result passes the same validation and projection path as a server tool's result. To let a client reconnect, serve resumeDeuzStreamResponse(streams, runId, { lastEventId }) from a GET route (after checking that the user owns runId) and point resume.endpoint at it.

AgentChat.tsx
'use client';
import { useChat } from '@deuz-sdk/react';

export function AgentChat() {
  const { messages, status, sendMessage, pendingApprovals, addToolApprovalResponse, agent, stopDetails } =
    useChat({
      api: '/api/agent',
      onToolCall: async ({ toolName }) => (toolName === 'locate' ? { city: 'Paris' } : null),
    });

  return (
    <div>
      {messages.map((m) => (
        <p key={m.id}>
          <b>{m.role}:</b> {m.content}
        </p>
      ))}
      {pendingApprovals.map((request) => (
        <button
          key={request.approvalId}
          onClick={() => addToolApprovalResponse({ approvalId: request.approvalId, approved: true })}
        >
          Approve {request.toolName}
        </button>
      ))}
      {agent?.status === 'completed' ? <output>{String(agent.output)}</output> : null}
      {agent?.status === 'stopped' && agent.reason === 'refusal' ? (
        <p role="alert">Refused: {stopDetails?.category ?? 'no reason given'}</p>
      ) : null}
      <button disabled={status === 'streaming'} onClick={() => sendMessage('Publish document 7.')}>
        Send
      </button>
    </div>
  );
}

The approval card resumes the run with runId and the verdict; locate then runs in the browser, and its result goes back with the same verdict in one request.

throttleMs

syncTurn used to publish up to seven setState calls per folded wire part; it now builds one snapshot and publishes it once. On top of that, throttleMs (default 0 = 1.8 behaviour) coalesces commits on the trailing edge:

useChat({ api: '/api/chat', throttleMs: 50 });

A fast model emits hundreds of text deltas per second; with markdown rendering over a long transcript that visibly janks. The terminal frame is always flushed — stream end, error, abort, or an external tool result — so the final text can never be lost to a throttle window.

Values are unchanged by the collapse, including the non-obvious one: cost stays cumulative across turns while dataParts / citations / plan / activity / verifications / warnings / falseFinishes / subAgents / steps / usage / finishReason stay turn-scoped.

Resuming

useChat({
  api: '/api/chat',
  resume: {
    endpoint: `/api/stream/${streamId}`, // resumeDeuzStreamResponse — NOT the POST route
    auto: true,
    cursor: {
      load: () => sessionStorage.getItem('deuz-cursor') ?? undefined,
      save: (id) => sessionStorage.setItem('deuz-cursor', id),
    },
  },
});
  • auto fires reconnect() once per mounted hook — the canonical scenario resumability exists for (the user refreshes mid-generation) with zero app wiring. A ref guard survives React 18/19 StrictMode's deliberate mount → unmount → mount double-invoke.
  • A failing automatic attempt is silent: no error, no status: 'error', no onError. The user did not ask for it, and every cold load of an app whose resume endpoint answers 404 would otherwise paint a permanent error. reconnect() called by hand keeps 1.8's exact error semantics.
  • A caught-up endpoint is a no-op. A resume that folds zero parts (an immediate [DONE]) changes nothing at all; 1.8 pre-pushed the assistant bubble before reading and left a permanently empty one on screen. One exception (2.4): a continued in-flight turn that already got its finish settles there. Such a turn comes from a read that dropped between finish and [DONE] (continued over durable-stream or through reconnect()), or from a snapshot taken in that window and restored with resume.auto. Its answer joins history.canonical, and an approval or client-call leg opens its gate. This changes a 2.3 path: a manual reconnect() that finds the endpoint caught up used to do nothing for such a turn. A turn without its finish stays unsettled.
  • cursor is an injectable adapter — no localStorage is hardcoded (that would break SSR and React Native), and a throwing adapter cannot kill a live stream.
  • A resumed turn settles like a streamed one (2.3). Its approvals pause the chat, and its client calls reach onToolCall or pendingToolCalls; see Client tools after a reconnect. Under auto the read stays silent, but a POST it triggers (an onToolCall result continuing the chat) reports a failure like any other request.
  • The cursor follows the POST read too (2.3). When the POST response carries the journal's id: lines (the route passes store and streamId), they move the hook's in-memory cursor. A reconnect() after a settled turn, or after a leg the hook continued with a POST, then resumes after it and appends nothing the history already holds; a reconnect after a dropped POST continues that turn from the last part it showed. Before 2.3 the POST read left the cursor alone, so such a reconnect appended the replayed turn to the history a second time. The persisted cursor (resume.cursor) still moves on resume reads only, because a reload mid-turn must replay that turn from its start. setHistory and setMessages drop the in-memory cursor, so after a chat switch the new chat is read from its persisted cursor or its start. After a reload, a replay of a journal that also holds legs the restored history already has (a lost or old cursor) still appends them again: the restored history is all the hook knows. Let the resume endpoint answer 404 when nothing is in flight, and journal one run per streamId. Since 2.4 a route that announces message-ids closes that gap: a replayed message whose id the history already holds is skipped (see Snapshots and the handshake).

Snapshots and the handshake (2.4)

exportSnapshot() returns the chat as a ChatSnapshotV1, plain data you can persist with JSON.stringify: the canonical messages with the id each one renders under, the cursor, a turn still streaming, pending approvals with the verdicts already given, parked client calls with the results already given, a native run's gate, and the announced capabilities. It reads the latest state, not the last render. For a user bubble the id comes from generateId; for an assistant turn it is the stream's start messageId, the id a route stores. A tool or system message gets a generated id on its first export and keeps it, so every export names it the same. Fields with nothing to carry are left out. initialSnapshot restores all of it at mount.

A chat stored server-side restores from its ChatRecord directly:

import { useChat } from '@deuz-sdk/react';
import type { ChatRecord } from '@deuz-sdk/core/chat';

export function SavedChat({ record }: { record: ChatRecord }) {
  const chat = useChat({
    api: '/api/chat',
    chatId: record.chatId,
    // Each message renders under its stored id, so two equal texts stay two messages.
    initialSnapshot: { version: 1, messages: record.messages, messageIds: record.messageIds },
  });
  return (
    <ul>
      {chat.messages.map((message) => (
        <li key={message.id}>{message.content}</li>
      ))}
    </ul>
  );
}

A missing, empty or repeated id gets a fresh one from generateId. A snapshot whose version is not 1, or whose messages, messageIds, pendingApprovals, pendingToolCalls or capabilities is not an array, throws a TypeError. A restored turn continues from the snapshot's cursor on reconnect() or resume.auto, every frame once; without resume it is sealed. This also works against a 2.3 core: the hook applies the ids itself when uiFromMessages ignores its third argument. setHistory still drops gates and the cursor.

With capabilityHandshake on (the default), every POST and every resume read to a URL endpoint sends x-deuz-capabilities: chat-snapshot-v1, durable-stream, message-ids. A function resume endpoint receives the same string as ctx.capabilities, to forward as the header or as capabilities.request to resumeDeuzStreamResponse. A 2.4 route that takes part answers in the response header and in a first capabilities frame, which the hook consumes: it never reaches the turn, onData or the transcript; a resume read that sends only that frame is still a silent no-op. capabilities on the result holds the announced set, sorted; it is absent until a response takes part (a 2.3 route never does) or a restored snapshot recorded one. Each announcing response replaces the set, a response that announces nothing leaves it, and a string the hook does not know is kept as it is. The hook uses a feature only while it is announced:

CapabilityWhat the hook does
message-idsA resume read that delivers again a message whose id the history holds skips that message's frames, so a replay after a lost cursor renders, appends and runs nothing twice; two messages with the same text and their own ids are both kept. A leg that continued the run on the same stream without a start of its own (an approval or client-result continuation) is skipped only while the history holds a later assistant message for it; otherwise it renders and settles as its own message. An error frame is never skipped
chat-snapshot-v1POST bodies gain messageIds, aligned with messages
durable-streamA POST read that drops before it ends continues from resume.endpoint instead of ending in status: 'error'; this needs resume and a read that already got event ids

Without an announcement all three stay off and request bodies are the 2.3 bodies. A cross-origin api whose preflight lists allowed headers explicitly must add x-deuz-capabilities, or set capabilityHandshake: false; add Access-Control-Expose-Headers: x-deuz-capabilities to let the browser read the response header, otherwise the hook reads the first frame. The server side, durable streams and the full snapshot fields are on Chat and stream durability.

Truncated turns

A tail text/reasoning part stays state: 'streaming' until something ends it. applyUIPart seals on the wire's terminal parts (finish, error), and stop() or a stream that ends without finish now calls sealAssistantTurn, so a truncated turn stops rendering as still-streaming.

A stream that dies with neither finish nor error and is never sealed deliberately leaves the tail 'streaming' — that is the truth about a truncated turn. Read finishReason === 'length' to tell "the answer was cut off by the output limit" from "the answer was complete".

useObject

Streams streamObject partials from a route that returns toDeuzObjectStreamResponse:

import { useObject } from '@deuz-sdk/react';

const { object, isLoading, error, submit, stop } = useObject<Recipe>({
  api: '/api/recipe',
  throttleMs: 50, // 1.9, default 0
  onHttpError: 'error-part', // 1.9, forwarded verbatim to readDeuzStream
});

await submit({ dish: 'menemen' }); // POSTs { input } and streams object-delta parts
// object: DeepPartial<Recipe> | undefined — every field optional at every depth

Each object-delta replaces object wholesale (no merging needed). String fields stream truncated ('mene' → 'menemen'), so render defensively. Wire error parts and rejected fetches land in error; stop() aborts without erroring. submit clears the previous object immediately — that is never coalesced by throttleMs. It also accepts headers and fetch.

app/api/recipe/route.ts
import { streamObject } from '@deuz-sdk/core';
import { toDeuzObjectStreamResponse } from '@deuz-sdk/core/ui';

export async function POST(req: Request): Promise<Response> {
  const { input } = await req.json();
  const result = streamObject({ model, schema: recipeSchema, prompt: describe(input) });
  return toDeuzObjectStreamResponse(result);
}

The server route

app/api/chat/route.ts
import { streamChat } from '@deuz-sdk/core';
import { validateChatRequest } from '@deuz-sdk/core/chat';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { toDeuzStreamResponse } from '@deuz-sdk/core/ui';

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

export async function POST(req: Request): Promise<Response> {
  const parsed = validateChatRequest(await req.json());
  if (!parsed.ok) return Response.json({ issues: parsed.issues }, { status: 400 });

  const result = streamChat({
    model: anthropic('claude-opus-4-8'),
    instructions: 'You are a helpful assistant.',
    messages: parsed.request.messages,
    ...(parsed.request.approvalResponses
      ? { approvalResponses: parsed.request.approvalResponses }
      : {}),
    signal: req.signal,
  });
  return toDeuzStreamResponse(result);
}

To take part in the capability handshake, pass capabilities: { request: req, announce: [DEUZ_CAPABILITY.messageIds, DEUZ_CAPABILITY.chatSnapshot] } to toDeuzStreamResponse and to resumeDeuzStreamResponse, and store the messageIds the client posts as ChatRecord.messageIds; see Chat and stream durability.

See also

In questa pagina