Labsco
triggerdotdev logo

trigger-authoring-chat-agent

โ˜… 15,576

by triggerdotdev ยท part of triggerdotdev/trigger.dev

Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult vs calling chat.pipe(), the two server actions (chat.createStartSessionAction + auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building, modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React transport, when declaring typed tools or

๐Ÿ”’ Repo-maintenance skill. It exists to help maintain triggerdotdev/trigger.dev itself โ€” it's only useful if you contribute code to that project.

This is the playbook your agent receives when the skill activates โ€” you don't need to read it to use the skill, but it's here to audit before installing.

Authoring a chat agent

A chat.agent runs an entire conversation as one long-lived Trigger.dev task. It wakes when a message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle gaps, and crashes. Your code is the loop you would write anyway: messages in, streamText out. There are no API routes. The frontend talks to the agent through a TriggerChatTransport, so history accumulates server-side and the client ships only the new message each turn.

Works with Vercel AI SDK v5, v6, or v7. On v7 also install @ai-sdk/otel so model calls are traced (the SDK registers it for you).

Core patterns

1. Return vs pipe

Return the streamText result from run for the simple case. When streamText is called deep inside nested helpers, call await chat.pipe(result) from anywhere in the task instead, and let run resolve void.

import { chat, type ChatStreamText } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import type { ModelMessage } from "ai";

export const agentChat = chat.agent({
  id: "agent-chat",
  run: async ({ messages, streamText }) => {
    await runAgentLoop(messages, streamText); // don't return; pipe inside
  },
});

// A loop factored out of `run` takes `streamText` as an argument, so it keeps the
// managed options. `ChatStreamText` (from `@trigger.dev/sdk/ai`) types the parameter.
// `chat.toStreamTextOptions()` is the alternative when threading it down is impractical.
async function runAgentLoop(messages: ModelMessage[], streamText: ChatStreamText) {
  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages,
  });
  await chat.pipe(result); // works from anywhere in the task
}

2. Typed tools (declare on config AND pass back)

Declare tools on chat.agent({ tools }), read them back typed from the run() payload, and pass that set as tools. One declaration flows everywhere.

import { tool, stepCountIs } from "ai";
import { z } from "zod";

const tools = {
  searchDocs: tool({
    description: "Search the docs.",
    inputSchema: z.object({ query: z.string() }),
    execute: async ({ query }) => searchIndex(query),
  }),
};

export const myChat = chat.agent({
  id: "my-chat",
  tools, // so toModelOutput survives across turns
  run: async ({ messages, tools, signal, streamText }) =>
    streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools, // same set, handed back typed
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});

tools also accepts a function (event) => ToolSet resolved per turn, where event carries chatId, turn, continuation, and clientData.

3. Custom data parts (persisted vs transient)

data-* parts written via chat.response.write() in run() (or writer.write() in hooks) persist into responseMessage.parts and surface in onTurnComplete. Add transient: true to stream them without persisting. Writes via chat.stream are always ephemeral.

// In run() - persists, surfaces in onTurnComplete's responseMessage
chat.response.write({ type: "data-context", data: { searchResults } });

// In a hook via writer - streams but does NOT persist
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });

4. Custom UIMessage type, client data, and builder hooks

For typed data-* parts or a tool map, build the agent through chat.withUIMessage<T>() and chat.withClientData({ schema }). Builder methods chain in any order; builder hooks run before the matching task hook. streamOptions becomes the default uiMessageStreamOptions (shallow-merged, agent wins).

export const myChat = chat
  .withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } })
  .withClientData({ schema: z.object({ userId: z.string() }) })
  .agent({
    id: "my-chat",
    tools: myTools,
    onTurnStart: async ({ uiMessages, writer }) => {
      writer.write({ type: "data-turn-status", data: { status: "preparing" } });
    },
    run: async ({ messages, tools, signal, streamText }) =>
      streamText({ model, messages, tools, abortSignal: signal }),
  });

Build MyChatUIMessage as UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>> (or, for tools only, InferChatUIMessageFromTools<typeof tools> from @trigger.dev/sdk/ai). On the frontend, narrow useChat with InferChatUIMessage<typeof myChat> from @trigger.dev/sdk/chat/react.

5. Lifecycle hooks and stop

chat.agent accepts hooks that fire in a fixed per-turn order:

onValidateMessages -> storage.loadContext (or the deprecated hydrateMessages)
  -> onChatStart (chat's first message only)
  -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete -> storage.save

onBoot fires once per worker process (every fresh boot, including continuation runs) and is where chat.local, DB connections, and per-process state belong. onChatStart fires only on the chat's first message. Suspend/resume use onChatSuspend / onChatResume. Config options include tools, clientDataSchema, maxTurns (100), turnTimeout ("1h"), idleTimeoutInSeconds (30), uiMessageStreamOptions, and exitAfterPreloadIdle. There is no generic retry; chat.agent runs with maxAttempts: 1 internally.

Stop depends on it: the signal passed to run aborts on stop or cancel. Forward it as abortSignal to streamText, or the Stop button updates the UI while the model keeps generating server-side.

run: async ({ messages, signal, streamText }) =>
  streamText({ model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });

6. Migrating from a plain AI SDK streamText route

There is no API route in this model. The transport replaces the route round-trip, so:

  • Delete the route handler. Move per-request auth into the two server actions from Setup step 2.
  • Move the streamText call into run. It already receives pre-converted ModelMessage[].
  • Return the StreamTextResult (it auto-pipes) and take streamText from run's argument, not from ai.
  • On the client, swap the api URL for useTriggerChatTransport; useChat stays the same shape.

References

  • trigger-chat-agent-advanced skill - lifecycle hooks in depth, sessions, raw-task primitives (chat.createSession, chat.customAgent, chat.stream), compaction, HITL approvals, recovery.
  • trigger-realtime-and-frontend skill - Realtime hooks and frontend streaming beyond the chat transport.
  • trigger-authoring-tasks skill - base task() semantics, ctx, and standard lifecycle hooks.

Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The sources: frontmatter above lists every doc this skill draws from, all under @trigger.dev/sdk/docs/ai-chat/. Start with quick-start.mdx, backend.mdx, tools.mdx, types.mdx, frontend.mdx.

A chat.agent is a Trigger.dev task, so it builds and deploys like any other. For trigger.config.ts and build extensions (Prisma, Playwright, Python, FFmpeg, etc. โ€” e.g. when a tool needs them), read the bundled config docs under @trigger.dev/sdk/docs/config/ (extensions are in config/extensions/, starting with overview.mdx).

Version

This skill is bundled inside @trigger.dev/sdk and read directly from node_modules, so it always matches your installed SDK version (see the adjacent package.json). The full documentation for these APIs ships alongside it under @trigger.dev/sdk/docs/.