> ## Documentation Index
> Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend

> Transport setup, session management, client data, and frontend patterns for AI Chat.

## How the transport works

Vanilla `useChat` expects an `api` URL — it POSTs the conversation to your own Next.js route handler, which terminates the stream. `useTriggerChatTransport` replaces that round-trip: instead of an `api` URL, you pass a custom [`ChatTransport`](https://ai-sdk.dev/docs/ai-sdk-ui/transport) that talks directly to the Trigger.dev cloud (or your self-hosted webapp) on behalf of `useChat`.

There's no API route to maintain. The browser uses a short-lived session-scoped PAT (minted by your `accessToken` server action) to:

* **Create the session** via your `startSession` action on the first message (or `transport.preload(chatId)`).
* **Append the new user message** to the session's durable `.in` stream.
* **Subscribe to the `.out` SSE stream** for the agent's response chunks (text, tool calls, reasoning, custom `data-*` parts).

The transport handles the auth refresh, reconnect, `Last-Event-ID` resume, and stop-signal plumbing transparently. `useChat` sees the result as `UIMessageChunk`s and renders them unchanged.

## Transport setup

Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`:

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, stop, status } = useChat({ transport });
  // ... render UI
}
```

The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID.

The two callbacks have distinct responsibilities:

* **`accessToken`** is a *pure* PAT mint — the transport invokes it on a 401/403 to refresh the session-scoped token. Customer wraps `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })`, which resolves to a `Promise<string>` (the JWT). Return that string from your `accessToken` callback.
* **`startSession`** wraps `chat.createStartSessionAction(taskId)` and is called when the transport needs to *create* the session (`transport.preload(chatId)`, or lazily on the first `sendMessage` for a chatId without a cached PAT). The customer's server controls authorization here, alongside any DB writes paired with session creation.

See [Quick start](https://trigger.dev/docs/ai-chat/quick-start.md) for the matching server actions.

> [!TIP]
>
> The hook keeps `onSessionChange` and `clientData` up to date via internal refs, so you don't need
> to memoize callbacks or worry about stale closures when those options change between renders.

## Typed messages (`chat.withUIMessage`)

If your chat agent is defined with [`chat.withUIMessage<YourUIMessage>()`](https://trigger.dev/docs/ai-chat/types) (custom `data-*` parts, typed tools, etc.), pass the same message type through `useChat` so `messages` and `message.parts` are narrowed on the client:

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "./myChat";

type Msg = InferChatUIMessage<typeof myChat>;

const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
});
const { messages } = useChat<Msg>({ transport });
```

See the [Types](https://trigger.dev/docs/ai-chat/types.md) guide for defining `YourUIMessage`, default stream options, and backend examples.

### Calling a fetch endpoint instead of a server action

If you want to mint tokens via a REST endpoint instead of a Next.js server action, the same callbacks accept any async function. Import `AccessTokenParams` and `StartSessionParams` from `@trigger.dev/sdk/chat` to type your fetch handler.

```ts theme={"theme":"css-variables"}
import type { AccessTokenParams, StartSessionParams } from "@trigger.dev/sdk/chat";

const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: async ({ chatId }: AccessTokenParams) => {
    const res = await fetch(`/api/chat/${chatId}/access-token`, { method: "POST" });
    return res.text();
  },
  startSession: async ({ chatId, taskId, clientData }: StartSessionParams) => {
    const res = await fetch(`/api/chat/${chatId}/start`, {
      method: "POST",
      body: JSON.stringify({ taskId, clientData }),
    });
    return res.json(); // { publicAccessToken: string }
  },
});
```

The fetch handlers on the server side wrap the same SDK helpers as the server-action variant: `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })` for refresh and `chat.createStartSessionAction(taskId)` for create.

## Session management

Every chat is backed by a durable Session — the row that owns the chat's runs, persists across run lifecycles, and orchestrates handoffs. The transport manages the session for you; what you persist on your side is a small piece of state per chat that lets a fresh tab resume without a round-trip to create a new session.

### What the transport persists per chat

| Field | Type | Notes |
| - | - | - |
| `publicAccessToken` | `string` | Session-scoped JWT (`read:sessions:{chatId} + write:sessions:{chatId}`). Refreshed automatically on 401/403 via `accessToken`. |
| `lastEventId` | `string \| undefined` | Resume cursor on `.out`: the last SSE event received, or while a turn is streaming, the cursor from when that turn started, so a reload replays the whole turn. **Valid for the lifetime of the Session**: keep it across `endRun` / `requestUpgrade` / continuation-run boundaries; only clear when the Session itself closes. The cursor lets the next subscription open past the prior turn's stale `turn-complete` record. |
| `isStreaming` | `boolean \| undefined` | **Optional.** The transport sets it internally, but you don't have to persist it — the server decides "nothing is streaming" via the session's [`X-Session-Settled`](https://trigger.dev/docs/ai-chat/client-protocol#x-session-settled-fast-close-on-idle-reconnects) signal on reconnect. If you do persist it, the transport keeps the fast-path short-circuit. If you drop it, reconnects open the SSE and close fast on settled sessions. |

### Session cleanup (frontend)

Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends:

```tsx theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  sessions: loadedSessions, // Restored from DB on page load
  onSessionChange: (chatId, session) => {
    if (!session) {
      deleteSession(chatId); // Server action — run ended
    }
  },
});
```

### Restoring on page load

On page load, fetch both the messages and the session state from your database, then pass them to `useChat` and the transport. Pass `resume: true` to `useChat` when there's an existing conversation — this tells the AI SDK to reconnect to the stream via the transport.

Because the underlying Session row outlives individual runs, a chat you were in yesterday resumes against the same chat — even if the original run has long since exited. The transport hydrates from the persisted state and uses `lastEventId` to resubscribe; if the client tries to send a new message and no run is alive, the server triggers a fresh continuation run on the same session before the message is appended.

If you do not keep your own copy of the conversation, load it from the agent's [transcript storage](https://trigger.dev/docs/ai-chat/transcript-storage.md#reading-the-transcript) instead: `chat.createLoadTranscriptAction(storage)` on the server and `useLoadTranscript(chatId, action, { transport })` in the browser return the messages and seed the transport's resume cursor, for the default storage and your own alike.

Mount the component that calls `useChat` after transcript loading finishes. Its initial `messages` and the transport's cursor must describe the same saved transcript. Transcript loading can seed the cursor before session hydration: the transport keeps it until the session is created or hydrated. Reconnecting the live stream requires a known session.

To stop a generation after a reload, stop the local subscription and send the backend stop command together. See [Stop generation](#stop-generation) for the handler.

```tsx app/chat/[chatId]/ChatPage.tsx theme={"theme":"css-variables"}
"use client";

