Skip to content

Commit bee74d7

Browse files
committed
docs(ai-chat): functional baseURL on chat transports
Document the new string-or-function baseURL and fetch override on TriggerChatTransport, AgentChat, and chat.createStartSessionAction. - New patterns/trusted-edge-signals page covering the trusted-proxy pattern: how to route .in/append + session-create through an edge worker for server-trusted metadata injection while keeping .out SSE direct, plus a complete Cloudflare Worker reference implementation, agent-side clientDataSchema example, and threat model. - reference: TriggerChatTransport options table now lists baseURL with the function shape, adds a fetch row, and drops streamBaseURL. New AgentChat options and createStartSessionAction options tables with the same shape (different endpoint enum: "sessions" | "auth"). - frontend: self-hosting section shows the function form alongside the string form, plus a one-line pointer to trusted-edge-signals. - server-chat: AgentChat options table picks up baseURL and fetch rows. - docs.json: nav entry for the new patterns page.
1 parent a353339 commit bee74d7

5 files changed

Lines changed: 374 additions & 3 deletions

File tree

docs/ai-chat/frontend.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,12 @@ const transport = useTriggerChatTransport({
552552
baseURL: "https://your-trigger-instance.com",
553553
});
554554
```
555+
556+
`baseURL` also accepts a function so you can route per endpoint — useful when fronting `.in/append` with an edge proxy (e.g. to inject server-trusted signal into the wire) while keeping `.out` SSE direct:
557+
558+
```ts
559+
baseURL: ({ endpoint }) =>
560+
endpoint === "out" ? "https://api.trigger.dev" : "https://chat-proxy.example.com",
561+
```
562+
563+
For per-request control beyond URL routing (header injection, custom retries, tracing), pass a `fetch` override. See [Trusted edge signals](/ai-chat/patterns/trusted-edge-signals) for a full proxy walkthrough.
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
---
2+
title: "Trusted edge signals"
3+
sidebarTitle: "Trusted edge signals"
4+
description: "How to safely deliver server-trusted signals (bot scores, JA4, ASN, ReCAPTCHA verdicts) to a chat.agent run via an edge proxy."
5+
---
6+
7+
A common need for chat-style endpoints is to drive agent behavior from **server-trusted signals** that the browser cannot be allowed to declare itself — bot management scores, JA4 fingerprints, ASN, ReCAPTCHA verdicts, or any other anti-abuse data only the edge can see. The agent's [`clientData`](/ai-chat/reference#withclientdata) channel is the right delivery mechanism, but `clientData` set in the browser is by definition spoofable. The fix is to move the value population out of the browser and into a trusted edge proxy.
8+
9+
This page documents the pattern using Cloudflare Workers as the proxy. The same shape applies to any edge layer (custom reverse proxy, Vercel Edge Middleware, AWS Lambda@Edge) — the trust comes from the deployment topology, not from Trigger.dev validating the source.
10+
11+
## Why headers don't work
12+
13+
It's tempting to ask whether `POST /realtime/v1/sessions/{id}/in/append` could carry the signal as an HTTP header. It cannot. The realtime route reads only `Authorization` and `X-Part-Id`; the remaining headers are dropped at the route boundary and the body is persisted to the durable stream as opaque bytes. There is no `headers → run payload` channel.
14+
15+
The trigger.dev wire payload, on the other hand, has a typed per-turn metadata channel ([`ChatTaskWirePayload.metadata`](/ai-chat/client-protocol#chattaskwirepayload)). It already flows from the wire into [`clientData`](/ai-chat/reference#withclientdata) on every hook (`onBoot`, `onChatStart`, `onTurnStart`, `run`, `onTurnComplete`). That field is where signals must land.
16+
17+
## The trust boundary
18+
19+
The pattern has one architectural requirement and one wire-shape convention.
20+
21+
**Topology**: the browser must not be able to reach `trigger.dev` directly. All four chat-related requests (`POST /api/v1/sessions`, `GET /realtime/v1/sessions/{id}/out`, `POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/auth/jwt/claims`) flow through your edge proxy. The proxy holds the trust; trigger.dev simply persists whatever the proxy writes.
22+
23+
**Namespace**: pick a key your edge proxy owns exclusively — e.g. `__cf`, `__edge`, `__trust`. The proxy **strips** anything in that key on the way in and **injects** its own value on every request. Nothing else in your system should write that key. This is the convention that converts deployment topology into a guarantee the agent can rely on.
24+
25+
```mermaid
26+
sequenceDiagram
27+
participant Browser
28+
participant Edge as Edge Proxy (CF Worker)
29+
participant Trigger as trigger.dev API
30+
participant Agent as chat.agent run
31+
32+
Browser->>Edge: POST /api/v1/sessions { triggerConfig.basePayload.metadata: {...} }
33+
Edge->>Edge: strip body.triggerConfig.basePayload.metadata.__cf<br/>inject body.triggerConfig.basePayload.metadata.__cf = { botScore, ja4, asn }
34+
Edge->>Trigger: POST /api/v1/sessions (rewritten body)
35+
Trigger-->>Agent: run boots with payload.metadata.__cf
36+
Browser->>Edge: POST /realtime/v1/sessions/{id}/in/append { kind: "message", payload: {...} }
37+
Edge->>Edge: strip payload.metadata.__cf<br/>inject payload.metadata.__cf
38+
Edge->>Trigger: POST /in/append (rewritten body)
39+
Trigger-->>Agent: chat.messages.wait() resolves with payload.metadata.__cf
40+
```
41+
42+
## Wire payload — the two endpoints to rewrite
43+
44+
The signal needs to land in **two** places. Both bodies are JSON; the edge proxy parses, mutates the namespaced key, and re-serializes.
45+
46+
### `POST /api/v1/sessions` — session create
47+
48+
The browser's session-create call carries the first-turn metadata under `triggerConfig.basePayload.metadata`. The proxy mutates that:
49+
50+
```ts
51+
// Before
52+
{
53+
"type": "chat.agent",
54+
"externalId": "conv-123",
55+
"taskIdentifier": "my-agent",
56+
"triggerConfig": {
57+
"basePayload": {
58+
"chatId": "conv-123",
59+
"trigger": "preload",
60+
"metadata": { "userId": "user-456" }
61+
}
62+
}
63+
}
64+
65+
// After
66+
{
67+
"type": "chat.agent",
68+
"externalId": "conv-123",
69+
"taskIdentifier": "my-agent",
70+
"triggerConfig": {
71+
"basePayload": {
72+
"chatId": "conv-123",
73+
"trigger": "preload",
74+
"metadata": {
75+
"userId": "user-456",
76+
"__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
77+
}
78+
}
79+
}
80+
}
81+
```
82+
83+
### `POST /realtime/v1/sessions/{id}/in/append` — every follow-up turn
84+
85+
The body is a JSON-serialized `ChatInputChunk`. The proxy parses it, checks `kind === "message"`, and mutates `payload.metadata`:
86+
87+
```ts
88+
// Before
89+
{
90+
"kind": "message",
91+
"payload": {
92+
"message": { "id": "u-2", "role": "user", "parts": [{ "type": "text", "text": "..." }] },
93+
"chatId": "conv-123",
94+
"trigger": "submit-message",
95+
"metadata": { "userId": "user-456" }
96+
}
97+
}
98+
99+
// After
100+
{
101+
"kind": "message",
102+
"payload": {
103+
"message": { ... },
104+
"chatId": "conv-123",
105+
"trigger": "submit-message",
106+
"metadata": {
107+
"userId": "user-456",
108+
"__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
109+
}
110+
}
111+
}
112+
```
113+
114+
Both bodies stay well under the [512 KiB cap on `/in/append`](/ai-chat/client-protocol#step-3-send-messages-stops-and-actions) — a typical trust object is ~200 bytes.
115+
116+
Other paths — `.out` SSE, `/api/v1/auth/jwt/claims`, anything else — pass through the proxy untouched. The SSE stream in particular must not be buffered; preserve the response body as-is.
117+
118+
## Cloudflare Worker reference implementation
119+
120+
A complete worker that proxies all paths to `TRIGGER_API_UPSTREAM` and injects `__cf` on the two body-write endpoints:
121+
122+
```ts
123+
export interface Env {
124+
TRIGGER_API_UPSTREAM: string; // e.g. "https://api.trigger.dev"
125+
}
126+
127+
type CfTrustData = {
128+
botScore: number;
129+
ja4: string;
130+
asn: number;
131+
country: string;
132+
};
133+
134+
function readCfTrustData(request: Request): CfTrustData {
135+
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
136+
const bm = cf?.botManagement as Record<string, unknown> | undefined;
137+
return {
138+
botScore: (bm?.score as number) ?? 0,
139+
ja4: (bm?.ja4 as string) ?? "",
140+
asn: (cf?.asn as number) ?? 0,
141+
country: (cf?.country as string) ?? "",
142+
};
143+
}
144+
145+
function injectCf(metadata: Record<string, unknown> | undefined, cf: CfTrustData) {
146+
// Strip anything the client tried to send under our namespace,
147+
// then inject the edge-trusted value. Topology + convention =
148+
// trust.
149+
const stripped = { ...(metadata ?? {}) };
150+
delete stripped.__cf;
151+
return { ...stripped, __cf: cf };
152+
}
153+
154+
function rewriteSessionsCreate(body: string, cf: CfTrustData) {
155+
const parsed = JSON.parse(body) as Record<string, unknown>;
156+
const tc = (parsed.triggerConfig as Record<string, unknown>) ?? {};
157+
const bp = (tc.basePayload as Record<string, unknown>) ?? {};
158+
parsed.triggerConfig = {
159+
...tc,
160+
basePayload: { ...bp, metadata: injectCf(bp.metadata as Record<string, unknown>, cf) },
161+
};
162+
return JSON.stringify(parsed);
163+
}
164+
165+
function rewriteAppend(body: string, cf: CfTrustData) {
166+
let parsed: Record<string, unknown>;
167+
try {
168+
parsed = JSON.parse(body);
169+
} catch {
170+
return body;
171+
}
172+
if (parsed.kind !== "message") return body;
173+
const payload = (parsed.payload as Record<string, unknown>) ?? {};
174+
parsed.payload = { ...payload, metadata: injectCf(payload.metadata as Record<string, unknown>, cf) };
175+
return JSON.stringify(parsed);
176+
}
177+
178+
export default {
179+
async fetch(request: Request, env: Env): Promise<Response> {
180+
const incoming = new URL(request.url);
181+
const target = new URL(incoming.pathname + incoming.search, env.TRIGGER_API_UPSTREAM);
182+
const cf = readCfTrustData(request);
183+
184+
const isSessionsCreate =
185+
request.method === "POST" && incoming.pathname === "/api/v1/sessions";
186+
const isAppend =
187+
request.method === "POST" &&
188+
/^\/realtime\/v1\/sessions\/[^/]+\/in\/append$/.test(incoming.pathname);
189+
190+
let body: BodyInit | null = null;
191+
if (request.method !== "GET" && request.method !== "HEAD") {
192+
const raw = await request.text();
193+
if (isSessionsCreate && raw) body = rewriteSessionsCreate(raw, cf);
194+
else if (isAppend && raw) body = rewriteAppend(raw, cf);
195+
else body = raw;
196+
}
197+
198+
const headers = new Headers(request.headers);
199+
headers.delete("host");
200+
headers.delete("content-length");
201+
202+
return fetch(target.toString(), {
203+
method: request.method,
204+
headers,
205+
body,
206+
redirect: "manual",
207+
});
208+
},
209+
};
210+
```
211+
212+
Browser-only deployments also need CORS on the worker — echo `Access-Control-Request-Headers` on preflight and set `Access-Control-Allow-Origin` to your frontend origin. The trigger.dev route itself allows all origins, but the worker becomes the visible cross-origin endpoint to the browser.
213+
214+
### Streaming and latency
215+
216+
The SDK's `baseURL` accepts a function (see [Browser transport configuration](#browser-transport-configuration)), so the recommended setup routes `.in/append` and session-create through the worker but lets `.out` SSE go direct to `api.trigger.dev`. Body-mutation only happens on the POST paths; the SSE stream is read-only, doesn't need rewriting, and routing it direct saves an edge hop on every reconnect.
217+
218+
If you do route `.out` through the proxy (e.g. you want a single origin in front of `api.trigger.dev` and don't care about the extra hop), the template above handles it correctly because the worker returns `response.body` as a `ReadableStream`. **Do not replace that with `await response.text()`** anywhere in your fork; doing so converts the streaming SSE response into a buffered read and breaks per-chunk delivery.
219+
220+
[Cloudflare Workers HTTP requests](https://developers.cloudflare.com/workers/platform/limits/) have no wall-clock duration limit while the client stays connected — the 60-second long-poll runs to completion on every plan, including Free. CPU-time limits (10 ms on Free, 30 s default on Paid) only apply to active computation; relaying bytes through `fetch` doesn't burn CPU. The two body-rewrite paths use sub-millisecond CPU for typical message sizes, well under either ceiling.
221+
222+
Network-wise the proxy adds one edge hop: roughly 10–50 ms per request round trip versus talking to `api.trigger.dev` directly. Routing SSE direct via the function-form `baseURL` eliminates that hop on the long-lived path.
223+
224+
## Agent side — declare the namespace in `clientDataSchema`
225+
226+
Mirror the namespace in the agent so every turn lands typed:
227+
228+
```ts
229+
import { chat } from "@trigger.dev/sdk/ai";
230+
import { z } from "zod";
231+
232+
export const myAgent = chat
233+
.withClientData({
234+
schema: z.object({
235+
userId: z.string(),
236+
__cf: z.object({
237+
botScore: z.number(),
238+
ja4: z.string(),
239+
asn: z.number(),
240+
country: z.string(),
241+
}),
242+
}),
243+
})
244+
.agent({
245+
id: "my-agent",
246+
run: async ({ messages, clientData, signal }) => {
247+
// Score-based routing. The values arrive from the edge proxy.
248+
if (clientData.__cf.botScore < 30) {
249+
return streamText({
250+
model: openai("gpt-4o-mini"),
251+
messages: [{ role: "system", content: "Reject politely; do not engage." }],
252+
abortSignal: signal,
253+
});
254+
}
255+
256+
return streamText({
257+
model: openai("gpt-4o"),
258+
messages,
259+
abortSignal: signal,
260+
// ...
261+
});
262+
},
263+
});
264+
```
265+
266+
Because the schema requires `__cf` on every turn, any request that *doesn't* go through the proxy fails at the agent boundary — the turn produces a `[ERROR]` span on the trace and an empty `turn-complete` on the wire (see [the client protocol error-detection note](/ai-chat/client-protocol#step-3-send-messages-stops-and-actions)). That gives you a server-side enforcement check for "did this request actually come through the trusted path?"
267+
268+
## Browser transport configuration
269+
270+
Point the `TriggerChatTransport` at the worker, not at `api.trigger.dev`:
271+
272+
`baseURL` accepts a function so you can route `.in/append` through the worker while keeping `.out` SSE direct to `api.trigger.dev`. The append path is where the body-mutation matters; the SSE stream is a read-only one-way channel that doesn't need to be proxied. Routing it direct saves an edge hop on every long-poll.
273+
274+
```tsx
275+
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
276+
277+
const WORKER = "https://worker.your-domain.com";
278+
const DIRECT = "https://api.trigger.dev";
279+
280+
const transport = useTriggerChatTransport({
281+
task: "my-agent",
282+
baseURL: ({ endpoint }) => (endpoint === "out" ? DIRECT : WORKER),
283+
// ... accessToken, startSession, etc.
284+
// NOTE: do not set __cf in clientData here. The browser cannot be
285+
// trusted to populate it — the worker is the source of truth.
286+
clientData: { userId: currentUserId },
287+
});
288+
```
289+
290+
If you'd rather route everything through the worker, pass a single string:
291+
292+
```tsx
293+
baseURL: "https://worker.your-domain.com",
294+
```
295+
296+
`baseURL` accepts the same string-or-function shape on `chat.createStartSessionAction`, so the Next.js server action that creates the session also flows through the worker — that's how the very first run's `basePayload.metadata.__cf` gets injected before reaching `api.trigger.dev`:
297+
298+
```ts
299+
// actions.ts — server-only
300+
import { chat } from "@trigger.dev/sdk/ai";
301+
302+
export const startSession = chat.createStartSessionAction("my-agent", {
303+
tokenTTL: "1h",
304+
baseURL: ({ endpoint }) =>
305+
endpoint === "sessions" ? WORKER : DIRECT,
306+
});
307+
```
308+
309+
The session-create endpoint discriminator is `"sessions"` (POST `/api/v1/sessions`) or `"auth"` (POST `/api/v1/auth/jwt/claims`) — distinct from the chat transport's `"in"` / `"out"`. If you want everything proxied, pass a string.
310+
311+
## Threat model
312+
313+
Two important invariants follow from this design:
314+
315+
1. **Direct browser-to-trigger.dev requests cannot succeed**. As long as your agent's `clientDataSchema` requires the namespaced field, any request that doesn't go through the proxy fails schema validation and produces an empty turn. This is your gate.
316+
2. **Anything inside the namespaced key is trusted only as far as the proxy is the sole writer**. If a client could obtain the public access token and bypass the proxy, they could send arbitrary values under `__cf`. The schema would still validate (it only checks shape, not provenance). The mitigation is operational: the public access token must only be served to clients that reach trigger.dev through the proxy. In practice this means your Next.js server actions and your browser are both behind the same edge layer, and the worker is the only fetch destination for `trigger.dev` baked into either of them.
317+
318+
You can harden further with a shared-secret header the worker injects (e.g. `X-Edge-Signature`) and an agent-side check, but in most CDN deployments the deployment topology is already sufficient.
319+
320+
## Recipe summary
321+
322+
1. Pick a namespaced key the edge proxy owns (`__cf`, `__edge`, `__trust`).
323+
2. Deploy a proxy in front of `trigger.dev` that rewrites POST `/api/v1/sessions` and POST `/realtime/v1/sessions/{id}/in/append` to inject your trusted values under that key.
324+
3. Declare the namespace in the agent's `clientDataSchema` so missing or malformed signals fail at the agent boundary.
325+
4. Point your transport's `baseURL` at the proxy. Never expose `api.trigger.dev` directly to the browser.
326+
327+
## See also
328+
329+
- [Client Protocol](/ai-chat/client-protocol) — the full wire shape the proxy is rewriting.
330+
- [`withClientData`](/ai-chat/reference#withclientdata) — agent-side typed metadata channel.
331+
- [Large payloads](/ai-chat/patterns/large-payloads) — for when injected signals or hooks need to ship more than the 1 MiB stream cap allows.

0 commit comments

Comments
 (0)