---
name: ai
description: "Use when adding LLM features (chat, streamed text, structured output, tools, media generation)"
metadata:
  type: convention
  library: wcz-layout
---

> Mechanics are covered by TanStack's own `@tanstack/ai` skills.
> This skill adds the wcz-layout conventions on top of that.

## Rules

- The gateway needs three server-side inputs:
  - Ensure that required env are present: `AI_BASE_URL` (`z.url()`) and `AI_SUBSCRIPTION_KEY` (`z.string()`) in `src/env.ts`.
  - Pass `getAppToken("cognitiveServices")` as the API key.
  - The subscription key sent as the `Ocp-Apim-Subscription-Key` header.
- Build the adapter in one place, `src/server/ai.ts`, and call it per request. `getAppToken`
  caches and refreshes the token.
- Build every chat with `createLayoutChatHook(chatFn)` from `wcz-layout/hooks`.
- The model renders MUI components itself (`show_ui`) and asks the user with MUI forms
  (`ask_user`). The client sends both tools with every request, so the server function only
  merges them: `tools: mergeAgentTools(serverTools, tools)`. It must also forward `parentRunId`
  and `resume`, or tool results and approvals never continue the run.
- App tools are server tools (`toolDefinition(...).server(...)`) in `serverTools`; they check the
  signed-in user themselves. Give every tool that writes `needsApproval: true`: the chat shows an
  Approve / Deny card for it. Their calls show only the progress bar in the transcript.
- Structured output (one typed object, e.g. a generated document) stays raw `useChat` with
  `outputSchema` and `partial`, not the chat.
- Render streamed text outside the chat with `@tanstack/markdown` (install if missing):
  `<Markdown>` from `@tanstack/markdown/react`.

## Example

```ts
// src/server/ai.ts
import { createOpenaiChat } from "@tanstack/ai-openai";
import { getAppToken } from "wcz-layout/auth";
import { serverEnv } from "~/env";

export const aiText = async () =>
  createOpenaiChat("some-model", await getAppToken("cognitiveServices"), {
    baseURL: serverEnv.AI_BASE_URL,
    defaultHeaders: { "Ocp-Apim-Subscription-Key": serverEnv.AI_SUBSCRIPTION_KEY },
  });

// src/server/actions/<feature>.ts
import {
  chat,
  chatParamsFromRequestBody,
  mergeAgentTools,
  toServerSentEventsResponse,
  toolDefinition,
} from "@tanstack/ai";
import type { RunAgentInput } from "@tanstack/ai/client";
import { createServerFn } from "@tanstack/react-start";
import z from "zod";
import { authMiddleware } from "wcz-layout/middleware";
import { aiText } from "~/server/ai";

const getOrders = toolDefinition({
  name: "get_orders",
  description: "Orders of the signed-in user",
  inputSchema: z.object({ status: z.enum(["open", "closed"]) }),
}).server(async ({ status }) => findOrders(status));

export const chatFn = createServerFn({ method: "POST" })
  .validator(z.custom<RunAgentInput>())
  .middleware([authMiddleware()])
  .handler(async ({ data }) => {
    const { messages, threadId, runId, parentRunId, resume, tools } =
      await chatParamsFromRequestBody(data);
    const abortController = new AbortController();
    const stream = chat({
      adapter: await aiText(),
      threadId,
      runId,
      parentRunId,
      resume,
      systemPrompts: [SYSTEM_PROMPT],
      messages,
      tools: mergeAgentTools([getOrders], tools),
      abortController,
    });
    return toServerSentEventsResponse(stream, { abortController });
  });
```

```tsx
// client component
import { createLayoutChatHook } from "wcz-layout/hooks";
import { chatFn } from "~/server/actions/chat";

const { useLayoutChat, LayoutChat } = createLayoutChatHook(chatFn);

const chat = useLayoutChat({ threadId: "assistant" });
```