import { useEffect, useState } from "react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import {
  mintChatAccessToken,
  startChatSession,
  getChatMessages,
  getSession,
  deleteSession,
} from "@/app/actions";

// Rendered from `app/chat/[chatId]/page.tsx`, which awaits `params`
// and forwards `chatId` into this client component:
//
//   export default async function Page({ params }: { params: Promise<{ chatId: string }> }) {
//     const { chatId } = await params;
//     return <ChatPage chatId={chatId} />;
//   }
export default function ChatPage({ chatId }: { chatId: string }) {
  const [initialMessages, setInitialMessages] = useState([]);
  const [initialSession, setInitialSession] = useState(undefined);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    async function load() {
      const [messages, session] = await Promise.all([getChatMessages(chatId), getSession(chatId)]);
      setInitialMessages(messages);
      setInitialSession(session ? { [chatId]: session } : undefined);
      setLoaded(true);
    }
    load();
  }, [chatId]);

  if (!loaded) return null;

  return (
    <ChatClient
      chatId={chatId}
      initialMessages={initialMessages}
      initialSessions={initialSession}
    />
  );
}

function ChatClient({ chatId, initialMessages, initialSessions }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    sessions: initialSessions,
    onSessionChange: (id, session) => {
      if (!session) deleteSession(id);
    },
  });

  const { messages, sendMessage, stop, status } = useChat({
    id: chatId,
    messages: initialMessages,
    transport,
    resume: initialMessages.length > 0, // Resume if there's an existing conversation
  });

  // ... render UI
}
```

> [!NOTE]
>
> `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component
> mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so
> the frontend only receives new data. Only enable `resume` when there are existing messages — for
> brand new chats, there's nothing to reconnect to.

