feat(sdk): type chat.createStartSessionAction against your chat agent (#3684)

## Summary

Type `chat.createStartSessionAction` against the chat agent so
`clientData` is typed end-to-end on the first turn. Closes the gap where
`useTriggerChatTransport`'s `startSession` callback already hands you a
typed `clientData` (via the transport generic) but the server-side
action couldn't accept it without untyped routing through the `metadata`
field.

## Design

`ChatStartSessionParams` gains a typed `clientData` field via the new
generic:

```ts
export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
  chatId: string;
  clientData?: InferChatClientData<TChat>;
  triggerConfig?: Partial<SessionTriggerConfig>;
  metadata?: Record<string, unknown>;
};

function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
  taskId: string,
  options?: CreateChatStartSessionActionOptions
): (params: ChatStartSessionParams<TChat>) => Promise<ChatStartSessionResult>
```

When provided, `clientData` is folded into the first run's
`triggerConfig.basePayload.metadata`, so `onPreload` / `onChatStart` see
the same shape per-turn `metadata` carries via the transport. The opaque
session-level `metadata` field stays exactly as before — it lands on the
Session row, not the run payload.

## Usage

```ts
// actions.ts
import { chat } from "@trigger.dev/sdk/ai";
import type { myChat } from "@/trigger/chat";

export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat");
```

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

## Test plan

- [x] `pnpm run build --filter @trigger.dev/sdk` passes
- [ ] Verify a `chat.agent` with `clientDataSchema` reads the typed
clientData from `onPreload` payload metadata on the first turn
This commit is contained in:
Eric Allam
2026-05-21 11:51:35 +01:00
committed by GitHub
parent d343727021
commit 9ff410bfa4
3 changed files with 195 additions and 17 deletions
@@ -0,0 +1,22 @@
---
"@trigger.dev/sdk": patch
---
Type `chat.createStartSessionAction` against your chat agent so `clientData` is typed end-to-end on the first turn:
```ts
import { chat } from "@trigger.dev/sdk/ai";
import type { myChat } from "@/trigger/chat";
export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat");
// In the browser, threaded from the transport's typed startSession callback:
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
// ...
});
```
`ChatStartSessionParams` gains a typed `clientData` field — folded into the first run's `payload.metadata` so `onPreload` / `onChatStart` see the same shape per-turn `metadata` carries via the transport. The opaque session-level `metadata` field is unchanged.
+37 -17
View File
@@ -9040,9 +9040,17 @@ export type CreateChatStartSessionActionOptions = {
/**
* Params for the function returned by {@link createChatStartSessionAction}.
*/
export type ChatStartSessionParams = {
export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
/** Conversation id (mapped to the Session's `externalId`). */
chatId: string;
/**
* Typed client data folded into the first run's `payload.metadata` so
* `onPreload`, `onChatStart`, etc. see the same `clientData` shape on the
* first turn as subsequent turns get via the transport's `clientData`
* option. Typed via the agent's `clientDataSchema` when the action is
* parameterised with `createStartSessionAction<typeof myChat>(...)`.
*/
clientData?: InferChatClientData<TChat>;
/**
* Per-call trigger config. Shallow-merged over the action's default
* `triggerConfig`. `basePayload` is the customer's wire payload (for
@@ -9050,7 +9058,11 @@ export type ChatStartSessionParams = {
* which the runtime injects automatically).
*/
triggerConfig?: Partial<SessionTriggerConfig>;
/** Pass-through metadata folded into the session row. */
/**
* Opaque session-level metadata stored on the Session row. Separate from
* the per-turn `clientData` above. Use this when you want to attach
* server-side metadata that doesn't go through the agent's `clientDataSchema`.
*/
metadata?: Record<string, unknown>;
};
@@ -9078,33 +9090,37 @@ export type ChatStartSessionResult = {
* Wrap in a Next.js server action (or any server-side handler) so the
* customer's secret key never crosses to the browser.
*
* Parameterise the action with `<typeof yourChatAgent>` to type the
* `clientData` field against your agent's `clientDataSchema`.
*
* @example
* ```ts
* // actions.ts
* "use server";
* import { chat } from "@trigger.dev/sdk/ai";
* import type { myChat } from "@/trigger/chat";
*
* export const startChatSession = chat.createStartSessionAction("my-chat", {
* triggerConfig: { machine: "small-1x" },
* });
* export const startChatSession = chat.createStartSessionAction<typeof myChat>(
* "my-chat",
* { triggerConfig: { machine: "small-1x" } }
* );
* ```
*
* Then in the browser:
* Then in the browser, threading the typed `clientData` from the transport:
* ```tsx
* const transport = useTriggerChatTransport({
* const transport = useTriggerChatTransport<typeof myChat>({
* task: "my-chat",
* accessToken: async ({ chatId }) => {
* const { publicAccessToken } = await startChatSession({ chatId });
* return publicAccessToken;
* },
* accessToken: ({ chatId }) => mintChatAccessToken(chatId),
* startSession: ({ chatId, clientData }) =>
* startChatSession({ chatId, clientData }),
* });
* ```
*/
function createChatStartSessionAction(
function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
taskId: string,
options?: CreateChatStartSessionActionOptions
): (params: ChatStartSessionParams) => Promise<ChatStartSessionResult> {
return async (params: ChatStartSessionParams): Promise<ChatStartSessionResult> => {
): (params: ChatStartSessionParams<TChat>) => Promise<ChatStartSessionResult> {
return async (params: ChatStartSessionParams<TChat>): Promise<ChatStartSessionResult> => {
if (!params.chatId) {
throw new Error(
"chat.createStartSessionAction: params.chatId is required — used as the session externalId."
@@ -9117,21 +9133,25 @@ function createChatStartSessionAction(
// `onPreload` fires, the runtime opens its `.in` subscription, the
// first user message arrives moments later via `.in/append`.
//
// `metadata` is the customer's transport-level `clientData`,
// threaded through so the agent's `clientDataSchema` validates on
// the very first turn (the typical schema requires `userId` etc.).
// `clientData` is folded into `basePayload.metadata` so the agent's
// `clientDataSchema` validates on the very first turn against the same
// shape per-turn `metadata` carries via the transport.
// Auto-tag every chat.agent run with `chat:{chatId}` so the dashboard /
// run-list filter by chat works without the customer having to wire it
// up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
const clientDataMetadata =
params.clientData !== undefined ? { metadata: params.clientData } : {};
const triggerConfig: SessionTriggerConfig = {
basePayload: {
messages: [],
trigger: "preload",
...(options?.triggerConfig?.basePayload ?? {}),
...(params.triggerConfig?.basePayload ?? {}),
...clientDataMetadata,
chatId: params.chatId,
},
...(options?.triggerConfig?.machine || params.triggerConfig?.machine
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import type { CreateSessionRequestBody, CreatedSessionResponseBody } from "@trigger.dev/core/v3";
import { chat } from "./ai.js";
import {
__setSessionStartImplForTests,
__setSessionOpenImplForTests,
SessionHandle,
} from "./sessions.js";
import { apiClientManager } from "@trigger.dev/core/v3";
// `auth.createPublicToken` is called by the action when no start token is
// supplied. Provide a minimal API client config so the mint path doesn't
// throw before we get to assert the captured request body.
apiClientManager.setGlobalAPIClientConfiguration({
baseURL: "https://example.invalid",
accessToken: "tr_test_secret",
});
// Capture the request body the action would send to `sessions.start()`.
let lastStartBody: CreateSessionRequestBody | undefined;
function installStartFixture() {
__setSessionStartImplForTests(async (body): Promise<CreatedSessionResponseBody> => {
lastStartBody = body;
return {
id: "session_fixture",
externalId: body.externalId ?? null,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: body.triggerConfig,
currentRunId: "run_fixture",
tags: body.triggerConfig.tags ?? [],
metadata: body.metadata ?? null,
closedAt: null,
closedReason: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
runId: "run_fixture",
publicAccessToken: "tr_pat_fixture",
isCached: false,
};
});
__setSessionOpenImplForTests(() => new SessionHandle("session_fixture"));
}
afterEach(() => {
__setSessionStartImplForTests(undefined);
__setSessionOpenImplForTests(undefined);
lastStartBody = undefined;
});
// Build a fake chat agent task shape that the generic can narrow against.
// We only need the static type — the runtime never invokes this task because
// `__setSessionStartImplForTests` intercepts the network call.
const fakeChat = chat
.withClientData({
schema: z.object({
userId: z.string(),
plan: z.enum(["free", "pro"]),
}),
})
.agent({
id: "fake-chat",
run: async () => undefined as any,
});
describe("chat.createStartSessionAction — runtime", () => {
it("folds typed clientData into basePayload.metadata so onChatStart sees it on the first turn", async () => {
installStartFixture();
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
const result = await start({
chatId: "chat-1",
clientData: { userId: "u-1", plan: "pro" },
});
expect(result.publicAccessToken).toBe("tr_pat_fixture");
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
messages: [],
trigger: "preload",
metadata: { userId: "u-1", plan: "pro" },
chatId: "chat-1",
});
});
it("leaves basePayload.metadata unset when clientData is not provided", async () => {
installStartFixture();
const start = chat.createStartSessionAction("fake-chat");
await start({ chatId: "chat-2" });
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
});
it("keeps session-level metadata distinct from per-turn clientData", async () => {
installStartFixture();
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
await start({
chatId: "chat-3",
clientData: { userId: "u-3", plan: "free" },
metadata: { source: "marketing-site" },
});
// Per-turn shape (visible to onPreload / onChatStart):
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
metadata: { userId: "u-3", plan: "free" },
});
// Session-row metadata (opaque, never typed via clientDataSchema):
expect(lastStartBody?.metadata).toEqual({ source: "marketing-site" });
});
});
describe("chat.createStartSessionAction — types", () => {
it("narrows clientData against the chat agent's clientDataSchema", () => {
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
// The clientData field is typed off the agent's schema.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<
{ userId: string; plan: "free" | "pro" } | undefined
>();
// The agent's typed clientData is strictly narrower than `unknown`.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().not.toEqualTypeOf<unknown>();
});
it("defaults clientData to unknown when called without a generic", () => {
const start = chat.createStartSessionAction("fake-chat");
expectTypeOf(start).parameter(0).toHaveProperty("clientData");
// Untyped variant — clientData is `unknown`.
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<unknown>();
});
});