Skip to content

Commit 3203120

Browse files
ericallamclaude
andcommitted
docs(chat): describe actions as edits that can become turns
onAction returns nothing or chat.turn(); the section on returning a response from an action is replaced, the frontend sends actions through useChat, and the reference drops the onAction streamText argument. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
1 parent a8a6ba6 commit 3203120

5 files changed

Lines changed: 33 additions & 43 deletions

File tree

docs/ai-chat/actions.mdx

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -52,65 +52,56 @@ export const myChat = chat.agent({
5252

5353
**Lifecycle flow:** Wake → parse action against `actionSchema``hydrateMessages` (if set) → **`onAction`** → apply `chat.history` mutations → emit `trigger:turn-complete` → wait for next message.
5454

55-
## Returning a model response from an action
55+
## Answering after an action
5656

57-
`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. All three are sent to the frontend and added to the conversation just like a normal turn's answer, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire. A returned `UIMessage` must have `role: "assistant"`; its text and `data-*` parts are delivered, and other part types are dropped.
58-
59-
The `messages` argument is captured before `onAction` runs, so a handler that mutates `chat.history` and then passes `messages` straight through sends the model the state it just changed. Rebuild from `chat.history.all()` after the mutation.
60-
61-
Build the response with the `streamText` from `onAction`'s own argument, the same one `run()` receives. It carries the agent's system prompt, config tools, skill tools, resolved model and telemetry, so a regenerated answer is produced under the same configuration as every other turn. The `streamText` imported from `ai` carries none of that, and the reply still looks fine, which is what makes the difference easy to miss.
57+
An action is a state edit. To answer after the edit, return `chat.turn()`: the edit is applied and snapshotted, then a turn runs on the edited history exactly as a message turn does. `onTurnStart`, `run()`, `onBeforeTurnComplete` and `onTurnComplete` fire, the turn counter advances, and the answer gets everything a turn has: the agent's system prompt and tools, steering, compaction, injected instructions and persistence.
6258

6359
```ts
64-
onAction: async ({ action, streamText }) => {
65-
if (action.type === "regenerate") {
66-
chat.history.slice(0, -1); // drop the last assistant
67-
return streamText({
68-
model: anthropic("claude-sonnet-4-5"),
69-
// Rebuild from the mutated history. The `messages` argument was captured
70-
// before the slice, so it still contains the answer being replaced.
71-
messages: await convertToModelMessages(chat.history.all()),
72-
stopWhen: stepCountIs(15),
73-
});
60+
onAction: async ({ action }) => {
61+
switch (action.type) {
62+
case "undo":
63+
chat.history.slice(0, -2);
64+
return; // edit only, no turn
65+
66+
case "regenerate":
67+
chat.history.slice(0, -1);
68+
return chat.turn(); // answer the edited history
69+
70+
case "retry-formal":
71+
chat.history.slice(0, -1);
72+
chat.inject([{ role: "system", content: "Answer formally this time." }]);
73+
return chat.turn(); // with a one-shot instruction
7474
}
75-
// other actions return void → side-effect only
7675
}
7776
```
7877

79-
This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style).
78+
`run()` receives the edited history with no incoming user message, the same shape as a `regenerate-message` turn, and its `trigger` is `"action"`. Returning anything other than `chat.turn()` or nothing is an error; a response can no longer be returned from `onAction` directly.
8079

8180
### Actions and persistence
8281

83-
An action is not a turn, so `onTurnComplete` never fires, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use.
82+
An action that returns nothing does not fire `onTurnComplete`, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use.
8483

85-
**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation (a `chat.history` mutation, a response returned from `onAction`, or both), the runtime writes the snapshot, so the change survives the run ending.
84+
**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation, the runtime writes the snapshot, so the edit survives the run ending. An action that returns `chat.turn()` is followed by a turn, which persists its answer the way every turn does.
8685

87-
**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured:
86+
**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history edit lives only in the running worker until you persist it, and a continuation rehydrates from your store, not from what the worker had in memory. Mirror each edit in your store, not only additions: a regenerate is a delete *and* an insert. The answer that follows `chat.turn()` reaches your store through `onTurnComplete`, like any turn's answer.
8887

8988
```ts
90-
onAction: async ({ action, messages, streamText }) => {
89+
onAction: async ({ action, chatId }) => {
9190
if (action.type === "undo") {
9291
chat.history.slice(0, -2);
9392
await db.deleteLastExchange(chatId); // the rollback is yours to persist
9493
}
95-
9694
if (action.type === "regenerate") {
9795
chat.history.slice(0, -1);
98-
await db.deleteLastAssistant(chatId); // drop the answer being replaced
99-
const { message } = await chat.pipeAndCapture(
100-
streamText({
101-
model: anthropic("claude-sonnet-4-5"),
102-
messages: await convertToModelMessages(chat.history.all()),
103-
})
104-
);
105-
if (message) await db.saveMessage(message); // then store the new one
96+
await db.deleteLastAssistant(chatId); // the delete half
97+
return chat.turn(); // the insert half arrives in onTurnComplete
10698
}
10799
},
100+
onTurnComplete: async ({ chatId, newUIMessages }) => {
101+
await db.saveMessages(chatId, newUIMessages);
102+
},
108103
```
109104

110-
Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert. Saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.)
111-
112-
Returning the stream instead of piping it yourself still works and still reaches the browser, but you have no message to store, so the next run does not know about it.
113-
114105
## Gating actions on HITL state
115106

116107
If you have a [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tool waiting on `addToolOutput`, you usually want to refuse competing actions like `regenerate` until the answer arrives. [`chat.history.getPendingToolCalls()`](/ai-chat/backend#chat-history) gives you exactly that signal:
@@ -148,7 +139,7 @@ The action payload is validated against `actionSchema` on the backend; invalid a
148139
## See also
149140

150141
- [`chat.history`](/ai-chat/backend#chat-history): the imperative API actions use to mutate state
151-
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): `transport.sendAction` ergonomics
142+
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): sending actions through `useChat` so a turn that follows one renders like any turn
152143
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): fires before `onAction` when set
153144
- [Branching conversations](/ai-chat/patterns/branching-conversations): pairs action handlers with backend-controlled history
154145
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): gating fresh actions while a tool is waiting

docs/ai-chat/client-protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
832832
}
833833
```
834834
835-
For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/ai-chat/actions) for the agent-side schema setup.
835+
For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns `chat.turn()`, a turn runs on the edited history and its chunks follow on `.out` like any turn's.
836836
837837
Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
838838

docs/ai-chat/reference.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Options for `chat.agent()`.
4646
| `onValidateMessages` | `(event: ValidateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || Validate/transform UIMessages before model conversion. See [onValidateMessages](/ai-chat/lifecycle-hooks#onvalidatemessages) |
4747
| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) |
4848
| `actionSchema` | `TaskSchema` || Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) |
49-
| `onAction` | `(event: ActionEvent) => Promise<unknown> \| unknown` || Handle custom actions. Actions are not turns — only `hydrateMessages` + `onAction` fire. Return a `StreamTextResult` (or `string` / `UIMessage`) for a model response; return `void` for side-effect-only. See [Actions](/ai-chat/actions) |
49+
| `onAction` | `(event: ActionEvent) => Promise<void \| ActionTurn> \| void \| ActionTurn` || Handle custom actions. Actions are state edits: only `hydrateMessages` + `onAction` fire. Return `chat.turn()` to run a turn on the edited history, or nothing for an edit only. See [Actions](/ai-chat/actions) |
5050
| `onTurnStart` | `(event: TurnStartEvent) => Promise<void> \| void` || Fires every turn before `run()` |
5151
| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise<void> \| void` || Fires after response but before stream closes. Includes `writer`. |
5252
| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise<void> \| void` || Fires after each turn completes (stream closed) |
@@ -245,7 +245,6 @@ Passed to the `onAction` callback. See [Actions](/ai-chat/actions).
245245
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
246246
| `uiMessages` | `UIMessage[]` | Accumulated UI messages (after hydration, if set) |
247247
| `messages` | `ModelMessage[]` | Accumulated model messages (after hydration, if set) |
248-
| `streamText` | `ChatStreamText` | `streamText` with the agent's managed options applied, the same one `run()` receives. Use it for a response produced from an action so the answer carries the agent's own prompt and tools |
249248

250249
## TurnStartEvent
251250

@@ -800,7 +799,7 @@ See [Stop generation](/ai-chat/frontend#stop-generation) for full details.
800799

801800
### transport.sendAction()
802801

803-
Send a custom action to the agent. Actions wake the agent from suspension and fire `onAction`. They are not turns — `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend.
802+
Send a custom action to the agent, outside `useChat`. Actions wake the agent from suspension and fire `onAction`. An action that returns `chat.turn()` is followed by a turn; its answer arrives on the returned stream, which the caller must read. From a `useChat` app, send actions as requests instead (`sendMessage(undefined, { body: { action } })` or the `useChatActions` hook) so `useChat` renders the answer.
804803

805804
```ts
806805
transport.sendAction(chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>>

docs/ai-chat/testing.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ Equivalent to the frontend's `useChat().regenerate()` — replays a turn with th
203203

204204
### sendAction
205205

206-
Routes a payload through `actionSchema` + `onAction`. Actions are not turns: only `hydrateMessages` and `onAction` fire on the agent side — no turn lifecycle hooks, no `run()`. The returned `turn.rawChunks` contains whatever `onAction` produced (a streamed model response if it returned a `StreamTextResult`, otherwise just `trigger:turn-complete`):
206+
Routes a payload through `actionSchema` + `onAction`. An action is a state edit: only `hydrateMessages` and `onAction` fire, unless `onAction` returns `chat.turn()`, in which case a turn runs on the edited history and the returned `turn.rawChunks` carries that turn's answer.
207207

208208
```ts
209209
const turn = await harness.sendAction({ type: "undo" });

docs/ai-chat/upgrade-guide.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,8 @@ and direct API consumers.
298298
fire at the same lifecycle points.
299299
- `onAction` is still defined the same way, but its semantics changed
300300
in the [May 6 prerelease](/ai-chat/changelog) — actions are no longer
301-
turns, and `onAction` returning a `StreamTextResult` produces a model
302-
response.
301+
turns. To answer after an action's edit, return `chat.turn()`; returning
302+
a `StreamTextResult` is no longer supported.
303303
- `chat.customAgent({...})` and the `chat.createSession(payload, ...)`
304304
helper for building a session loop manually inside a custom agent.
305305
- `chat.defer` (deferred work) and `chat.history` (imperative history

0 commit comments

Comments
 (0)