> [!NOTE]
>
> After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend. The
> transport accepts an abort signal on `reconnectToStream` but doesn't treat it as owning the turn,
> so aborting a resumed subscription only closes your local stream while the run keeps generating.
> Use `transport.stopGeneration(chatId)` for reliable stop behavior after resume — see
> [Stop generation](#stop-generation) for the recommended pattern. Pass `stopOnAbort: true` to
> `reconnectToStream` only when that subscriber owns the turn.

> [!WARNING]
>
> In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read
>     properties of undefined (reading 'state')` in the console when using `resume`. This is a [known
> bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode
> double-firing the resume effect. The error is caught internally and **does not affect
> functionality** — streaming and message display work correctly. It only appears in development and
> will not occur in production builds.

### Network resilience

You don't need to handle network drops, mobile background-kills, or Safari bfcache restores. The transport retries indefinitely with bounded backoff, reconnects on `online` / tab refocus / `pageshow` with `event.persisted`, and uses `Last-Event-ID` to resume without dropping chunks. See the [changelog entry](https://trigger.dev/docs/ai-chat/changelog.md) for the gory details.

## Client data and metadata

### Transport-level client data

Set default client data on the transport that's included in every request. When the task uses `clientDataSchema`, this is type-checked to match:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  clientData: { locale: currentLocale },
});
```

The transport threads `clientData` through three places automatically: into `startSession`'s `params.clientData` for the first run's `payload.metadata`, into per-turn `metadata` on every `.in/append` chunk, and live-updates if the option value changes between renders (so React-driven values like the selected language work without reconstructing the transport).

Browser-supplied `clientData` is input, including after schema validation. Use it for preferences such as language. Derive ownership, permissions, and plan limits from authenticated server state. See [trusted edge signals](https://trigger.dev/docs/ai-chat/patterns/trusted-edge-signals.md).

### Per-message metadata

Pass metadata with individual messages via `sendMessage`. Per-message values are merged with transport-level client data (per-message wins on conflicts):

```ts theme={"theme":"css-variables"}
sendMessage({ text: "Hello" }, { metadata: { model: "gpt-4o", priority: "high" } });
```

### Typed client data with clientDataSchema

Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.agent`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({
    locale: z.enum(["en", "fr"]).default("en"),
  }),
  run: async ({ messages, clientData, signal, streamText }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      system: clientData?.locale === "fr" ? "Reply in French." : "Reply in English.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

The schema also types the `clientData` option on the frontend transport:

```ts theme={"theme":"css-variables"}
// TypeScript enforces that clientData matches the schema
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  clientData: { userId: currentUser.id },
});
```

Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK.

## Stop generation

Use `transport.stopGeneration(chatId)` to stop the current generation. This sends a stop signal to the running task via input streams, aborting the current `streamText` call while keeping the run alive for the next message.

`stopGeneration` also works after a page refresh when the stream was reconnected via `resume`. Call `useChat`'s `stop()` immediately, then send the backend stop command without waiting for the local stop to finish:

```tsx theme={"theme":"css-variables"}
const { messages, sendMessage, stop: aiStop, status } = useChat({ transport });

// Wrap both calls in a single stop handler
const stop = useCallback(() => {
  aiStop();
  transport.stopGeneration(chatId);
}, [transport, chatId, aiStop]);

{
  status === "streaming" && (
    <button type="button" onClick={stop}>
      Stop
    </button>
  );
}
```

> [!NOTE]
>
> `transport.stopGeneration(chatId)` handles the backend stop signal and closes
> the SSE connection, while `aiStop()` (from `useChat`) updates the frontend
> status to `"ready"` and fires the `onFinish` callback.

> [!TIP]
>
> Aborting a resumed stream never stops the run by itself, so `useChat`'s
> built-in `stop()` isn't enough after a page refresh. Use the pattern above, or
> pass `stopOnAbort: true` to `reconnectToStream` when that subscriber owns the
> turn.

See [Stop generation](https://trigger.dev/docs/ai-chat/backend.md#stop-generation) in the backend docs for how to handle stop signals in your task.

## Tool approvals

AI SDK 6 and later support `needsApproval: true` on tools with an `execute` function. The approval request appears before `execute` runs. Approving the call runs it on the next turn; denying it skips execution.

To collect a selection from search results, use a tool without `execute` and supply its result with `addToolOutput`. See [Choose from tool results](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop.md#choose-from-tool-results).

### Backend: define an approval-required tool

```ts theme={"theme":"css-variables"}
import { tool } from "ai";
import { z } from "zod";

