Skip to content

Commit abe3190

Browse files
committed
feat(cli): install Trigger.dev agent skills into your coding agent
Adds a trigger skills command (the old install-rules becomes an alias) that copies SKILL.md files bundled with the CLI into each tool's native skills directory, version-matched to the CLI. trigger dev offers to install them on first run.
1 parent fa4804e commit abe3190

14 files changed

Lines changed: 1953 additions & 694 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
`trigger skills` installs Trigger.dev agent skills into your coding agent so it knows how to write tasks, schedules, realtime, and chat.agent code. The skills ship with the CLI and are copied into each tool's native skills directory (Claude Code, Cursor, GitHub Copilot, and Codex / AGENTS.md), and `trigger dev` offers to install them on first run.
6+
7+
```bash
8+
trigger skills --target claude-code
9+
```
10+
11+
Replaces the previous `install-rules` command, which stays as an alias.

packages/cli-v3/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
"apis",
2525
"jobs",
2626
"background jobs",
27-
"nextjs"
27+
"nextjs",
28+
"tanstack-intent"
2829
],
2930
"files": [
30-
"dist"
31+
"dist",
32+
"skills"
3133
],
3234
"bin": {
3335
"trigger": "./dist/esm/index.js"
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
---
2+
name: authoring-chat-agent
3+
description: >
4+
Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn
5+
run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult
6+
vs calling chat.pipe(), the two server actions (chat.createStartSessionAction +
7+
auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building,
8+
modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React
9+
transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK
10+
streamText route to chat.agent.
11+
type: core
12+
library: trigger.dev
13+
library_version: "{{TRIGGER_SDK_VERSION}}"
14+
sources:
15+
- docs/ai-chat/overview.mdx
16+
- docs/ai-chat/quick-start.mdx
17+
- docs/ai-chat/how-it-works.mdx
18+
- docs/ai-chat/backend.mdx
19+
- docs/ai-chat/frontend.mdx
20+
- docs/ai-chat/reference.mdx
21+
- docs/ai-chat/types.mdx
22+
- docs/ai-chat/tools.mdx
23+
- docs/ai-chat/lifecycle-hooks.mdx
24+
- docs/ai-chat/error-handling.mdx
25+
---
26+
27+
# Authoring a chat agent
28+
29+
A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a
30+
message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle
31+
gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out.
32+
There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so
33+
history accumulates server-side and the client ships only the new message each turn.
34+
35+
Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced
36+
(the SDK registers it for you).
37+
38+
## Setup
39+
40+
Three pieces: the agent task, two server actions, and the frontend transport.
41+
42+
### 1. Define the agent
43+
44+
```ts trigger/chat.ts
45+
import { chat } from "@trigger.dev/sdk/ai";
46+
import { streamText, stepCountIs } from "ai";
47+
import { anthropic } from "@ai-sdk/anthropic";
48+
49+
export const myChat = chat.agent({
50+
id: "my-chat",
51+
run: async ({ messages, signal }) =>
52+
streamText({
53+
// Spread this FIRST. See "Common mistakes".
54+
...chat.toStreamTextOptions(),
55+
model: anthropic("claude-sonnet-4-5"),
56+
messages,
57+
abortSignal: signal,
58+
stopWhen: stepCountIs(15),
59+
}),
60+
});
61+
```
62+
63+
`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's
64+
`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the
65+
`StreamTextResult` auto-pipes it to the frontend.
66+
67+
### 2. Add two server actions
68+
69+
Both run on your server, so the browser never holds your environment secret key. This is also
70+
where per-user / per-plan authorization and any paired DB writes live.
71+
72+
```ts app/actions.ts
73+
"use server";
74+
import { auth } from "@trigger.dev/sdk";
75+
import { chat } from "@trigger.dev/sdk/ai";
76+
77+
// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId).
78+
export const startChatSession = chat.createStartSessionAction("my-chat");
79+
80+
// Pure mint. The transport calls this on 401/403 to refresh an expired token.
81+
export async function mintChatAccessToken(chatId: string) {
82+
return auth.createPublicToken({
83+
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
84+
expirationTime: "1h",
85+
});
86+
}
87+
```
88+
89+
### 3. Wire the frontend
90+
91+
```tsx app/components/chat.tsx
92+
"use client";
93+
import { useState } from "react";
94+
import { useChat } from "@ai-sdk/react";
95+
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
96+
import type { myChat } from "@/trigger/chat";
97+
import { mintChatAccessToken, startChatSession } from "@/app/actions";
98+
99+
export function Chat() {
100+
const transport = useTriggerChatTransport<typeof myChat>({
101+
task: "my-chat", // typeof myChat gives compile-time task-id validation
102+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
103+
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
104+
});
105+
106+
const { messages, sendMessage, stop, status } = useChat({ transport });
107+
const [input, setInput] = useState("");
108+
// render messages, a form that calls sendMessage({ text: input }),
109+
// and a Stop button (onClick={stop}) while status === "streaming".
110+
}
111+
```
112+
113+
The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the
114+
agent's message type through `useChat`.
115+
116+
## Core patterns
117+
118+
### 1. Return vs pipe
119+
120+
Return the `streamText` result from `run` for the simple case. When `streamText` is called deep
121+
inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let
122+
`run` resolve `void`.
123+
124+
```ts
125+
export const agentChat = chat.agent({
126+
id: "agent-chat",
127+
run: async ({ messages }) => {
128+
await runAgentLoop(messages); // don't return; pipe inside
129+
},
130+
});
131+
132+
async function runAgentLoop(messages: ModelMessage[]) {
133+
const result = streamText({
134+
...chat.toStreamTextOptions(),
135+
model: anthropic("claude-sonnet-4-5"),
136+
messages,
137+
});
138+
await chat.pipe(result); // works from anywhere in the task
139+
}
140+
```
141+
142+
### 2. Typed tools (declare on config AND spread back)
143+
144+
Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass
145+
that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere.
146+
147+
```ts
148+
import { tool, stepCountIs } from "ai";
149+
import { z } from "zod";
150+
151+
const tools = {
152+
searchDocs: tool({
153+
description: "Search the docs.",
154+
inputSchema: z.object({ query: z.string() }),
155+
execute: async ({ query }) => searchIndex(query),
156+
}),
157+
};
158+
159+
export const myChat = chat.agent({
160+
id: "my-chat",
161+
tools, // so toModelOutput survives across turns
162+
run: async ({ messages, tools, signal }) =>
163+
streamText({
164+
...chat.toStreamTextOptions({ tools }), // same set, handed back typed
165+
model: anthropic("claude-sonnet-4-5"),
166+
messages,
167+
abortSignal: signal,
168+
stopWhen: stepCountIs(15),
169+
}),
170+
});
171+
```
172+
173+
`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries
174+
`chatId`, `turn`, `continuation`, and `clientData`.
175+
176+
### 3. Custom data parts (persisted vs transient)
177+
178+
`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks)
179+
persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to
180+
stream them without persisting. Writes via `chat.stream` are always ephemeral.
181+
182+
```ts
183+
// In run() - persists, surfaces in onTurnComplete's responseMessage
184+
chat.response.write({ type: "data-context", data: { searchResults } });
185+
186+
// In a hook via writer - streams but does NOT persist
187+
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });
188+
```
189+
190+
### 4. Custom UIMessage type, client data, and builder hooks
191+
192+
For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage<T>()` and
193+
`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the
194+
matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged,
195+
agent wins).
196+
197+
```ts
198+
export const myChat = chat
199+
.withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } })
200+
.withClientData({ schema: z.object({ userId: z.string() }) })
201+
.agent({
202+
id: "my-chat",
203+
tools: myTools,
204+
onTurnStart: async ({ uiMessages, writer }) => {
205+
writer.write({ type: "data-turn-status", data: { status: "preparing" } });
206+
},
207+
run: async ({ messages, tools, signal }) =>
208+
streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }),
209+
});
210+
```
211+
212+
Build `MyChatUIMessage` as `UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>` (or, for
213+
tools only, `InferChatUIMessageFromTools<typeof tools>` from `@trigger.dev/sdk/ai`). On the
214+
frontend, narrow `useChat` with `InferChatUIMessage<typeof myChat>` from `@trigger.dev/sdk/chat/react`.
215+
216+
### 5. Lifecycle hooks and stop
217+
218+
`chat.agent` accepts hooks that fire in a fixed per-turn order:
219+
220+
```
221+
onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only)
222+
-> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
223+
```
224+
225+
`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where
226+
`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's
227+
first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include
228+
`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30),
229+
`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent`
230+
runs with `maxAttempts: 1` internally.
231+
232+
Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as
233+
`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating
234+
server-side.
235+
236+
```ts
237+
run: async ({ messages, signal }) =>
238+
streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
239+
```
240+
241+
### 6. Migrating from a plain AI SDK `streamText` route
242+
243+
There is no API route in this model. The transport replaces the route round-trip, so:
244+
245+
- Delete the route handler. Move per-request auth into the two server actions from Setup step 2.
246+
- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`.
247+
- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first.
248+
- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape.
249+
250+
## Common mistakes
251+
252+
- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.**
253+
```ts
254+
// Wrong - compaction / steering / background injection silently no-op
255+
return streamText({ model, messages, abortSignal: signal });
256+
// Correct - spread FIRST so explicit overrides win
257+
return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
258+
```
259+
It wires the `prepareStep` callback behind compaction, mid-turn steering, and background
260+
injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds
261+
telemetry. Omitting it makes all of those silently no-op with no error.
262+
263+
- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them
264+
back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's
265+
`toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns.
266+
267+
- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the
268+
model keeps generating server-side.
269+
270+
- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires
271+
once per chat, so continuation runs skip it and crash with
272+
`chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker.
273+
274+
- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via
275+
the two server actions; the transport calls them.
276+
277+
- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it
278+
only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
279+
`seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty.
280+
281+
- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys,
282+
stack traces). Return a sanitized string instead.
283+
284+
## References
285+
286+
- `chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives
287+
(`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery.
288+
- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
289+
- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
290+
- Docs: /ai-chat/quick-start, /ai-chat/backend, /ai-chat/tools, /ai-chat/types, /ai-chat/frontend
291+
292+
## Version
293+
294+
Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer
295+
after upgrading.

0 commit comments

Comments
 (0)