trigger-authoring-chat-agent

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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).
chat.agent
将整个对话作为一个长期运行的 Trigger.dev 任务执行。有消息到达时它会唤醒,无消息时则暂停,内存状态可在页面刷新、部署、空闲间隔和崩溃后保留。你只需编写常规的循环逻辑:输入消息,输出
streamText
。无需API路由,前端通过
TriggerChatTransport
与代理通信,因此对话历史在服务器端累积,客户端每轮仅需发送新消息。
兼容 Vercel AI SDK v5、v6 或 v7。使用 v7 时还需安装
@ai-sdk/otel
,以便追踪模型调用(SDK会自动注册该工具)。

Setup

配置步骤

Three pieces: the agent task, two server actions, and the frontend transport.
分为三部分:代理任务、两个服务器操作,以及前端传输层。

1. Define the agent

1. 定义代理

ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) =>
    streamText({
      // Spread this FIRST. See "Common mistakes".
      ...chat.toStreamTextOptions(),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
run
receives
messages
already converted to
ModelMessage[]
(the SDK converts the frontend's
UIMessage[]
for you) plus a
signal
that aborts on stop or cancel. Returning the
StreamTextResult
auto-pipes it to the frontend.
ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) =>
    streamText({
      // 必须首先展开此内容。详见「常见错误」。
      ...chat.toStreamTextOptions(),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
run
函数会接收已转换为
ModelMessage[]
messages
(SDK会自动将前端的
UIMessage[]
转换为该格式),以及一个在停止或取消时触发中止的
signal
。返回
StreamTextResult
会自动将结果传输至前端。

2. Add two server actions

2. 添加两个服务器操作

Both run on your server, so the browser never holds your environment secret key. This is also where per-user / per-plan authorization and any paired DB writes live.
ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";

// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId).
export const startChatSession = chat.createStartSessionAction("my-chat");

// Pure mint. The transport calls this on 401/403 to refresh an expired token.
export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}
这两个操作均在服务器端运行,因此浏览器不会持有你的环境密钥。你也可以在此处添加按用户/按计划的授权逻辑,以及相关的数据库写入操作。
ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";

// 创建会话和首次运行,返回会话PAT。基于(env, chatId)实现幂等。
export const startChatSession = chat.createStartSessionAction("my-chat");

// 纯令牌生成逻辑。当传输层遇到401/403错误时会调用此方法刷新过期令牌。
export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}

3. Wire the frontend

3. 关联前端

tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat", // typeof myChat gives compile-time task-id validation
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, stop, status } = useChat({ transport });
  const [input, setInput] = useState("");
  // render messages, a form that calls sendMessage({ text: input }),
  // and a Stop button (onClick={stop}) while status === "streaming".
}
The transport is memoized (created once, reused across renders). Passing
typeof myChat
flows the agent's message type through
useChat
.
tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat", // typeof myChat 提供编译期任务ID验证
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, stop, status } = useChat({ transport });
  const [input, setInput] = useState("");
  // 渲染消息列表、调用 sendMessage({ text: input }) 的表单,
  // 以及在 status === "streaming" 时显示的「停止」按钮(点击时触发 stop)。
}
传输层会被缓存(仅创建一次,在多次渲染中复用)。传入
typeof myChat
会将代理的消息类型传递给
useChat

Core patterns

核心模式

1. Return vs pipe

1. 返回结果 vs 管道传输

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
.
ts
export const agentChat = chat.agent({
  id: "agent-chat",
  run: async ({ messages }) => {
    await runAgentLoop(messages); // don't return; pipe inside
  },
});

async function runAgentLoop(messages: ModelMessage[]) {
  const result = streamText({
    ...chat.toStreamTextOptions(),
    model: anthropic("claude-sonnet-4-5"),
    messages,
  });
  await chat.pipe(result); // works from anywhere in the task
}
简单场景下,直接从
run
返回
streamText
的结果即可。当
streamText
在嵌套工具内部调用时,可在任务的任意位置调用
await chat.pipe(result)
,并让
run
返回
void
ts
export const agentChat = chat.agent({
  id: "agent-chat",
  run: async ({ messages }) => {
    await runAgentLoop(messages); // 不返回结果,在内部进行管道传输
  },
});

async function runAgentLoop(messages: ModelMessage[]) {
  const result = streamText({
    ...chat.toStreamTextOptions(),
    model: anthropic("claude-sonnet-4-5"),
    messages,
  });
  await chat.pipe(result); // 在任务的任意位置均可生效
}

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

2. 类型化工具(需在配置中声明并回传)

