Skip to content

Commit 50c86cd

Browse files
committed
feat(chat): validate custom agent client data
1 parent d62dd0d commit 50c86cd

7 files changed

Lines changed: 903 additions & 106 deletions

File tree

.changeset/quiet-chats-validate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Custom chat agents now validate and parse client data declared with `chat.withClientData({ schema })` before passing it to agent code.

docs/ai-chat/client-protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,7 @@ type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unk
771771
```
772772
773773
<Note>
774-
**`metadata` is the wire envelope for `clientData`.** The agent's `clientData` (typed via `chat.withClientData({ schema })`) is read from this field at run boot. If the agent declares e.g. `{ userId: string, model?: string }`, then every `kind: "message"` payload — and the `triggerConfig.basePayload` you sent at session create must carry a matching `metadata.userId`. The agent rejects messages whose metadata fails schema validation.
774+
**`metadata` is the wire envelope for `clientData`.** The agent's `clientData` (typed via `chat.withClientData({ schema })`) is read from this field at run boot. If the agent declares e.g. `{ userId: string, model?: string }`, then the `triggerConfig.basePayload` you sent at session create and every non-close `kind: "message"` payload must carry a matching `metadata.userId`. Invalid metadata is not passed to agent code. Async reads produce an error chunk followed by `turn-complete`; raw `chat.messages.on()` subscriptions use `onClientDataValidationError` and the task log so they do not end an active response.
775775
</Note>
776776
777777
### Sending a message

docs/ai-chat/custom-agents.mdx

Lines changed: 75 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,61 +19,91 @@ Inside the wrapper, pick one of two loop styles:
1919
- **[Managed loop](#managed-loop-chatcreatesession)**`chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
2020
- **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.
2121

22+
### Validating client data
23+
24+
Use `chat.withClientData({ schema })` to validate `payload.metadata`. Custom agents parse the initial payload and later message and action frames before passing them to `run`, `chat.messages`, or `chat.createSession`. Schema defaults and transforms are included in the value your code receives. Close frames are not validated.
25+
26+
If validation fails in `run`, `chat.createSession()`, or an async read such as `wait()`, the SDK writes an error chunk followed by `turn-complete` and skips the invalid frame. The invalid value never reaches your run handler or turn loop. If the initial payload is invalid, the task waits for the next valid frame instead of starting `run` with bad data. Without a schema, metadata is passed through unchanged.
27+
28+
`chat.messages.on()` is different because a subscribed frame can arrive while the current response is still streaming. Ending the turn at that point would cut off the response. The SDK skips the frame, logs the validation error, and calls `onClientDataValidationError` if you set it:
29+
30+
```ts
31+
import { chat } from "@trigger.dev/sdk/ai";
32+
import { z } from "zod";
33+
34+
export const myChat = chat
35+
.withClientData({ schema: z.object({ userId: z.string() }) })
36+
.customAgent({
37+
id: "my-chat",
38+
onClientDataValidationError: ({ error, payload }) => {
39+
console.warn("Invalid client data", { error, trigger: payload.trigger });
40+
},
41+
run: async (payload) => {
42+
// ...
43+
},
44+
});
45+
```
46+
47+
`chat.messages.peek()` validates synchronously and throws validation errors to the caller. If your schema only supports asynchronous parsing, use `once()`, `wait()`, or `waitWithIdleTimeout()` instead.
48+
2249
## Managed loop: chat.createSession()
2350

