AI
The config.ai product - a streaming chat assistant, scheduled runs, and an act-on-your-app capability layer on the shared Hono backend, with built-in credit-billed AI and/or user-supplied API keys.
config.ai turns on a full AI product on the shared Hono backend: a streaming chat assistant your users talk to, scheduled runs that fire on a cron cadence, and a capability layer that lets the assistant operate the app (list, run, create, edit, and delete schedules; manage integrations) on the user's behalf. Models come from two independent switches - built-in AI (builtin, billed from credits) and user API keys (byok, never metered) - either or both. The whole feature is inert until config.ai.enabled is true.
The assistant is a Mastra agent. Chat and scheduled runs both go through one registered agent (packages/api/src/mastra/agents/assistant/config.ts): Mastra owns the agent loop, its tools, and the memory threads, and this boilerplate layers the seams a SaaS needs on top - model resolution, per-run billing, the capability toolset, and the schedules boundary. That split is the mental model for the whole page: use Mastra's own conventions to author the agent, its tools, memory, and MCP clients; reach for our seams for the SaaS parts. See Agents to rename, re-voice, or extend the agent. @repo/ai stays available as the AI SDK re-export those seams use for model primitives (streamText, tool, and friends) when you build a bespoke endpoint of your own.
Where it lives
Chat and Schedules are top-level sidebar items; the AI configuration surfaces live in Settings. Only the surfaces whose feature flag is on ever render.
| Surface | Path | Appears when |
|---|---|---|
| Chat | /chat (plus a dock available across the app) | config.ai.enabled |
| Schedules | /schedules | config.ai.enabled - the scheduled runs users list, create, and manage |
| Models | /settings/models | config.ai.byok - the user provider-key surface plus the account default and fallback model |
| Integrations | /settings/integrations | config.ai.enabled - connect the accounts the assistant's capabilities act on |
| Companions | /settings/companions | config.companion.enabled - pair and watch companion daemons |
| MCP | /settings/mcp | config.mcpServer.enabled - the outward MCP server URL and add-to-agent commands |
In the sidebar, Chat and Schedules lead; Account (Profile, Billing, Security) and Settings (Models, Integrations, Companions, MCP, Developers) sit under the Manage category. Each AI settings tab is guided - an empty state names the single next step and carries one primary action.
Old links keep working: the dissolved /ai hub and its tabs, plus the former top-level /models, /integrations, /companions, and /mcp paths, all redirect to these new homes.
Where models come from
Two independent switches decide what your users can run; turn on either or both:
Built-in AI (builtin: true) | User API keys (byok: true) | |
|---|---|---|
| What it is | Your app offers models under its OWN name, billed from user credits | Users connect their own provider API keys |
| Who pays | The user's credit balance (you pay OpenRouter behind the scenes) | The user pays their provider directly - never credits |
| Setup | OPENROUTER_API_KEY in your server env - that is the whole setup | None on your side |
| Models | The full OpenRouter catalog, live-priced (narrow it with builtinModels) | Every catalog provider, models from live discovery |
| In the picker | ONE house pane named after your app (config.siteName), listed first | Vendor panes with per-provider connect + key management (/ai/keys) |
With both on, THE PANE THE USER PICKS IS THE BILLING CHOICE, encoded in the selection key itself: a pick from the built-in pane always draws credits (even if the user also holds their own OpenRouter key), and a pick from their own provider pane always runs on their key - if that key is gone, the run fails with a clear "add your key" error rather than silently billing credits. The two are never substituted. Even a fallback retry is priced by whichever selection actually ran. Runs on a paired companion CLI are never metered either - the user's own subscription executes them. One shared decision point (resolveChatRun in packages/api/src/ai/byok.ts) serves chat and scheduled runs, so the two can never drift.
Built-in pricing is automatic: per-token rates come from OpenRouter's live model listing (refreshed every 5 minutes, applied at run start), so the debit always tracks what the run actually cost times your shared pricingConfig.credits markup - no per-model rate upkeep, ever. A model the live listing cannot price is rejected rather than billed at a guessed rate. End-users never see "OpenRouter": the picker shows YOUR product as the provider.
Zero-setup for end-users: set config.ai.defaults (default + fallback model as plain OpenRouter ids, plus efforts) and a fresh user's chat, schedules, and picker arrive pre-configured on the model you chose - no provider, key, or model step, nothing to do but keep credits topped up. A user's own stored choice always overrides the defaults. Per-schedule defaults ride each AiScheduleSpec.defaults entry as before.
Metering is structural, not a setting
There is no metered flag anywhere, and nothing sniffs a provider name. A call can draw credits only if the MODEL OBJECT it runs on was built to, which makes "did this bill" a property of the model rather than a rule every surface has to remember:
| The rule | How it is enforced |
|---|---|
| A model resolves no credential by default | The resolver takes its credential source as a required argument - there is no implicit fallback to your server env |
| Exactly one module can reach your operator key | packages/api/src/ai/builtin-model.ts - a guard test fails the build the moment a second file imports the operator-key resolver |
| A built-in model is always metered | That factory builds the model and wraps it in the credits middleware in ONE expression, so the unwrapped model never escapes |
| A user-keyed model is never metered | It resolves unwrapped, so it has no path to the ledger at all |
The middleware settles per model call, so every step of an agent loop pays rather than only the first, and it clamps maxOutputTokens to what the remaining balance can actually buy.
Running out of credits mid-run
A pre-flight balance check cannot see the future: a long tool loop, or a second run spending the same balance, can exhaust it after the answer has started streaming. The run then stops where it is, which is a real behaviour change worth knowing before the support ticket arrives:
- The user keeps the partial answer already streamed, followed by an error - "You are out of credits, so the run stopped. Top up to continue."
- It is not a clean truncation and not a
402. The stream had already opened, so the failure arrives as a stream error rather than a status code. - Everything the provider actually produced is still billed. Tokens already generated are owed however the drain ends - a provider error, a closed tab, an aborted request.
A balance that is empty before the run is still a clean 402 with no model call made at all.
Chat
Chat renders two ways off the same view: a full /chat page (centered conversation column, session switcher, and model chip in the header) and a dock available across the app. Sessions continue across the two - the dock's "open full page" button and the page's "open as side panel" button hand the same conversation back and forth. When no provider is connected the empty state is the guide: one line and one button to connect a provider; once a provider is connected it greets the user with optional starter suggestions (config.ai.chat.suggestions, empty by default).
Under the hood, chat is a streaming assistant at POST /ai/chat (authenticated, rate-limited). The transport sends UI messages, the route resolves the run mode, and calls the registered assistant agent (getMastra().getAgent("assistant").stream(...)). The resolved model is passed as an explicit per-call override (the agent's own model is a static placeholder), the capability tools ride as a named app toolset, and the run streams tokens back while Mastra persists each turn to a server-side memory thread. A GET /ai/models endpoint feeds the model picker: the built-in house pane plus, with byok on, the provider catalog whose models are discovered per provider live.
- The user picks a model in the dashboard; with
byokon they also manage their own keys (/ai/keys). - The model picker carries a per-chat reasoning effort control, mapped to the chosen provider's native mechanism and shown only when that provider supports it. Send
reasoningEffortin thePOST /ai/chatbody; a provider without it simply ignores it. Web search just works: it is on automatically for every provider whose SDK supports it (Anthropic, OpenAI, Google, OpenRouter), with no toggle. It is always on for user-keyed runs; for built-in runs, provider search fees are billed outside token usage but ride OpenRouter's usage-accounting cost, so the credit debit does bill them - setconfig.ai.webSearch: falseto turn search off on built-in runs entirely. - A built-in run is debited on finish via the existing credits system; the stream is drained server-side so the debit lands even if the client disconnects.
- A recoverable failure retries the user's configured fallback model and signals it with an
x-ai-fallback-modelresponse header.
Synced chat history
Chat sessions follow the signed-in user across devices: every conversation is stored as a server-side memory thread in the app's own Postgres (Mastra Memory) and reappears in the switcher wherever they sign in. Transcripts are ordinary application data - protected at the database level, and readable by whoever operates the app. The honest guarantee ladder:
- Stored in your database, synced across devices. Threads live in Postgres, not only the browser, so a user's history follows them to every device they sign in on.
- Protected at the database level, not private from the operator. Transcripts are plain rows: your database access controls, network isolation, and backups guard them, but anyone with database or admin access can read them, the same as every other row the app holds.
- Live runs transit the backend. In-flight run content passes through the backend during execution, like every server-mediated AI product.
Two working limits keep history bounded: an account keeps its 20 most recent chats (the oldest thread is evicted past that), and a long conversation auto-condenses its older turns into a recap so it keeps fitting the model's context window.
Updating an existing project? Chat history resets once with this change - previously stored transcripts do not carry over. No migration step is needed: Mastra creates its memory tables on first use (CREATE TABLE IF NOT EXISTS), and the old ai_chat_sessions and ai_chat_keys tables sit unused until your next db:push drops them.
Scheduled runs
Scheduled runs let a user (or the builder) save a prompt that fires on a cadence, executing the same toolset as chat but headless. They are managed on the /schedules page - a slim card per schedule (status, cadence, next run, toggle) that opens a detail sheet holding the rest (prompt, last result, model, device pinning, run-now, delete, and a History button whose modal lists the last 25 recorded runs with each outcome and its credit cost) - and run on the shared background jobs cron. End-user authoring is gated by config.ai.userSchedules: when false, the create form is hidden, the create route returns 403, and the create_schedule capability is withheld, while listing, running, editing, and deleting existing schedules stay available.
Plans can also cap how many schedules each billing entity (the user, or the active org in org-billing mode) may create: set maxSchedules on a plan in packages/config/src/pricing.ts (omit it for unlimited; the shipped config caps free at 3 and starter at 10). The cap is enforced once at the creation choke point, so every surface - the create form, the chat capability, and the MCP tool - refuses at the limit (HTTP 403, PLAN_LIMIT_REACHED), and the schedules page shows usage against the cap with an upgrade path. Built-in schedules never count toward it.
A schedule's cadence is exactly one of a fixed interval (intervalMinutes, the preset Hourly/Daily/Weekly picks) or a cron expression (cron, the create form's "Custom schedule" option) for granular control - e.g. 0 9 * * 1-5 for weekday mornings. Editing a schedule with one cadence field switches it to that form and clears the other, and a cadence edit re-anchors the next fire to the new cadence. Crons are validated server-side with the exact parser the runner fires with, evaluate in the server's timezone, and due rows are swept every 5 minutes - so that is the effective granularity floor.
A scheduled run goes through the same registered assistant agent as chat (assistant.generate(...), threadless), with the same model resolution and the same credit metering - so the two paths can never drift. One behavioral consequence to know: because the run flows through the agent, it carries the agent's persona (the agent's instructions), not only the capability nudge a bare headless call would send. Re-voice that persona where you author the agent - see Agents.
Built-in schedules
Ship schedules with the product: declare them once in packages/config/src/ai/schedules.ts (@repo/config/ai/schedules) and every user gets them. The registry is aiSchedules, an array of AiScheduleSpec:
| Field | Purpose |
|---|---|
id | Stable registry id; row ids derive from it, so never reuse an old one. |
name | Display name in the schedules UI. |
description | Optional short user-facing description shown under the name. |
prompt | The prompt the scheduled run executes. Hidden from end-users by default - users see only the name/description. |
promptVisible | Set true to show the prompt on the schedule card like a user-authored schedule (default false). |
intervalMinutes | Fixed cadence in minutes. Set either this or cron, never both. |
cron | Cron expression cadence (web surface only; desktop's scheduler is interval-only). |
enabled | Whether it starts on - ship toggleable examples false. |
toggleable | Set false to FORCE the registry's enabled state: the on/off switch disappears and the backend refuses enabled edits, so enabled: true, toggleable: false is an always-on schedule (default true). |
surfaces | ("web" | "desktop")[] - which surfaces materialize it (optional; omitted means both). |
defaults | Per-surface execution defaults: web { model, effort }, desktop { agentId } (optional). |
On the web, each built-in materializes into a real schedule row for the owning entity (the user, or the active org in org-billing mode) with a deterministic id builtin:<id>:<entity> - the first time that entity lists its schedules, and, for an enabled spec, by the schedule sweep itself, so an always-on schedule fires for every user even if they never open the schedules page. The row is registry-owned: users can toggle a built-in on or off (unless toggleable: false), but never edit its prompt or cadence and never delete it - the lock is enforced by the store, not just the UI, and the schedules API serves a hidden prompt as null so it never reaches a client.
The registry stays the source of truth after deployment: the schedule sweep re-aligns every user's built-in rows to the current specs each tick, so editing a prompt/cadence/name propagates to everyone within minutes, flipping toggleable/enabled takes effect for all users, and removing an entry retires its rows. Changing an id is a remove-plus-add: the old rows retire and a fresh schedule materializes for everyone, so treat ids as permanent.
The registry is validated at startup: a spec with a broken cadence (both fields, neither, an unparseable cron, an interval under the floor) or a non-kebab-case/duplicate id fails the app at boot naming the offending id, instead of breaking the schedules API and sweep at runtime.
The boilerplate ships one example (a daily digest), disabled.
Capabilities
Capabilities are tools the assistant uses to operate the app for the user, so they can ask the assistant to do a thing instead of clicking through the UI. The shipped set lets the assistant list, run, create, edit, and delete schedules and manage the user's integrations. Capabilities run automatically with no approval prompt, the way a coding assistant acts on your behalf; every call is scoped to the acting user and audited.
The set is builder-extensible: add a defineCapability({ name, description, inputSchema, execute }) to a domain file under packages/api/src/ai/capabilities/ and spread it into APP_CAPABILITIES (packages/api/src/ai/app-capabilities.ts). The framework turns each one into a tool per run, applying surface filtering and audit, so a new capability is available to chat and scheduled runs at once.
Name a capability like an identifier - letters, digits, _ or -. A capability's name LEAVES your server: when a user runs your app's AI through their own coding CLI, the names are joined into one comma-separated allowlist the CLI reads as a list of permission rules - so a capability called list_users,Bash would pre-approve unprompted shell execution on that user's machine. defineCapability throws on a name outside that charset, at the declaration site, which means a bad name fails your build and tests rather than quietly breaking every terminal session your users open.
The accounts these capabilities act on are connected from the AI integrations catalog. The capability nudge that tells the assistant it may act - and every other prompt the product injects - is builder-editable in one module; see AI prompts.
The model-primitive toolkit: @repo/ai
The agent runtime is Mastra - the agent, its tools, memory threads, and MCP clients all follow Mastra's own conventions (see Agents). @repo/ai sits one level down from that: a thin, provider-agnostic re-export of the AI SDK v6 core (ai@^6) - the model primitives Mastra runs on, and the toolkit for a bespoke endpoint or a custom agent tool of your own. The whole package is one barrel (packages/ai/src/index.ts); it is sideEffects: false, so unused exports tree-shake away.
| Export | Kind | Purpose |
|---|---|---|
streamText | fn | Stream tokens - pipe straight to an HTTP response |
generateText | fn | One-shot text completion |
generateObject | fn | Typed JSON output validated by a Zod schema |
tool | fn | Define a function the model can call |
convertToModelMessages | fn | Map UI messages to model messages |
UIMessage / ModelMessage | type | The shapes your UI sends and the model expects |
LanguageModel | type | A configured provider model |
Writing your own AI surface
An endpoint of your own gets metering for free if it resolves its model through the same seam the shipped surfaces use, and gets nothing if it builds a provider model itself. This is the one mistake on this page that costs you real money, so it is worth stating flatly:
| What you write | What the run bills |
|---|---|
resolveChatRun(...) - packages/api/src/ai/byok.ts | The user's own selection decides: a built-in pick draws credits, their own key does not. Chat and scheduled runs both take this path |
builtinModel(...) - packages/api/src/ai/builtin-model.ts | Always metered - it returns a model already wrapped in the credits middleware |
openai("gpt-5.5"), anthropic(...), or any provider SDK model | Nothing. The call runs on whatever key is in scope and the ledger never hears about it |
So build the endpoint on a resolved model, and give it the run's own id so each call settles under its own key:
import { streamText, convertToModelMessages } from "@repo/ai";
import { Billing } from "@repo/payments";
import { resolveChatRun } from "../../../ai/byok"; // packages/api/src/ai/byok.ts
const { resolved } = await resolveChatRun(session.user.id, config.ai!, {
model: userSelection,
runId: crypto.randomUUID(),
feature: "ai.my_surface",
resolveBilling: () => Billing(session)
});
const result = streamText({
model: resolved.model,
messages: await convertToModelMessages(uiMessages)
});
return result.toUIMessageStreamResponse();Reaching for a provider SDK directly is right for exactly one case: work you pay for on your own key with no user-facing meter - a nightly internal digest, a moderation pass. Even then, keep the call on the shared backend.
Never expose a provider key to the client. The product's BYOK keys are stored server-side and your own server keys live in environment variables; keep every model call on the shared backend (API).
Frequently asked questions
Which flag turns AI on?
config.ai.enabled. With it false the backend /ai/* API endpoints are inert (the aiEnabledGuard returns 400) and no AI UI renders. Set it in packages/config/src/index.ts - see configuration.
Credits or BYOK - which should I ship?
builtin: true when you resell AI and want to meter and mark up usage (the flagship experience - zero setup for users); byok: true when users bring their own provider keys and pay the provider directly. Turn on both to offer built-in models while letting power users run free on their own keys.
Where do model calls run?
Always on the shared backend, so no key reaches the browser. The product owns /ai/chat; for your own endpoints, streaming-vs-fetch and client hooks differ per framework - see data fetching.
How should I upgrade Mastra?
Pin the @mastra/* 1.x line the boilerplate ships and treat an upgrade as a real change: bump, run the full test suite, and only then ship. Mastra publishes codemods for breaking changes, so a major bump is usually a scripted migration rather than a hand edit - run the codemod, then re-run the suite. Do not float the pins.
Agents
Author, rename, and extend the Mastra assistant agent: tools, MCP clients, skills, and its persona.
Configuration
The config.ai fields - enabled, byok, credits, models, userSchedules.
AI prompts
Re-voice every prompt the product injects into AI runs from one config module.
AI integrations
The catalog of third-party services the assistant can act on, and how a connector attaches.
Credits
The credit balance built-in AI runs are billed from.
Background jobs
The cron that fires scheduled AI runs.
External Agent (MCP)
Expose these capabilities to an external always-on agent over an authenticated MCP server.
Companion Daemon
A user-owned terminal daemon that drives their own coding-CLI subscription for your app, 24/7.
Publish the Companion
Cut a release of your branded companion daemon with one command so your users can install it.
API
The shared Hono backend the /ai/* routes mount on.
Blog
Publish a file-based Markdown blog with categories, tags, search, and view tracking via config.blog.
Agents
Author, rename, and extend the assistant - a Mastra agent. Register in code, add AI SDK tools, give it MCP clients and inline skills, and re-voice its persona. Our seams only; Mastra's own docs cover the rest.