Skip to main content

Overview

Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. Actions are not turns: they fire hydrateMessages (if set) and onAction only. No turn lifecycle hooks (onTurnStart / prepareMessages / onBeforeTurnComplete / onTurnComplete), no run(), no turn-counter increment. The trace span is named chat action. Actions wake the agent from suspension the same way a new message does, run their handler against the latest accumulator state, and emit a trigger:turn-complete chunk so the frontend’s useChat knows the action has been applied.

Defining an action handler

Define an actionSchema for validation and an onAction handler that uses chat.history to modify state:
Lifecycle flow: Wake → parse action against actionSchemahydrateMessages (if set) → onAction → apply chat.history mutations → emit trigger:turn-complete → wait for next message. When onAction returns chat.turn(), the flow continues instead of emitting trigger:turn-complete: the edit is snapshotted, then a turn runs on the edited history with trigger: "action-turn", so onTurnStart, run(), onBeforeTurnComplete and onTurnComplete all fire and the answer is persisted like any turn’s. See Answering after an action.

Answering after an action

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.
run() receives the edited history with no incoming user message, the same shape as a regenerate-message turn, and its trigger is "action-turn", so a run() that returns early on "action" (the pre-May behaviour, when actions invoked run() directly) still answers. Returning anything other than chat.turn() or nothing is an error; a response can no longer be returned from onAction directly.

Actions and persistence

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

Gating actions on HITL state

If you have a human-in-the-loop tool waiting on addToolOutput, you usually want to refuse competing actions like regenerate until the answer arrives. chat.history.getPendingToolCalls() gives you exactly that signal:

Sending actions from the frontend

The action payload is validated against actionSchema on the backend; invalid actions throw and surface as a stream error. The action parameter in onAction is fully typed from the schema.
For silent state changes that should never appear as a turn (e.g. injecting background context), use chat.inject() instead. Actions are explicit user-driven mutations; injections are agent-side context updates.

See also