Declare tools on
chat.agent({ tools })
, read them back typed from the
run()
payload, and pass that set to
chat.toStreamTextOptions({ tools })
. One declaration flows everywhere.
ts
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({
      ...chat.toStreamTextOptions({ tools }), // same set, handed back typed
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
tools
also accepts a function
(event) => ToolSet
resolved per turn, where
event
carries
chatId
,
turn
,
continuation
, and
clientData
.
chat.agent({ tools })
中声明工具,从
run()
的参数中读取已类型化的工具集,再将其传入
chat.toStreamTextOptions({ tools })
。一次声明即可在全流程复用。
ts
import { tool, stepCountIs } from "ai";
import { z } from "zod";

const tools = {
  searchDocs: tool({
    description: "搜索文档。",
    inputSchema: z.object({ query: z.string() }),
    execute: async ({ query }) => searchIndex(query),
  }),
};

export const myChat = chat.agent({
  id: "my-chat",
  tools, // 确保 toModelOutput 在多轮对话中持续生效
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }), // 复用同一工具集,且已类型化
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
tools
也可接受一个函数
(event) => ToolSet
,每轮对话都会解析该函数,其中
event
包含
chatId
turn
continuation
clientData

3. Custom data parts (persisted vs transient)

3. 自定义数据部分(持久化 vs 临时)

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.
ts
// 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 });
run()
中通过
chat.response.write()
(或在钩子中通过
writer.write()
)写入的
data-*
部分会持久化到
responseMessage.parts
中,并在
onTurnComplete
中暴露。添加
transient: true
可实现流式传输但不持久化。通过
chat.stream
写入的内容始终是临时的。
ts
// 在 run() 中 - 持久化,会在 onTurnComplete 的 responseMessage 中暴露
chat.response.write({ type: "data-context", data: { searchResults } });

// 在钩子中通过 writer - 流式传输但不持久化
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });

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

4. 自定义 UIMessage 类型、客户端数据和构建器钩子

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).
ts
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({ ...chat.toStreamTextOptions({ tools }), model, messages, 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
.
如需类型化的
data-*
部分或工具映射,可通过
chat.withUIMessage<T>()
chat.withClientData({ schema })
构建代理。构建器方法可按任意顺序链式调用;构建器钩子会在对应的任务钩子之前执行。
streamOptions
会成为默认的
uiMessageStreamOptions
(浅合并,代理配置优先级更高)。
ts
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({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }),
  });
MyChatUIMessage
定义为
UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>
(若仅需工具类型,可使用
@trigger.dev/sdk/ai
中的
InferChatUIMessageFromTools<typeof tools>
)。在前端,通过
@trigger.dev/sdk/chat/react
中的
InferChatUIMessage<typeof myChat>
缩小
useChat
的类型范围。

5. Lifecycle hooks and stop

5. 生命周期钩子与停止操作

chat.agent
accepts hooks that fire in a fixed per-turn order:
text
onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only)
  -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
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 is load-bearing: 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.
ts
run: async ({ messages, signal }) =>
  streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
chat.agent
支持以下钩子,它们会按固定的逐轮顺序触发:
text
onValidateMessages -> hydrateMessages -> onChatStart(仅对话第一条消息触发)
  -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
onBoot
会在每个工作进程启动时触发一次(包括每次新启动和续跑),适合初始化
chat.local
、数据库连接和进程级状态。
onChatStart
仅在对话的第一条消息时触发。暂停/恢复操作使用
onChatSuspend
/
onChatResume
。配置选项包括
tools
clientDataSchema
maxTurns
(默认100)、
turnTimeout
(默认"1h")、
idleTimeoutInSeconds
(默认30)、
uiMessageStreamOptions
exitAfterPreloadIdle
。不支持通用的
retry
chat.agent
内部使用
maxAttempts: 1
运行。
停止操作至关重要:传入
run
signal
会在停止或取消时触发中止。需将其作为
abortSignal
传入
streamText
,否则「停止」按钮仅会更新UI,但服务器端模型仍会继续生成内容。
ts
run: async ({ messages, signal }) =>
  streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });

6. Migrating from a plain AI SDK
streamText
route

6. 从普通AI SDK的
streamText
路由迁移

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 add
    ...chat.toStreamTextOptions()
    first.
  • On the client, swap the
    api
    URL for
    useTriggerChatTransport
    ;
    useChat
    stays the same shape.
此模型中无需API路由,传输层替代了路由往返,因此:
  • 删除路由处理程序。将每请求的授权逻辑移至配置步骤2中的两个服务器操作中。
  • streamText
    调用移至
    run
    函数中。它会自动接收已转换的
    ModelMessage[]
  • 返回
    StreamTextResult
    (会自动进行管道传输),并首先添加
    ...chat.toStreamTextOptions()
  • 在客户端,将
    api
    URL替换为
    useTriggerChatTransport
    useChat
    的使用方式保持不变。

Common mistakes

