Airwave

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 (getActiveModelgetActiveConnection, packages/api/src/services/agent/config.ts:73). With no connection assigned to the chat role, the endpoint returns 400 "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 by WORKFLOW_ENABLED. WORKFLOW_ENABLED gates 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 and docs/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)AdapterNotes
anthropic@ai-sdk/anthropicClaude models
openai@ai-sdk/openaiGPT / o-series
google@ai-sdk/googleGemini
compatible@ai-sdk/openai with a custom baseUrlAny 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_SECRET changes, 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 the mediaSourceId.
  • 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_channel
  • update_channels (bulk move to a package / enable-disable), renumber_channels
  • create_package, update_package, delete_package
  • clear_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_fields and values discovered via discover_field_values — never an invented genre or studio.
  • Always preview_filter before 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 useChat hook with DefaultChatTransport against /api/ai/chat (ai-chat-panel.tsx:168-177). The server streams a UI-message response via streamText(...).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 in ai.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 the ai.conversations tRPC 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 sets sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses so 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 isInteractive admin chat that builds channels one at a time, with you in the loopAutonomous, durable job that designs and builds a whole lineup (~50 channels) in one run
Gated byAn AiConnection assigned the chat role (isActive). Nothing else.WORKFLOW_ENABLED=1 and planner/worker connections
Runs onThe Vercel AI SDK, streamed live over /api/ai/chatThe Vercel Workflow SDK (durable, resumable, crash-surviving)
ApprovalEvery write is Approve/Deny in the chatRuns autonomously (the services skip the needsApproval wrapper); the start is confirmed once because a re-run wipes prior AI rows
Model roles usedactive (chat)planner (one big design call) + worker (per-channel loops)
Wherepackages/api/src/services/agent/{chat,agent-tools,tools}.tsapps/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

ConcernFile
Connections, provider factory, roles, encryption entry pointspackages/api/src/services/agent/config.ts
Streaming chat, persistence, resume/heal, prompt cachingpackages/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 panelapps/web/src/components/layout/app-layout.tsx
Durable AI lineup builder (separate system)docs/workflows.md, apps/server/workflows/lineup.ts

On this page