|
| 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