trigger-authoring-chat-agent について
会話型エージェントを長時間実行する Trigger.dev タスクとして構築するためのスキルです。サーバー側の会話状態、ストリーミング応答、型付きツール、ライフサイクルフック、API ルートを使わないフロントエンド接続を解説します。
主な機能
- 01長時間実行するチャットエージェント
- 02モデル応答のストリーミング
- 03型付きツールとメッセージデータ
- 04ターンのライフサイクルフック
- 05フロントエンド接続とサーバーアクション
活用シーン
- 01Vercel AI SDK を使ったチャットエージェントの構築
- 02API ルートなしのチャットストリーミング
- 03型付きツールと独自データの追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ triggerdotdev/ skills --skill trigger-authoring-chat-agentインストール後、Claude Code で/trigger-authoring-chat-agentを入力して読み込まれたか確認します。
詳細
チャットエージェントの作成
chat.agent は、会話全体を1つの長時間実行される Trigger.dev タスクとして実行します。メッセージが届くと起動し、届かなくなると停止します。メモリ内の状態は、ページの再読み込み、デプロイ、アイドル状態の期間、クラッシュを越えて保持されます。コードは、普段書くループそのものです。メッセージを受け取り、streamText を返します。API ルートはありません。フロントエンドは TriggerChatTransport を通じてエージェントと通信するため、履歴はサーバー側に蓄積され、クライアントは各ターンで新しいメッセージだけを送信します。
Vercel AI SDK v5、v6、v7 に対応しています。v7 ではモデル呼び出しをトレースできるよう、@ai-sdk/otel もインストールしてください(SDK が自動的に登録します)。
セットアップ
必要な要素は3つです。エージェントタスク、2つのサーバーアクション、フロントエンドのトランスポートです。
1. エージェントを定義する
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",
// `streamText` below is the SDK's, from the run argument. See "Common mistakes".
run: async ({ messages, signal, streamText }) =>
streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
run には、すでに ModelMessage[] に変換された messages(SDK がフロントエンドの UIMessage[] を変換します)と、停止またはキャンセル時に中断する signal が渡されます。StreamTextResult を返すと、フロントエンドへのパイプ処理が自動で行われます。
2. 2つのサーバーアクションを追加する
どちらもサーバー上で実行されるため、ブラウザーが環境シークレットキーを保持することはありません。ユーザーごと、プランごとの認可や、関連する DB 書き込みもここで行います。
"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",
});
}
3. フロントエンドを接続する
"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".
}
トランスポートはメモ化されます(1度作成され、レンダリングをまたいで再利用されます)。typeof myChat を渡すことで、エージェントのメッセージ型が useChat に引き継がれます。
基本パターン
1. 返す場合とパイプする場合
シンプルなケースでは、run から streamText の結果を返します。streamText をネストしたヘルパーの深い場所で呼び出す場合は、タスク内のどこからでも await chat.pipe(result) を呼び出し、run は 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. 型付きツール(設定で宣言し、再度渡す)
chat.agent({ tools }) でツールを宣言し、run() のペイロードから型付きで受け取り、そのセットを tools として渡します。宣言は一元化できます。
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 には、各ターンで解決される関数 (event) => ToolSet も指定できます。この関数には、chatId、turn、continuation、clientData を含む event が渡されます。
3. カスタムデータパーツ(永続化と一時的なデータ)
run() 内の chat.response.write()(またはフック内の writer.write())で書き込まれた data-* パーツは、responseMessage.parts に永続化され、onTurnComplete で取得できます。永続化せずにストリーミングするには、transient: true を追加します。chat.stream による書き込みは常に一時的です。
// 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. カスタム UIMessage 型、クライアントデータ、ビルダーフック
型付きの data-* パーツやツールマップを使うには、chat.withUIMessage<T>() と chat.withClientData({ schema }) を通じてエージェントを構築します。ビルダーメソッドは任意の順序で連結でき、ビルダーフックは対応するタスクフックの前に実行されます。streamOptions は uiMessageStreamOptions のデフォルトになり、浅くマージされます(エージェント側の値が優先されます)。
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 }),
});
MyChatUIMessage は UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>> として構築します(ツールのみの場合は、@trigger.dev/sdk/ai の InferChatUIMessageFromTools<typeof tools> を使用します)。フロントエンドでは、@trigger.dev/sdk/chat/react の InferChatUIMessage<typeof myChat> を使って useChat の型を絞り込みます。
5. ライフサイクルフックと停止
chat.agent は、ターンごとに固定の順序で実行されるフックを受け取ります。
onValidateMessages -> storage.loadContext (or the deprecated hydrateMessages)
-> onChatStart (chat's first message only)
-> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete -> storage.save
onBoot はワーカープロセスごとに1度(継続実行を含む、新しい起動ごとに)実行されます。chat.local、DB 接続、プロセスごとの状態はここに置きます。onChatStart はチャットの最初のメッセージでのみ実行されます。中断と再開には onChatSuspend/onChatResume を使用します。設定オプションには tools、clientDataSchema、maxTurns(100)、turnTimeout("1h")、idleTimeoutInSeconds(30)、uiMessageStreamOptions、exitAfterPreloadIdle があります。汎用の retry はありません。内部では chat.agent は maxAttempts: 1 で実行されます。
停止の挙動は、これに依存します。run に渡される signal は、停止またはキャンセル時に中断します。これを streamText の abortSignal として渡さないと、モデルがサーバー側で生成を続けているのに、停止ボタンを押すと UI だけが更新されてしまいます。
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
6. プレーンな AI SDK streamText ルートからの移行
このモデルには API ルートがありません。トランスポートがルートへの往復を置き換えるため、次のようにします。
- ルートハンドラーを削除します。リクエストごとの認証は、Setup のステップ 2 にある 2 つのサーバーアクションに移します。
streamTextの呼び出しをrunに移します。すでに変換済みのModelMessage[]を受け取ります。StreamTextResultを返します(自動的にパイプ処理されます)。また、streamTextはaiからではなく、runの引数から取得します。- クライアントでは、
apiURL をuseTriggerChatTransportに置き換えます。useChatの形はそのままです。
よくある間違い
-
重大:
aiからインポートしたstreamTextを呼び出す。// Wrong - compaction / steering / background injection silently no-op import { streamText } from "ai"; run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }); // Correct - the run argument's streamText carries the managed options run: async ({ messages, signal, streamText }) => streamText({ model, messages, abortSignal: signal });SDK の
streamTextには、コンパクション後のprepareStep、ターン中のステアリングとバックグラウンド注入、chat.prompt()またはchat.agent({ system })からのシステムプロンプト、レジストリによって解決されたモデル、テレメトリーが含まれています。インポートしたstreamTextにはそのどれも含まれず、エラーも発生しません。...chat.toStreamTextOptions()でも同じことを手動で行えます。run引数がないカスタムエージェントでは、これを使う必要があります。chat.headStartルートにもバインド済みのstreamTextが渡され、そこではmessages、prompt、stopWhen、abortSignalも管理されます。これを展開してからtoolsやprepareStepを再設定すると、管理対象の設定が置き換えられます。一方、run引数のstreamTextはtoolsをマージし、prepareStepを合成します。 -
streamTextにだけツールを宣言する。chat.agent({ tools })にも宣言し、runから取得して、そのセットをtoolsとして渡します。そうしないと、各ツールのtoModelOutputはターン 1 では実行されますが、後続のターンで履歴が再変換される際に破棄されます。 -
停止時に
signalを転送しない。abortSignal: signalがないと、停止操作は UI を更新しても、モデルはサーバー側で生成を続けます。 -
onChatStartでchat.localを初期化する。onBootで初期化します。onChatStartはチャットごとに一度だけ実行されるため、継続実行ではスキップされ、chat.local can only be modified after initializationというエラーが発生します。onBootは新しいワーカーの起動ごとに実行されます。 -
ブラウザーでトークンを発行する。 環境シークレットキーをクライアント側に公開してはいけません。2 つのサーバーアクションを通じて発行します。トランスポートがそれらを呼び出します。
-
chat.endRun()でlastEventIdをクリアする。 セッションの存続期間中はカーソルを保持し、クリアするのはセッション自体を閉じるときだけです。カーソルはsessionIdをキーとしているため、クリアするとseq_num=0から再購読され、前のターンの古いturn-completeを受信してストリームが空のまま閉じることがあります。 -
uiMessageStreamOptions.onErrorから生のエラーを返す。 内部情報(キー、スタックトレース)が漏れるため、代わりにサニタイズした文字列を返します。
参考資料
trigger-chat-agent-advancedスキル — ライフサイクルフックの詳細、セッション、未加工タスクのプリミティブ(chat.createSession、chat.customAgent、chat.stream)、コンパクション、HITL 承認、復旧。trigger-realtimeスキル — Realtime フックと、チャットトランスポートを超えるフロントエンドストリーミング。trigger-tasksスキル — 基本的なtask()のセマンティクス、ctx、標準のライフサイクルフック。
リファレンスドキュメントは同じパッケージ内でこのスキルと一緒に提供されます。インストール済みのバージョンに固定されているため、ネットワークには接続せず、ローカルで読み込んでください。上記の sources: frontmatter には、このスキルが参照するすべてのドキュメントが記載されており、いずれも @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 から始まります)。
バージョン
このスキルは @trigger.dev/sdk 内にバンドルされ、node_modules から直接読み込まれるため、常にインストール済みの SDK バージョンと一致します(隣接する package.json を参照)。これらの API の完全なドキュメントも、@trigger.dev/sdk/docs/ 以下に同梱されています。