2451
`chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:
2552

2653
```ts trigger/my-chat.ts
27-
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
54+
import { chat } from "@trigger.dev/sdk/ai";
2855
import { streamText, stepCountIs } from "ai";
2956
import { anthropic } from "@ai-sdk/anthropic";
30-
31-
export const myChat = chat.customAgent({
32-
id: "my-chat",
33-
run: async (payload: ChatTaskWirePayload, { signal }) => {
34-
// One-time initialization — plain code, no hooks. Upsert, not create:
35-
// continuation runs boot with the row already in place.
36-
const clientData = payload.metadata as { userId: string };
37-
await db.chat.upsert({
38-
where: { id: payload.chatId },
39-
create: { id: payload.chatId, userId: clientData.userId },
40-
update: {},
41-
});
42-
43-
const session = chat.createSession(payload, {
44-
signal,
45-
idleTimeoutInSeconds: 60,
46-
timeout: "1h",
47-
});
48-
49-
for await (const turn of session) {
50-
// Persist the incoming user message BEFORE streaming — this is your
51-
// onTurnStart equivalent. Without it, a page reload mid-stream
52-
// restores the assistant text (replayed from the session) but loses
53-
// the user message that prompted it.
54-
await db.chat.update({
55-
where: { id: turn.chatId },
56-
data: { messages: turn.uiMessages },
57+
import { z } from "zod";
58+
59+
export const myChat = chat
60+
.withClientData({ schema: z.object({ userId: z.string() }) })
61+
.customAgent({
62+
id: "my-chat",
63+
run: async (payload, { signal }) => {
64+
// One-time initialization — plain code, no hooks. Upsert, not create:
65+
// continuation runs boot with the row already in place.
66+
const clientData = payload.metadata!;
67+
await db.chat.upsert({
68+
where: { id: payload.chatId },
69+
create: { id: payload.chatId, userId: clientData.userId },
70+
update: {},
5771
});
5872

59-
const result = streamText({
60-
model: anthropic("claude-sonnet-4-5"),
61-
messages: turn.messages,
62-
abortSignal: turn.signal,
63-
stopWhen: stepCountIs(15),
73+
const session = chat.createSession(payload, {
74+
signal,
75+
idleTimeoutInSeconds: 60,
76+
timeout: "1h",
6477
});
6578

66-
// Pipe, capture, accumulate, and signal turn-complete — all in one call
67-
await turn.complete(result);
68-
69-
// Persist the full exchange after the turn — your onTurnComplete equivalent
70-
await db.chat.update({
71-
where: { id: turn.chatId },
72-
data: { messages: turn.uiMessages },
73-
});
74-
}
75-
},
76-
});
79+
for await (const turn of session) {
80+
// Persist the incoming user message BEFORE streaming — this is your
81+
// onTurnStart equivalent. Without it, a page reload mid-stream
82+
// restores the assistant text (replayed from the session) but loses
83+
// the user message that prompted it.
84+
await db.chat.update({
85+
where: { id: turn.chatId },
86+
data: { messages: turn.uiMessages },
87+
});
88+
89+
const result = streamText({
90+
model: anthropic("claude-sonnet-4-5"),
91+
messages: turn.messages,
92+
abortSignal: turn.signal,
93+
stopWhen: stepCountIs(15),
94+
});
95+
96+
// Pipe, capture, accumulate, and signal turn-complete — all in one call
97+
await turn.complete(result);
98+
99+
// Persist the full exchange after the turn — your onTurnComplete equivalent
100+
await db.chat.update({
101+
where: { id: turn.chatId },
102+
data: { messages: turn.uiMessages },
103+
});
104+
}
105+
},
106+
});
77107
```
78108

79109
<Warning>
@@ -102,7 +132,7 @@ Each turn yielded by the iterator provides:
102132
| `number` | `number` | Turn number (0-indexed) |
103133
| `chatId` | `string` | Chat session ID |
104134
| `trigger` | `string` | What triggered this turn |
105-
| `clientData` | `unknown` | Client data from the transport |
135+
| `clientData` | Schema output or `unknown` | Parsed client data when `withClientData` is configured |
106136
| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` |
107137
| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence |
108138
| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) |

docs/ai-chat/reference.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data
546546

547547
## `chat.withClientData`
548548

549-
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options.
549+
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. Managed-agent hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options. Custom agents parse `payload.metadata` on the initial payload and later input frames before passing it to user code.
550550

551551
```ts
552552
chat.withClientData<TSchema>({ schema: TSchema }): ChatBuilder<UIMessage, TSchema>;
@@ -556,6 +556,8 @@ chat.withClientData<TSchema>({ schema: TSchema }): ChatBuilder<UIMessage, TSchem
556556
| --------- | ------------ | -------------------------------------------------- |
557557
| `schema` | `TaskSchema` | Zod, ArkType, Valibot, or any supported schema lib |
558558

559+
For `chat.customAgent()`, invalid client data is skipped. Async reads emit an error chunk followed by `turn-complete`. A `chat.messages.on()` subscription uses the task's `onClientDataValidationError` callback and task log instead, so an active response is not ended early. Without a schema, metadata is passed through unchanged.
560+
559561
Full guide: [Typed client data](/ai-chat/types#typed-client-data-with-chatwithclientdata).
560562

561563
## `ChatWithUIMessageConfig`

docs/ai-chat/types.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React
140140

141141
## Typed client data with `chat.withClientData`
142142

143-
`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options.
143+
`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. Managed-agent hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options. A `.customAgent()` run receives the parsed schema output in `payload.metadata`, and `chat.createSession()` yields it as `turn.clientData`.
144144

145145
```ts
146146
import { chat } from "@trigger.dev/sdk/ai";
@@ -167,6 +167,8 @@ export const myChat = chat
167167
});
168168
```
169169

170+
The schema runs at runtime for both `.agent()` and `.customAgent()`. Custom agents validate the initial payload and later `chat.messages` frames. Invalid frames are not passed to user code. Async reads emit an error chunk followed by `turn-complete`; `chat.messages.on()` reports through `onClientDataValidationError` and the task log so it does not end an active response. Without a schema, metadata is passed through unchanged.
171+
170172
## ChatBuilder
171173

172174
Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`.

0 commit comments

Comments
 (0)