常见错误

  • CRITICAL: forgetting
    ...chat.toStreamTextOptions()
    .
    ts
    // Wrong - compaction / steering / background injection silently no-op
    return streamText({ model, messages, abortSignal: signal });
    // Correct - spread FIRST so explicit overrides win
    return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
    It wires the
    prepareStep
    callback behind compaction, mid-turn steering, and background injection, injects the system prompt from
    chat.prompt()
    , resolves the registry model, and adds telemetry. Omitting it makes all of those silently no-op with no error.
  • Declaring tools only on
    streamText
    .
    Also declare them on
    chat.agent({ tools })
    , read them back from
    run
    , and pass
    chat.toStreamTextOptions({ tools })
    . Otherwise each tool's
    toModelOutput
    runs on turn 1 but is dropped when history is re-converted on later turns.
  • Not forwarding
    signal
    for stop.
    Without
    abortSignal: signal
    , Stop updates the UI but the model keeps generating server-side.
  • Initializing
    chat.local
    in
    onChatStart
    .
    Initialize it in
    onBoot
    .
    onChatStart
    fires once per chat, so continuation runs skip it and crash with
    chat.local can only be modified after initialization
    .
    onBoot
    fires on every fresh worker.
  • Minting tokens in the browser. Never expose the environment secret key client-side. Mint via the two server actions; the transport calls them.
  • Clearing
    lastEventId
    on
    chat.endRun()
    .
    Keep the cursor for the Session lifetime; clear it only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
    seq_num=0
    that can hit the prior turn's stale
    turn-complete
    and close the stream empty.
  • Returning the raw error from
    uiMessageStreamOptions.onError
    .
    It leaks internals (keys, stack traces). Return a sanitized string instead.
  • 严重错误:忘记添加
    ...chat.toStreamTextOptions()
    ts
    // 错误 - 压缩/引导/后台注入会静默失效
    return streamText({ model, messages, abortSignal: signal });
    // 正确 - 首先展开,显式覆盖项优先级更高
    return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
    此操作会在压缩、轮中引导和后台注入背后关联
    prepareStep
    回调,注入来自
    chat.prompt()
    的系统提示,解析注册模型,并添加遥测。省略此操作会导致所有这些功能静默失效且无错误提示。
  • 仅在
    streamText
    中声明工具。
    还需在
    chat.agent({ tools })
    中声明工具,从
    run
    中读取工具集,并传入
    chat.toStreamTextOptions({ tools })
    。否则每个工具的
    toModelOutput
    仅在第一轮生效,后续轮次转换历史时会被丢弃。
  • 未转发
    signal
    用于停止操作。
    若未传入
    abortSignal: signal
    ,「停止」按钮仅会更新UI,但服务器端模型仍会继续生成内容。
  • onChatStart
    中初始化
    chat.local
    应在
    onBoot
    中初始化。
    onChatStart
    仅在每个对话触发一次,因此续跑会跳过此步骤并因
    chat.local can only be modified after initialization
    崩溃。
    onBoot
    会在每个新工作进程启动时触发。
  • 在浏览器中生成令牌。 切勿在客户端暴露环境密钥。应通过两个服务器操作生成令牌;传输层会自动调用它们。
  • chat.endRun()
    时清除
    lastEventId
    应在会话生命周期内保留游标;仅当会话本身关闭时才清除。它以sessionId为键,因此清除会强制从
    seq_num=0
    重新订阅,可能会命中上一轮过期的
    turn-complete
    并导致流空关闭。
  • uiMessageStreamOptions.onError
    返回原始错误。
    这会泄露内部信息(密钥、堆栈跟踪)。应返回经过清理的字符串。

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
    skill - Realtime hooks and frontend streaming beyond the chat transport.
  • trigger-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
).
  • trigger-chat-agent-advanced
    技能 - 深入讲解生命周期钩子、会话、原始任务原语(
    chat.createSession
    chat.customAgent
    chat.stream
    )、压缩、人工介入审批、恢复等功能。
  • trigger-realtime
    技能 - 聊天传输层之外的实时钩子和前端流式传输。
  • trigger-tasks
    技能 - 基础
    task()
    语义、
    ctx
    和标准生命周期钩子。
参考文档与本技能捆绑在同一包中,可本地阅读(无需网络),且与你安装的SDK版本一致。上方的
sources:
前置元数据列出了本技能引用的所有文档,均位于
@trigger.dev/sdk/docs/ai-chat/
下。建议从
quick-start.mdx
backend.mdx
tools.mdx
types.mdx
frontend.mdx
开始阅读。
chat.agent
是一个Trigger.dev任务,因此其构建和部署方式与其他任务一致。如需了解
trigger.config.ts
和构建扩展(Prisma、Playwright、Python、FFmpeg等——例如工具需要这些依赖时),请阅读
@trigger.dev/sdk/docs/config/
下的捆绑配置文档(扩展位于
config/extensions/
,从
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/
.
本技能捆绑在
@trigger.dev/sdk
中,直接从
node_modules
读取,因此始终与你安装的SDK版本一致(可查看相邻的
package.json
)。这些API的完整文档位于
@trigger.dev/sdk/docs/
下。