AI assistant
The optional, bring-your-own-key chat that helps you author channels.
An optional, bring-your-own-key admin chat that builds channels and packages from your own Plex library — grounded in real library data, and gated only by adding an AI connection. Nothing is ever sent to a provider until you configure one.
Overview
Airwave ships an in-app channel-building assistant: a chat that can inspect your library, propose filters, preview what they match, and (with your approval) create or edit channels and packages for you. It is entirely opt-in and bring-your-own-key — Airwave has no built-in model and makes no outbound AI calls of its own.
Two facts define the feature:
- It does nothing until you add a connection. The chat resolves the active AI connection at request time (
getActiveModel→getActiveConnection,packages/api/src/services/agent/config.ts:73). With no connection assigned to the chat role, the endpoint returns400 "No active AI connection. Add one in Settings → AI Assistant."(packages/api/src/services/agent/chat.ts:129-135) and the panel shows an empty state instead of a prompt box (apps/web/src/components/ai/ai-chat-panel.tsx:63-87). No key, no traffic. - The chat is gated ONLY by an
AiConnection— not byWORKFLOW_ENABLED.WORKFLOW_ENABLEDgates a separate system: the durable, multi-agent AI lineup builder (and the lineup importer) that runs on the Workflow SDK. The two are easy to conflate but are wired independently — see Assistant vs the AI lineup builder anddocs/workflows.md.
The endpoint is admin-only. POST /api/ai/chat is cookie-authenticated and rejects any non-admin session (apps/server/src/index.ts:64-73).
Setting up a connection
Go to Settings → AI Assistant (/settings/ai, apps/web/src/routes/_auth/settings/ai.tsx). Add a connection with a provider, a model, and (usually) an API key.
Supported providers (AI_PROVIDERS, config.ts:16), each mapped to a Vercel AI SDK model by the getModel factory (config.ts:21-35):
Provider (provider) | Adapter | Notes |
|---|---|---|
anthropic | @ai-sdk/anthropic | Claude models |
openai | @ai-sdk/openai | GPT / o-series |
google | @ai-sdk/google | Gemini |
compatible | @ai-sdk/openai with a custom baseUrl | Any OpenAI-compatible endpoint — Ollama, LM Studio, vLLM, OpenRouter. The key may be omitted for a keyless local endpoint. Only tool-calling-capable models drive the agent well. |
Keys are encrypted at rest. A key is stored in AiConnection.apiKeyEnc as AES-256-GCM ciphertext, never plaintext (config.ts:152, createConnection). Encryption is app-level via node:crypto, with the key derived from BETTER_AUTH_SECRET (packages/api/src/services/crypto.ts). Two consequences worth knowing:
- The API never returns a key back to the UI — the connection list exposes only
hasKey(listConnections,config.ts:51-64). - If
BETTER_AUTH_SECRETchanges, every stored key (and the Plex token, which uses the same scheme) becomes undecryptable and must be re-entered. Keep it stable.
Test button. Each connection card has a Test action that does a cheap round-trip ("reply with the single word: ok") to prove the provider/model/key actually work (testConnection, config.ts:192-208).
Per-role connections
A connection can hold up to three independent roles (AiConnection in packages/db/prisma/schema/ai.prisma:12-34):
- Chat (
isActive) — the model the admin assistant uses. This is the only role that matters for the chat. - Planner (
isPlanner) — one big reasoning call in the durable AI lineup builder. - Worker (
isWorker) — the ~50 per-channel build loops in that same builder.
The first connection you create automatically claims all three roles, so a single-connection setup needs no further configuration (createConnection, config.ts:144-159). The split only matters once you add a second connection — e.g. point the high-volume worker at a cheap model to cut lineup-build cost. Assign roles under "How connections are used" on the settings page (settings/ai.tsx:252-296); planner/worker also offer "Same as chat". Roles are explicit with no runtime fallback — a cleared planner/worker genuinely disables the AI lineup builder (getConnectionForRole, config.ts:99-112).
What it can do
The assistant's toolbox is a set of plain service functions (packages/api/src/services/agent/tools.ts) wrapped as AI SDK tools (packages/api/src/services/agent/agent-tools.ts). Read tools run freely; write tools pause for your approval.
Read / grounding tools (safe, run automatically):
list_media_sources— connected source(s) + enabled libraries + item counts. Called first to get themediaSourceId.library_overview— movie / show / episode counts for a source.list_filter_fields— the catalog of filterable fields, their kinds and allowed operators. Filters may only use these fields.discover_field_values— the real values of a tag field (genre, studio, actor, contentRating…) pulled from the live Plex library.search_titles— does the library contain X? Substring match over titles.preview_filter— resolve an unsaved filter tree and report the match count and matching items (episodes coalesced into their show with season/episode counts). This is the "measure before you build" step.list_channels/get_channel/list_packages/get_package— inspect what already exists.
Write tools (each needsApproval: true, so the chat pauses for an Approve/Deny decision — agent-tools.ts:117-165):
create_channel,update_channel(partial patch — pass only fields to change),delete_channelupdate_channels(bulk move to a package / enable-disable),renumber_channelscreate_package,update_package,delete_packageclear_ai_generated— one-shot undo (see below)
Everything the assistant creates is stamped aiGenerated = true (createChannel, tools.ts:308; createPackage, tools.ts:387). That stamp powers a clean undo: clear_ai_generated deletes only the rows the assistant (or the lineup builder) created, leaving your hand-made and preset channels/packages untouched (clearAiGenerated, tools.ts:411-421).
Grounding — why the filters are trustworthy
The system prompt (chat.ts:14-34) forces a grounded, safe workflow, and the tool design enforces it:
- Build filters only from fields returned by
list_filter_fieldsand values discovered viadiscover_field_values— never an invented genre or studio. - Always
preview_filterbefore creating a channel, and refine (don't create) if it returns 0 or looks wrong. - It knows Plex operator quirks — e.g.
title is "Bear"is a substring match, so it uses distinctive substrings and verifies with a preview.
The filter tree the tools accept is the real resolver grammar (a recursive condition | and/or group, agent-tools.ts:43-48), the same one the admin filter UI and the schedule resolver use — so a channel the assistant builds is indistinguishable from one you built by hand.
Using the chat
The assistant lives in a slide-in global side panel, toggled by the AI Assistant button (a Sparkles icon) in the top-right of the admin header (apps/web/src/components/layout/app-layout.tsx:74-86). The panel UI is built on Airwave's base-lyra AI Elements components (apps/web/src/components/ai-elements/ — conversation, message, prompt-input, reasoning, response, tool).
- Streaming. The client uses the AI SDK
useChathook withDefaultChatTransportagainst/api/ai/chat(ai-chat-panel.tsx:168-177). The server streams a UI-message response viastreamText(...).toUIMessageStreamResponse(...)(chat.ts:153-170). You see reasoning, tool calls, tool results, and the final answer appear live. A turn may chain up to 40 steps (stepCountIs(40)) so a long discovery+preview+build sequence completes in one turn. - History. Conversations persist to
AiConversation/AiMessage(Prisma models inai.prisma:102-130), keyed per user. New chat and History controls let you start fresh or resume a past thread (ai-chat-panel.tsx:104-140); the history list is fetched via theai.conversationstRPC query. Each message stores the full AI SDK part list as JSON so a thread replays faithfully. - Model badge. A footer badge shows the active model and lets you switch the chat's active connection inline (
ModelBadge,ai-chat-panel.tsx:291-319). - The Approve / Deny flow. When the assistant wants to write, the tool card renders an "Apply this change?" footer with Approve / Deny (
ai-chat-panel.tsx:242-254). Approving resumes the turn and runs the tool; denying leaves nothing changed. (Implementation note: the client setssendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponsesso the decision actually POSTs the resume request rather than sticking on "Working" —ai-chat-panel.tsx:172-177.)
Reliability details
Long AI chats are fragile in specific ways, and the server handles them:
- Prompt caching. A cache breakpoint is set on the last message so Anthropic reuses the whole prior prefix each turn — a channel-building chat grows past 100k tokens fast (previews are large), and without caching every turn would re-ship all of it (
chat.ts:143-151). Namespaced to Anthropic; a no-op for other providers. - Idle timeout. Bun's default 10s idle timeout is raised to 255s so a slow thinking/tool turn isn't killed mid-stream (
apps/server/src/index.ts:263-271). - Resume / heal. On each request the server strips unsigned/broken reasoning parts while keeping valid signed ones (
stripReasoning,chat.ts:56-63) and injects synthetic "not applied" results for dangling write tool calls the user has already moved past (healDanglingToolCalls,chat.ts:79-98) — without this a crashed approval turn would brick the whole conversation.
Assistant vs the AI lineup builder
Airwave has two distinct AI systems. Keep them separate:
| AI assistant (this doc) | AI lineup builder | |
|---|---|---|
| What it is | Interactive admin chat that builds channels one at a time, with you in the loop | Autonomous, durable job that designs and builds a whole lineup (~50 channels) in one run |
| Gated by | An AiConnection assigned the chat role (isActive). Nothing else. | WORKFLOW_ENABLED=1 and planner/worker connections |
| Runs on | The Vercel AI SDK, streamed live over /api/ai/chat | The Vercel Workflow SDK (durable, resumable, crash-surviving) |
| Approval | Every write is Approve/Deny in the chat | Runs autonomously (the services skip the needsApproval wrapper); the start is confirmed once because a re-run wipes prior AI rows |
| Model roles used | active (chat) | planner (one big design call) + worker (per-channel loops) |
| Where | packages/api/src/services/agent/{chat,agent-tools,tools}.ts | apps/server/workflows/lineup.ts (+ import.ts) |
Both share the same toolbox in tools.ts and both stamp their output aiGenerated — so clear_ai_generated cleanly reverses whatever either one made. The durable builder is documented in full in docs/workflows.md. If WORKFLOW_ENABLED is unset, the lineup builder is simply unavailable — but the chat assistant still works, because it never touched the workflow engine.
Privacy & cost
- Bring your own key, opt-in. Airwave ships no model and no key. Until you add a connection in Settings, the assistant is inert and no data leaves your server — the chat endpoint returns a 400 with no upstream call, and the UI shows an empty state.
- You pick the provider. Point it at a hosted provider (Anthropic / OpenAI / Google) or keep everything on your own hardware via an OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM) — in which case nothing leaves your network at all.
- You pay the provider directly. Airwave has no billing layer; usage is billed by whichever provider your key belongs to. Chats can grow large (previews are token-heavy), which is why prompt caching is on — but the cost is still yours and metered by the provider.
- Fully separable. Delete the connection and the assistant goes dark again. The feature is a bolt-on: no connection, no AI, no difference to the rest of Airwave.
Source map
| Concern | File |
|---|---|
| Connections, provider factory, roles, encryption entry points | packages/api/src/services/agent/config.ts |
| Streaming chat, persistence, resume/heal, prompt caching | packages/api/src/services/agent/chat.ts |
Tool service functions (reads + writes, aiGenerated, undo) | packages/api/src/services/agent/tools.ts |
AI SDK tool wrappers (needsApproval on writes) | packages/api/src/services/agent/agent-tools.ts |
Chat HTTP route (POST /api/ai/chat, cookie + admin) | apps/server/src/index.ts |
Secret encryption (AES-256-GCM, BETTER_AUTH_SECRET) | packages/api/src/services/crypto.ts |
| tRPC router (connection CRUD, roles, test, history) | packages/api/src/routers/ai.ts |
Prisma models (AiConnection, AiConversation, AiMessage) | packages/db/prisma/schema/ai.prisma |
| Chat side panel (useChat, Approve/Deny, history, model badge) | apps/web/src/components/ai/ai-chat-panel.tsx |
| AI Elements (base-lyra chat components) | apps/web/src/components/ai-elements/ |
| Settings → AI Assistant (connection config + roles) | apps/web/src/routes/_auth/settings/ai.tsx |
| Header trigger for the panel | apps/web/src/components/layout/app-layout.tsx |
| Durable AI lineup builder (separate system) | docs/workflows.md, apps/server/workflows/lineup.ts |