const sendEmail = tool({
  description: "Send an email. Requires human approval before sending.",
  inputSchema: z.object({
    to: z.string(),
    subject: z.string(),
    body: z.string(),
  }),
  needsApproval: true,
  execute: async ({ to, subject, body }) => {
    await emailService.send({ to, subject, body });
    return { sent: true, to, subject };
  },
});
```

Declare the tool on the agent and pass it to the `streamText` supplied to your `run` function. When the model calls the tool, `chat.agent` streams a `tool-approval-request` chunk. The turn completes and the run waits for the next message.

### Frontend: approval UI

Import `lastAssistantMessageIsCompleteWithApprovalResponses` from the AI SDK and pass it to `sendAutomaticallyWhen`. This tells `useChat` to automatically re-send messages once all approvals have been responded to.

Destructure `addToolApprovalResponse` from `useChat` and wire it to your approval buttons:

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import {
  isToolUIPart,
  lastAssistantMessageIsCompleteWithApprovalResponses,
  type ChatTransport,
  type UIMessage,
} from "ai";

function Chat({ chatId, transport }: { chatId: string; transport: ChatTransport<UIMessage> }) {
  const { messages, sendMessage, addToolApprovalResponse, status } = useChat({
    id: chatId,
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  const handleApprove = (approvalId: string) => {
    addToolApprovalResponse({ id: approvalId, approved: true });
  };

  const handleDeny = (approvalId: string) => {
    addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" });
  };

  return (
    <div>
      {messages.map((msg) =>
        msg.parts.map((part, i) => {
          if (isToolUIPart(part) && part.state === "approval-requested") {
            return (
              <div key={i}>
                <p>Tool "{part.type}" wants to run with input:</p>
                <pre>{JSON.stringify(part.input, null, 2)}</pre>
                <button onClick={() => handleApprove(part.approval.id)}>Approve</button>
                <button onClick={() => handleDeny(part.approval.id)}>Deny</button>
              </div>
            );
          }
          // ... render other parts
        })
      )}
    </div>
  );
}
```

### How it works

1. Model calls a tool with `needsApproval: true` — the turn completes with the tool in `approval-requested` state
2. Frontend shows Approve/Deny buttons
3. User clicks Approve — `addToolApprovalResponse` updates the tool part to `approval-responded`
4. `sendAutomaticallyWhen` returns `true` — `useChat` re-sends the updated assistant message
5. The transport sends the message via input streams — the backend matches it by ID and replaces the existing assistant message in the accumulator
6. `streamText` sees the approved tool, executes it, and streams the result

> [!NOTE]
>
> Message IDs are kept in sync between frontend and backend automatically. The backend always
> includes a `generateMessageId` function when streaming responses, ensuring the `start` chunk
> carries a `messageId` that the frontend uses. This makes the ID-based matching reliable
> for tool approval updates.

## Sending actions

Send custom actions (undo, rollback, edit, regenerate) as `useChat` requests, with the action in the request `body`. The transport recognises `body.action` and sends it as an action rather than a message, and because `useChat` made the request it owns the response: an action that returns `chat.turn()` on the server streams its answer into the message list like any turn, with `status`, `error` and `stop` behaving as for a message. An action that returns nothing completes with no message added.

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useChatActions, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";

function ChatControls({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
  });
  const { sendMessage, regenerate, setMessages } = useChat({ id: chatId, transport });
  const { sendAction } = useChatActions({ sendMessage });

  return (
    <div>
      <button
        onClick={() => {
          // Mirror the server-side edit optimistically; the server does not
          // push history changes back.
          setMessages((prev) => prev.slice(0, -2));
          void sendAction({ type: "undo" });
        }}
      >
        Undo last exchange
      </button>
      {/* regenerate() removes the trailing answer locally; the server does the same before its turn */}
      <button onClick={() => regenerate({ body: { action: { type: "regenerate" } } })}>
        Regenerate
      </button>
    </div>
  );
}
```

`useChatActions` is a two-line convenience over `sendMessage(undefined, { body: { action } })`. Any `useChat` request can carry an action the same way, `regenerate({ body })` included.

The action payload is validated against the agent's `actionSchema` on the backend; invalid actions are rejected. See [Actions](https://trigger.dev/docs/ai-chat/actions.md) for the backend setup.

> [!NOTE]
>
> `transport.sendAction()` still exists for callers outside `useChat` (server to server, or a custom client). It returns the response as a raw `ReadableStream<UIMessageChunk>` that the caller must read; `useChat` does not consume it.

For server-to-server usage, `AgentChat` has the same method:

```ts theme={"theme":"css-variables"}
const stream = await agentChat.sendAction({ type: "undo" });
for await (const chunk of stream) {
  if (chunk.type === "text-delta") process.stdout.write(chunk.delta);
}
```

## Sub-agents

When a tool delegates to a sub-agent with [`chat.forwardSubAgent`](https://trigger.dev/docs/ai-chat/patterns/sub-agents), the parent's tool part has no output until the sub-agent finishes. `useSubAgentMessages` rebuilds the sub-agent's message from the chunks streamed so far:

```tsx app/components/chat.tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useSubAgentMessages } from "@trigger.dev/sdk/chat/react";

