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’sDocumentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
clientData 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.
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.
Why headers don’t work
It’s tempting to ask whetherPOST /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.
The trigger.dev wire payload, on the other hand, has a typed per-turn metadata channel (ChatTaskWirePayload.metadata). It already flows from the wire into clientData on every hook (onBoot, onChatStart, onTurnStart, run, onTurnComplete). That field is where signals must land.
The trust boundary
The pattern has one architectural requirement and one wire-shape convention. Topology: the browser must not be able to reachtrigger.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.
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.
Wire payload — the two endpoints to rewrite
The signal needs to land in two places. Both bodies are JSON; the edge proxy parses, mutates the namespaced key, and re-serializes.POST /api/v1/sessions — session create
The browser’s session-create call carries the first-turn metadata under triggerConfig.basePayload.metadata. The proxy mutates that:
POST /realtime/v1/sessions/{id}/in/append — every follow-up turn
The body is a JSON-serialized ChatInputChunk. The proxy parses it, checks kind === "message", and mutates payload.metadata:
/in/append — a typical trust object is ~200 bytes.
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.
Cloudflare Worker reference implementation
A complete worker that proxies all paths toTRIGGER_API_UPSTREAM and injects __cf on the two body-write endpoints:
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.
Streaming and latency
The SDK’sbaseURL accepts a function (see 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.
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.
Cloudflare Workers HTTP requests 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.
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.
Agent side — declare the namespace in clientDataSchema
Mirror the namespace in the agent so every turn lands typed:
__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). That gives you a server-side enforcement check for “did this request actually come through the trusted path?”
Browser transport configuration
Point theTriggerChatTransport at the worker, not at api.trigger.dev:
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.
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:
"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.
Threat model
Two important invariants follow from this design:- Direct browser-to-trigger.dev requests cannot succeed. As long as your agent’s
clientDataSchemarequires the namespaced field, any request that doesn’t go through the proxy fails schema validation and produces an empty turn. This is your gate. - 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 fortrigger.devbaked into either of them.
X-Edge-Signature) and an agent-side check, but in most CDN deployments the deployment topology is already sufficient.
Recipe summary
- Pick a namespaced key the edge proxy owns (
__cf,__edge,__trust). - Deploy a proxy in front of
trigger.devthat rewrites POST/api/v1/sessionsand POST/realtime/v1/sessions/{id}/in/appendto inject your trusted values under that key. - Declare the namespace in the agent’s
clientDataSchemaso missing or malformed signals fail at the agent boundary. - Point your transport’s
baseURLat the proxy. Never exposeapi.trigger.devdirectly to the browser.
See also
- Client Protocol — the full wire shape the proxy is rewriting.
withClientData— agent-side typed metadata channel.- Large payloads — for when injected signals or hooks need to ship more than the 1 MiB stream cap allows.

