Wire up the AI assistant feature

Start state: nothing. End state: a streaming Claude-powered chat inside your app, with the API key held server-side and a system prompt tuned to your domain. The ai feature ships two Convex files that do all the work — you configure a key and edit one string.

1. Scaffold with ai

pnpm new concierge --title "Concierge" --type webapp --features ai
pnpm install
cd apps/concierge && npx convex dev --once && pnpm exec tsc --noEmit

2. How the two backend files divide the work

convex/ai.ts runs in Convex's default runtime and owns the data: listMessages (your chat history, per user), sendMessage (inserts your message plus an empty assistant message with status "streaming", then schedules the generation), and clearChat. convex/aiNode.ts starts with "use node" — the Claude SDK needs Node APIs, so the actual API call runs there as an internalAction.

Streaming works without websockets: as chunks arrive, the action patches the assistant message row (throttled to ~4 writes/second), and Convex reactivity live-updates every subscribed client:

// convex/aiNode.ts (shipped)
const stream = client.messages.stream({
  model: "claude-opus-4-8",
  max_tokens: 16000,
  thinking: { type: "adaptive" },
  system: "You are the in-app assistant for Concierge. Be concise and helpful; answer in the user's language.",
  messages: history,
});
stream.on("text", (delta) => { text += delta; /* throttled patch(text, "streaming") */ });
The key never touches the browser. It lives in the Convex deployment's environment, read by the Node action via process.env.ANTHROPIC_API_KEY — there is no VITE_ variable for it and there must never be one.

3. Set the API key

cd apps/concierge
npx convex env set ANTHROPIC_API_KEY sk-ant-…          # dev deployment
npx convex env set ANTHROPIC_API_KEY sk-ant-… --prod   # after pnpm deploy

Expected without the key: the chat still works, but every reply is the built-in error message telling you exactly this command — the feature fails informatively, not silently.

4. Make it a domain assistant

The system prompt is one string in convex/aiNode.ts. Replace it to specialize:

system:
  "You are the support assistant for Concierge, a booking tool for small hotels. " +
  "Answer questions about reservations, cancellations, and pricing tiers. " +
  "If asked anything outside hotel operations, say it is out of scope. Be concise.",

Then push the change and typecheck:

npx convex dev --once && pnpm exec tsc --noEmit

The last 30 messages of history (MAX_HISTORY in ai.ts) go along with every request, so the assistant keeps conversational context per user.

5. Try it

pnpm dev

Sign up, open AI in the nav, ask something in-domain. Expected: the reply streams in word by word; a second browser tab on the same account shows the identical stream — it is database reactivity, not a client-side trick.

Cost note: each message sends up to 30 turns of history to a large model with max_tokens 16000. Fine for an MVP; before real traffic, consider a cheaper model for casual chat, a lower max_tokens, and trimming MAX_HISTORY. The throttled patching already keeps Convex write costs low.

What you built

  • A streaming AI chat: mutation → scheduled "use node" action → Claude API → reactive patches back to the UI
  • A server-side key setup for dev and prod, with a self-explaining failure mode when unset
  • A domain assistant customized by editing one system string