const subAgents = useSubAgentMessages();
const { messages } = useChat({ transport, onData: subAgents.onData });

// in the tool card: live while it runs, the tool output once it finishes
const message = subAgents.get(part.toolCallId) ?? part.output;
```

## Multi-tab coordination

When the same chat is open in multiple browser tabs, `multiTab: true` prevents duplicate messages and syncs conversation state across tabs. Only one tab can send at a time. Other tabs enter read-only mode with real-time message updates.

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useMultiTabChat } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

function Chat({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    multiTab: true,
  });

  const { messages, setMessages, sendMessage } = useChat({
    id: chatId,
    transport,
  });

  const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);

  return (
    <div>
      {isReadOnly && (
        <div className="bg-amber-50 text-amber-700 p-2 text-sm">
          This chat is active in another tab. Messages are read-only.
        </div>
      )}
      {/* message list */}
      <input
        disabled={isReadOnly}
        placeholder={isReadOnly ? "Active in another tab" : "Type a message..."}
      />
    </div>
  );
}
```

### How it works

1. When a tab sends a message, the transport "claims" the chatId via `BroadcastChannel`
2. Other tabs detect the claim and enter read-only mode (`isReadOnly: true`)
3. The active tab broadcasts its messages so read-only tabs see updates in real-time
4. When the turn completes, the claim is released. Any tab can send next.
5. Heartbeats detect crashed tabs (10s timeout clears stale claims)

### What `useMultiTabChat` does

* Returns `{ isReadOnly }` for disabling the input UI
* Broadcasts `messages` from the active tab to other tabs
* Calls `setMessages` on read-only tabs when messages arrive from the active tab
* Tracks read-only state via the transport's `BroadcastChannel` coordinator

> [!NOTE]
>
> Multi-tab coordination is same-browser only (`BroadcastChannel` is a browser API). It gracefully degrades to a no-op in Node.js, SSR, or browsers without `BroadcastChannel` support. Cross-device coordination requires server-side involvement.

## Self-hosting

If you're self-hosting Trigger.dev, pass the `baseURL` option:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  baseURL: "https://your-trigger-instance.com",
});
```

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

```ts theme={"theme":"css-variables"}
baseURL: ({ endpoint }) =>
  endpoint === "out" ? "https://api.trigger.dev" : "https://chat-proxy.example.com",
```

For per-request control beyond URL routing (header injection, custom retries, tracing), pass a `fetch` override. See [Trusted edge signals](https://trigger.dev/docs/ai-chat/patterns/trusted-edge-signals.md) for a full proxy walkthrough.

## Monitoring message delivery

`sendMessage` from `useChat` gives no feedback about whether the message actually reached the backend. The transport's `onEvent` callback closes that gap with typed lifecycle events (see the [event catalog](https://trigger.dev/docs/ai-chat/reference.md#transport-events)) so you can record real metrics:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  onEvent: (event) => {
    switch (event.type) {
      case "message-sent":
        metrics.increment("chat.message_sent");
        metrics.timing("chat.send_duration_ms", event.durationMs);
        break;
      case "message-send-failed":
        metrics.increment("chat.message_send_failed", { status: event.status });
        break;
    }
  },
});
```

A `message-sent` event means the message is durably written to the session's input stream (the stream the agent consumes from), so it's a true "sent successfully" signal. Because send and response events share the same callback, "sent but never answered" becomes a small client-side watchdog:

```ts theme={"theme":"css-variables"}
// Module scope (or a ref) so re-renders don't recreate it. Keyed by chatId
// on purpose: a new send on the same chat supersedes the in-flight turn.
const pending = new Map<string, ReturnType<typeof setTimeout>>();

onEvent: (event) => {
  if (event.type === "message-sent" && event.source === "submit-message") {
    pending.set(event.chatId, setTimeout(() => {
      // Log the chatId; don't tag the metric with it (unbounded cardinality).
      metrics.increment("chat.sent_but_unanswered");
      console.warn("sent but unanswered", event.chatId);
    }, 30_000));
  }
  if (event.type === "first-chunk" || event.type === "turn-completed" || event.type === "stream-error") {
    clearTimeout(pending.get(event.chatId));
    pending.delete(event.chatId);
  }
};
```

Time to first token is `first-chunk`'s `sinceSendMs` (the transport tracks the last turn-producing send per chat, so no bookkeeping is needed), and `turn-completed`'s `sinceSendMs` is the full turn latency. Exceptions thrown inside `onEvent` are swallowed, so a failing metrics pipeline can never break the chat.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.