> ## Documentation Index
> Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Actions

> Custom commands sent from the frontend that mutate chat state without consuming a turn: undo, rollback, edit, regenerate.

## 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`](https://trigger.dev/docs/ai-chat/backend#chat-history) to modify state:

```ts theme={"theme":"css-variables"}
import { z } from "zod";

export const myChat = chat.agent({
  id: "my-chat",
  actionSchema: z.discriminatedUnion("type", [
    z.object({ type: z.literal("undo") }),
    z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
    z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
  ]),

  onAction: async ({ action }) => {
    switch (action.type) {
      case "undo":
        chat.history.slice(0, -2); // Remove last user + assistant exchange
        break;
      case "rollback":
        chat.history.rollbackTo(action.targetMessageId);
        break;
      case "edit":
        chat.history.replace(action.messageId, {
          id: action.messageId,
          role: "user",
          parts: [{ type: "text", text: action.text }],
        });
        break;
    }
    // returning void → side-effect-only, no model call
  },

  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

**Lifecycle flow:** Wake → parse action against `actionSchema` → `hydrateMessages` (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).

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

```ts theme={"theme":"css-variables"}
onAction: async ({ action }) => {
  switch (action.type) {
    case "undo":
      chat.history.slice(0, -2);
      return; // edit only, no turn

    case "regenerate":
      chat.history.slice(0, -1);
      return chat.turn(); // answer the edited history

    case "retry-formal":
      chat.history.slice(0, -1);
      chat.inject([{ role: "system", content: "Answer formally this time." }]);
      return chat.turn(); // with a one-shot instruction

    case "ask":
      return chat.turn(action.question); // answer a new user message
  }
}
```

`chat.turn(message)` appends `message` (a string, or a `UIMessage`) to the history as a user message after the edit, and the turn answers it. That is how a button click becomes a question, such as a follow-up suggestion in [Slack](https://trigger.dev/docs/webhooks/slack.md#buttons-that-send-the-agent-an-action).

Without a message, `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.

**Transcript storage** (the default, or your own `storage`): nothing to do. After an action that changed the conversation, the runtime hands the storage a changeset with `reason: "action"`. An undo is one `truncateAfter`; a regenerate is a `truncateAfter` followed by the new answer's `put` when the turn completes; an edit is a `put` for the edited id. The changeset carries the same resume cursors as the last turn. See [Transcript storage](https://trigger.dev/docs/ai-chat/transcript-storage.md#what-the-runtime-saves). An action that returns `chat.turn()` is followed by a turn, which persists its answer the way every turn does.

**Your own store through the deprecated `hydrateMessages`**: 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.

```ts theme={"theme":"css-variables"}
onAction: async ({ action, chatId }) => {
  if (action.type === "undo") {
    chat.history.slice(0, -2);
    await db.deleteLastExchange(chatId); // the rollback is yours to persist
  }
  if (action.type === "regenerate") {
    chat.history.slice(0, -1);
    await db.deleteLastAssistant(chatId); // the delete half
    return chat.turn(); // the insert half arrives in onTurnComplete
  }
},
onTurnComplete: async ({ chatId, newUIMessages }) => {
  await db.saveMessages(chatId, newUIMessages);
},
```

## Gating actions on HITL state

If you have a [human-in-the-loop](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md) tool waiting on `addToolOutput`, you usually want to refuse competing actions like `regenerate` until the answer arrives. [`chat.history.getPendingToolCalls()`](https://trigger.dev/docs/ai-chat/backend#chat-history) gives you exactly that signal:

```ts theme={"theme":"css-variables"}
onAction: async ({ action }) => {
  if (action.type === "regenerate") {
    if (chat.history.getPendingToolCalls().length > 0) return; // gated
    chat.history.slice(0, -1);
    return chat.turn();
  }
},
```

## Sending actions from the frontend

```ts theme={"theme":"css-variables"}
// Browser: TriggerChatTransport
const stream = await transport.sendAction(chatId, { type: "undo" });

// Server: AgentChat
const stream = await agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" });
```

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.

> [!NOTE]
>
> For silent state changes that should never appear as a turn (e.g. injecting background context), use [`chat.inject()`](https://trigger.dev/docs/ai-chat/background-injection) instead. Actions are explicit user-driven mutations; injections are agent-side context updates.

## See also

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.