You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
**Lifecycle flow:** Wake → parse action against `actionSchema` → `hydrateMessages` (if set) → **`onAction`** → apply `chat.history` mutations → emit `trigger:turn-complete` → wait for next message.
54
54
55
-
## Returning a model response from an action
55
+
## Answering after an action
56
56
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.
62
58
63
59
```ts
64
-
onAction: async ({ action, streamText }) => {
65
-
if (action.type==="regenerate") {
66
-
chat.history.slice(0, -1); // drop the last assistant
67
-
returnstreamText({
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.
chat.inject([{ role: "system", content: "Answer formally this time." }]);
73
+
returnchat.turn(); // with a one-shot instruction
74
74
}
75
-
// other actions return void → side-effect only
76
75
}
77
76
```
78
77
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.
80
79
81
80
### Actions and persistence
82
81
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.
84
83
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.
86
85
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.
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
-
114
105
## Gating actions on HITL state
115
106
116
107
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
148
139
## See also
149
140
150
141
-[`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
152
143
-[`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): fires before `onAction` when set
153
144
-[Branching conversations](/ai-chat/patterns/branching-conversations): pairs action handlers with backend-controlled history
154
145
-[Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): gating fresh actions while a tool is waiting
Copy file name to clipboardExpand all lines: docs/ai-chat/client-protocol.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -832,7 +832,7 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
832
832
}
833
833
```
834
834
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 historyand its chunks follow on `.out` like any turn's.
836
836
837
837
Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
Copy file name to clipboardExpand all lines: docs/ai-chat/reference.mdx
+2-3Lines changed: 2 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -46,7 +46,7 @@ Options for `chat.agent()`.
46
46
|`onValidateMessages`|`(event: ValidateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>`| — | Validate/transform UIMessages before model conversion. See [onValidateMessages](/ai-chat/lifecycle-hooks#onvalidatemessages)|
47
47
|`hydrateMessages`|`(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>`| — | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages)|
48
48
|`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)|
50
50
|`onTurnStart`|`(event: TurnStartEvent) => Promise<void> \| void`| — | Fires every turn before `run()`|
51
51
|`onBeforeTurnComplete`|`(event: BeforeTurnCompleteEvent) => Promise<void> \| void`| — | Fires after response but before stream closes. Includes `writer`. |
52
52
|`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).
245
245
|`clientData`| Typed by `clientDataSchema`| Custom data from the frontend |
246
246
|`uiMessages`|`UIMessage[]`| Accumulated UI messages (after hydration, if set) |
247
247
|`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 |
249
248
250
249
## TurnStartEvent
251
250
@@ -800,7 +799,7 @@ See [Stop generation](/ai-chat/frontend#stop-generation) for full details.
800
799
801
800
### transport.sendAction()
802
801
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.
Copy file name to clipboardExpand all lines: docs/ai-chat/testing.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -203,7 +203,7 @@ Equivalent to the frontend's `useChat().regenerate()` — replays a turn with th
203
203
204
204
### sendAction
205
205
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.
0 commit comments