AI
The config.ai product - a streaming chat assistant, cron-automated runs, and an act-on-your-app capability layer on the shared Hono backend, billed from credits or run on your users' own API keys.
config.ai ships a full AI product on the shared Hono backend: a streaming chat assistant, automated runs on a cron cadence, and capabilities that let the assistant operate the app for the user. Nothing renders until config.ai.enabled is true.
- One Mastra agent (
packages/api/src/mastra/agents/assistant/config.ts) serves chat and cloud automated runs alike. Author it with Mastra's own conventions - see Agents. - Two independent model switches: built-in AI (
builtin, billed from credits) and user API keys (byok, model calls never metered). Turn on either or both.
Where it lives
Chat and Automations lead the sidebar; AI configuration sits under Settings. Only surfaces whose flag is on ever render.
| Surface | Path | Appears when |
|---|---|---|
| Chat | /chat (plus an app-wide dock) | config.ai.enabled |
| Automations | /automations | config.ai.enabled |
| Models | /settings/models | config.ai.byok or config.ai.builtin - connected providers (built-in, user keys), account default and fallback model |
| Integrations | /settings/integrations | config.ai.enabled - the service connections that become agent tools |
| MCP | /settings/mcp | config.mcpServer.enabled - the outward MCP server URL and add-to-agent commands |
Where models come from
Built-in AI (builtin: true) | User API keys (byok: true) | |
|---|---|---|
| What it is | Models under YOUR app's name, billed from user credits | Users connect their own provider keys |
| Who pays | The user's credit balance - you pay OpenRouter behind the scenes | The user's provider, directly - credits only for web-tool calls |
| Setup | OPENROUTER_API_KEY in your server env, and nothing else | None on your side |
| Models | The full OpenRouter catalog, live-priced (narrow it with builtinModels) | Every catalog provider, discovered live |
| In the picker | One house pane named after config.siteName, listed first | Vendor panes with per-provider connect and key management |
- Bring your own key covers every model call: chat, compaction summaries, titles and cloud automations. The one exception is a desktop coding-CLI run, which stays on the CLI's own subscription.
- A held key always wins. A built-in pick runs on the workspace's OpenRouter key when there is one, and on credits otherwise. An own-key pick always runs on a stored key and fails with "add your key" when it is gone, never moving onto credits. Scheduled runs read the key when they fire. One decision point (
resolveChatRuninpackages/api/src/ai/byok.ts) serves every lane. - Inside an organization, the owner's keys serve everyone. Keys belong to the workspace's payer, the same person billing charges: in an organization, every member's chats, automations and titles run on the owner's keys, and a member's own keys apply only in their personal workspace. Only the owner sees (last four), replaces or removes them; members' pickers show which providers are connected, and their Models page says the owner manages the keys. Handing the organization over moves the keys with the funding, and a removed member stops using them on their next request.
- Pricing needs no upkeep. Built-in rates come from OpenRouter's live listing (refreshed every 5 minutes, applied at run start) times your
pricingConfig.creditsmarkup. An unpriceable model is rejected, never billed at a guess. - Zero setup for end-users. Set
config.ai.defaults(default and fallback model as plain OpenRouter ids, plus efforts) and a fresh user arrives pre-configured. Their own stored choice always wins. Conversation titles are a separateconfig.ai.titlesdecision, never a user setting.
Metering is structural, not a setting
There is no metered flag and nothing sniffs a provider name: a call draws credits only if the MODEL OBJECT it runs on was built to.
| The rule | How it is enforced |
|---|---|
| A model resolves no credential by default | The resolver takes its credential source as a required argument - no implicit fallback to your server env |
| Exactly one module reaches your operator key | packages/api/src/ai/builtin-model.ts - a guard test fails the build on a second importer |
| A built-in model on your key is always metered | That factory builds the model and wraps it in the credits middleware in ONE expression |
| Every model is built in one place | resolveChatRun; a lint rule in packages/api/eslint.config.mjs refuses the lower-level builders and provider SDKs anywhere else in packages/api/src |
| A user-keyed model is never metered | It resolves unwrapped, with no path to the ledger |
That rule is about MODEL calls. The web tools spend YOUR vendor keys rather than the user's, so their calls are metered on every lane — including user-key runs.
The middleware settles per model call, so every step of an agent loop pays. Before each call it prices that call's own input (prompt plus tool schemas) and refuses it when the balance cannot cover the input plus a short answer, then clamps maxOutputTokens to what is left AFTER the input (and never above the room the context window leaves nor the model's own output limit from the OpenRouter listing; there is no fixed per-step cap, so a model may use its full output when the balance pays for it) - so a dust balance cannot buy a long prompt whose input alone outruns it. A non-empty balance that cannot cover the message in front of it is refused the same way, before any model call: the user is told this message costs more than they have left rather than that a run stopped, since none started.
Running out mid-run stops the run where it is. The user keeps the partial answer plus an error, delivered as a stream error rather than a 402 because the stream had already opened, and every token the provider produced is still billed. An empty balance before the run is a clean 402 with no model call. See credits.
Chat
POST /ai/chat (authenticated, rate-limited) resolves the run mode and calls getMastra().getAgent("assistant").stream(...), passing the resolved model through assistantRunModel(model) (a per-call override plus the request context, so Mastra shapes tool schemas for the provider that actually runs) and the capability tools as a named app toolset while Mastra persists each turn to a memory thread. GET /ai/models feeds the picker.
- Two surfaces, one session. A full
/chatpage and an app-wide dock hand the same conversation back and forth; the empty state offers starter chips (config.ai.chat.suggestions, empty by default). - Reasoning effort is per chat and offers each model's OWN advertised levels; send
reasoningEffortin the request body. Web search is a composer toggle whose request flag is tri-state (see below): omitwebSearchand the server decides (its own tools when an engine is configured, never the provider-native lane), sendtrueto add that lane as the fallback, sendfalsefor no searching at all. - Picking a model sets the account default (
PUT /ai/settings), so the next conversation opens on it. A chat reopened from history keeps the model it was held on. - Switching model or provider keeps the conversation, including a switch to or from a paired CLI. A CLI with no session for the chat reads a bounded summary of earlier turns; a CLI on a device another user owns gets only the newest message.
- A turn typed mid-run is queued, not refused. It is listed above the composer, editable and removable, and sent when the run settles - one per settle, in the order it was typed. A queued turn carries its own attachments, so the composer keeps staging photos, PDFs and text files while the assistant is still answering. Send now on a row stops the running turn (the composer's own Stop, so a metered run settles its partial usage) and sends that one ahead of the rest.
- A failed run parks the queue rather than firing every waiting turn into a dead lane. Submitting again releases the head; Send now releases the row it names.
- A substituted model is never silent. A recoverable failure retries the user's fallback model and names it in the
x-ai-fallback-modelheader, which chat shows inline. - Debits land on finish, with the stream drained server-side so a disconnected client still pays.
Attachments
The composer takes any file the selected target can actually receive, and offers exactly that - the accepted types are derived from the model's declared input modalities, never from a list built into the UI.
| Lane | Photos | PDFs | Text files (code, CSV, config, logs) | Anything else |
|---|---|---|---|---|
| Built-in (house) models | when the catalog declares the image modality | when it declares file | always | no |
| BYOK provider models | when models.dev declares image | when it declares pdf | always | no |
| Desktop local CLIs | yes | yes | always | yes - any file, by path |
- Three delivery mechanisms, chosen by what the file is. A photo or PDF rides the turn as an attachment where the model declares that modality. A text file is not attached at all - it is read into the message as a fenced, filename-labelled block, exactly as a CLI reads a file into its context, which is why it works on every model without any capability. Anything else needs a filesystem, so it reaches only a local CLI.
- Audio and video ride the same path as PDFs wherever a model declares those modalities (roughly a thousand catalog entries do).
- The desktop local lane is the unfloored one. The user is sitting in front of it, so its CLIs keep their file tools and any file at all can be handed over by path - a zip, a spreadsheet, a binary.
- Unknown means yes for photos, no for everything binary. Image input is the norm; PDF and media input are the exception, so offering them on an undeclared model would fail the turn at the provider with the file already uploaded.
| Limit | Photos | PDFs | Text files |
|---|---|---|---|
| Per message | 5 | 3 | no count cap |
| Per file | 2MB (after the optimizer) | 3MB | 64KB |
| Per message, together | 3MB | 3MB | 128KB inlined |
- The text ceiling is a context guard, not just a cost one. Inlined text is billed as ordinary prompt tokens: 3MB of it prices at roughly 786,000 tokens where the same bytes as a PDF reserve 2,000, and it overflows every shipped context window. 64KB is about 16,000 tokens, and the limit is clamped further on a model whose own window is narrow.
- Too large is refused, never truncated. A silently shortened CSV makes the model answer about partial data with nothing saying so. Split the file instead.
- PDFs are never recompressed - a photo is downscaled and re-encoded, but a document has no lossy variant that stays readable.
- A stored attachment is a chip, not a payload. Coding-CLI turns persist only a name and size, so a reopened conversation shows the chip and a replayed turn re-sends nothing. Text files are already part of the message text, so they persist as the message. On the built-in and BYOK lanes an attached turn is stored by Mastra Memory like any other, so a PDF inside the recall window is replayed and billed again, exactly as an attached photo is.
- Billing is the provider's own. No attachment-specific metering: a binary file reserves the same pre-flight allowance an image does, and inlined text is priced as the text it is.
Asking the user a question
The assistant can stop mid-turn and ask, with the answer continuing the same turn. Mastra's ask_user tool suspends the run, the chat renders a card with the options, and answering resumes it - or Skip ends the turn there.
| Where it works | Chat only - the web app, the desktop's cloud lane, and the phone |
| Where it does not | Automated runs |
| Shapes | Option buttons, multi-select plus Send, or a free-text box; a text box always rides alongside the options, and Skip beside Send |
| Cost while waiting | None - the request ends, the run is a snapshot in Postgres |
| Cost of skipping | None - no model call, no credit check, no concurrency slot |
- A suspended run holds nothing open. No stream, no concurrency slot, no tokens. Answering posts
resumeback toPOST /ai/chat, which resumes the run and streams the rest of the turn - billed like any other turn. - An answer or a skip can only reach its own conversation. The client-supplied
runIdis matched against runs suspended on that user's own thread first; anything else is a404. - Only the person answers. A model that writes
resumeDatainto its own tool call (Mastra reads it as an answer) is ignored: the chat model's tool arguments are stripped of it, andask_usertakes an answer only when the user's ownresumerequest recorded it. - Skipping closes the run.
resume: { runId, skip: true }resumes it with{ skipped: true }and stops before the next model step, so the thread records "The user chose not to answer" as the tool's result and the next turn sends a valid history. The card reads as skipped, and so does the question's row in a reopened conversation. - A new message skips a waiting question. Sending instead of answering closes that question first, then runs the turn normally - so a card left open elsewhere cannot resume a run the conversation has moved past. A send refused for credits closes nothing.
- Automations never ask. A headless run has nobody to answer, so the tool is absent there and its prompt clause with it - it answers with a default or says what it would have needed instead.
ask_user: it asks through its own question tool instead, and the desktop app draws that in this same card. The card is read-only wherever a lane cannot answer.Chat titles
A new conversation is named by a model at its first message, in parallel with the answer, so the title lands while the assistant is still writing. Which model is your decision, per lane (config.ai.titles) - never a user setting: set the cheapest sensible model each provider offers.
| Lane | Title model | Who pays |
|---|---|---|
| Built-in chats | config.ai.titles.builtin (an OpenRouter id) | the workspace's OpenRouter key when there is one, else credits under the ai.title ledger feature |
| BYOK chats | config.ai.titles.byok[provider] (that provider's native id); unmapped falls back to builtin | the workspace's key for that provider |
| Desktop coding-CLI chats | config.ai.titles.cli[cliId] ({ model, effort? }) - run THROUGH the same CLI on the user's machine | the user's own CLI subscription |
- Keep every entry cheap. Naming fires on every conversation's first message, abandoned one-liners included. Reasoning is off at the call site; the CLI entries take
effort: "minimal"where the CLI spends reasoning by default. - Remove an entry to keep prefix titles on that lane.
- Once per conversation, never twice. The thread is claimed with the prompt-prefix title before the title model is called, so the early ask closes the gate the settled turn would otherwise name through.
- A rename always wins. Only a title still equal to the auto-derived prompt prefix is replaced, including one renamed while the model is still naming it.
- A title is stored whole, never trimmed to fit: the prompt asks for a short one and the surfaces ellipsize in CSS. A reply past 120 characters is prose, not a name, and is discarded.
- Nothing fails on it, and nothing retries. No title model, an empty balance, a provider failure, or a reply that is not a title all leave the prompt-prefix title in place - and a failed or rejected naming run logs at
warnwith the reply sample, so it is diagnosable.
Synced chat history
Every conversation is a server-side Mastra Memory thread in your own Postgres, so history follows the signed-in user to every device.
- Workspace-scoped. A thread is stamped with the organization and project it was created under, and the list shows only the active workspace's conversations - switching projects switches the visible history (desktop local CLI chats scope the same way on-device). Threads created before scoping appear in every scope until their next message stamps them.
- Ordinary application rows. Guarded by your database access controls, readable by whoever operates the app.
- Reopens as it looked. A thread stores each turn's text, tool calls, attached thumbnails and the model's reasoning, so a restored conversation shows the same collapsed Thoughts boxes it showed live.
- Thumbnails travel, originals do not. Coding-CLI turns store a 256px thumbnail so a re-sent transcript stays inside the append budget; the full-resolution image is archived in the browser's own IndexedDB (64MB, least-recently-used first) purely so the enlarged view is sharp. Opening that chat on another device shows the thumbnail. A PDF stores only a name + size chip - see Attachments.
- Two kinds of stored reasoning. Reasoning the provider streamed is saved by Memory with its thought signature and replayed on later turns, signature intact. Reasoning a client sends up - a compacted transcript, a coding-CLI turn - has no signature, so it is stored as a display-only part that renders but never reaches a model.
- Bounded twice.
config.ai.chats.capthreads per user (default 20, oldest evicted), plusconfig.ai.chats.apiKeyCapthreads started by the user's API keys (default 20, capped apart so a key's chats never evict the user's own), and a long conversation auto-condenses older turns into a recap once it passes 70% of the selected model's own context window. - The composer's meter reads what the runtime measured, not a character-count guess: the tokens the last model step actually carried, cached prompt included, over the window that step ran against. A lane that reports nothing falls back to a ~4-chars-per-token estimate, which is also what the compaction threshold above compares.
- Compact on demand. The chat menu's Compact runs that recap immediately; it is disabled while every turn still fits the window the compaction keeps (the newest 6).
Web search and extract
Set a vendor key and the assistant gets its own web tools, which it calls only when a question needs them. Set none and nothing changes.
| Tool | What it does | Default price per call |
|---|---|---|
web_search | Returns 5 results (max 10): title, URL, snippet, and where the engine reports them, published date and site name | $0 TinyFish, $0.011 Firecrawl, $0.005 Parallel |
web_extract | Reads one page and returns its main content, up to 15,000 characters | $0 TinyFish, $0.0032 Firecrawl, $0.001 Parallel |
Which engine serves a call, per capability and chosen independently:
| Order | Engine | Key | Notes |
|---|---|---|---|
| 1 | TinyFish | TINYFISH_API_KEY | Free at list price, rate-limited instead: 30 searches and 150 pages a minute per API key, shared by every user, so one research loop can hand everyone else a 429 |
| 2 | Firecrawl | FIRECRAWL_API_KEY | The recommended paid engine, and the only one whose terms carry no training license over what you submit |
| 3 | Parallel | PARALLEL_API_KEY | $5 per 1,000 advanced searches and $1 per 1,000 pages; extract is much slower on a page it has not already indexed |
- A key's presence is the switch. No key, no tool - the model is never offered something that would fail. Any one key covers both capabilities.
- Naming an engine wins over the order. Set
config.ai.webTools.searchEngine/extractEngineto"tinyfish","firecrawl"or"parallel"; a named engine whose key is missing ships no tool on that lane rather than routing to another vendor. - What one call costs the user: the vendor price x
credits.creditsPerUsdx thewebSearch/webExtractmarkup, which ships at100(twice cost). Override the vendor price withsearchCostUsd/extractCostUsdonce you know your plan rate, and your number wins on every engine. - TinyFish calls are free end to end. A
$0call debits nothing, writes no ledger row, and is never refused for an empty balance, so an out-of-credits user still gets a searching chat. Set a price on a busy app and it becomes an ordinary priced call. - Where they run: chat (see below), the desktop app-tools lane, automated runs and the external agent. Ledger tags follow the surface:
ai.chat,ai.automation,ai.mcp. - Chat searches by default. A user who has never touched the composer toggle gets these tools whenever an engine is configured, and the model decides per turn - a turn it answers from memory costs nothing extra. Turning the toggle off is remembered and switches them off for that browser. The clients learn whether an engine exists from
webToolsonGET /ai/models, so a deployment with no key shows the toggle OFF rather than promising a search it cannot run. - The provider-native lane still needs an explicit yes, and is the fallback for two cases, both of which start with the user turning the toggle ON: no engine is configured at all, or an engine is configured but this run's tools were withheld (the payer lookup failed - see below). Either way our own tools did not register, so there is nothing to suppress it. OpenRouter bills it per REQUEST (~
$0.0175) whether the model needed it or not, so an unanswered toggle never reaches it, and a run that DID register our tools suppresses it outright. - It researches, it does not just look up. The tool descriptions and a system clause (
aiPrompts.webResearch, injected only on a turn that actually gotweb_search) tell the model to refine its query and search again when the first results do not settle a question, and to read a page when snippets conflict. Expect a hard question to cost several calls and an easy one to cost none. - Billed like model usage, on every lane. Each call is a credits line item (
Web search: 3 results,Web extract: example.com). A repeated search or page is served from a 15-minute cache and costs nothing, as does a call the vendor refused. - User-key runs pay for tools, not for tokens. This is the one place BYOK draws credits: the model call spends the user's own provider key, but a search spends YOURS. A project with credits disabled meters nothing.
- Margin them apart from AI usage with
credits.markupByCategory, which giveswebSearchandwebExtracttheir own rate and leaves the cost honest. Adding a provider is one file implementingWebSearchEngineorWebExtractEngineplus one line inpackages/api/src/ai/web-tools/registry.ts, placed by price: the order is auto-detect precedence.
Weigh the vendors' terms, not just their prices. Searching sends the user's query to whichever engine you configure, and both TinyFish's and Parallel's customer terms include a license to train on submitted content. If that is unacceptable for your users' prompts, set FIRECRAWL_API_KEY and name "firecrawl" on both capabilities rather than relying on auto-detection.
Extracted pages are untrusted input. web_extract refuses any URL carrying a credential (key-shaped, percent-encoded, or in user:pass@), then returns the page fenced in a per-call random marker so page text cannot impersonate your app's instructions. Keep that framing if you edit the tool descriptions. It also refuses non-public addresses and non-http(s) schemes before calling the vendor — that is abuse prevention, not protection of your own network: your server never fetches the page, the extraction provider does, from its infrastructure.
Automated runs
An automation is a saved prompt that fires on a cadence, running the same integration, capability and web tools as chat but headless - same agent (assistant.generate(...), threadless), same model resolution, same metering, so it carries the agent's persona and the paths never drift. Users manage them on /automations as one list, each row badged with its lane; a card opens a detail dialog (prompt, last result, model, device pinning, edit, run-now, delete, and a history modal of the last 25 runs with their outcome and credit cost). They fire on the shared background jobs cron.
| Rule | Detail |
|---|---|
| Cadence | Exactly one of intervalMinutes (the Hourly/Daily/Weekly presets) or cron (0 9 * * 1-5). Editing one clears the other and re-anchors the next fire. |
| First run | One full cadence after creation, never at once - a new hourly automation first fires in an hour. |
| Editing | Name, prompt, model, effort and cadence, from the detail dialog's Edit. A built-in takes no patch but its enable switch. |
| Granularity | Due rows sweep every 5 minutes - the effective floor. |
| Timezone | The zone the create form captured from the author's device; an automation carrying none uses the server's. |
| Ownership | The active organization when the user is in one, else the user. Visibility only - the payer is the workspace owner. |
| Who it runs as | A cloud run acts as its author: their default model, their own connections, the projects they reach. A member who changes a colleague's prompt, model or effort becomes the author; a rename, a new schedule or switching it off keeps the original one. Any member may switch an organization's automation on, reschedule it or run it now. |
| Whose keys | An organization's automations run on its owner's provider keys, whoever wrote, scheduled or started them; a personal one runs on its author's. The key is read at every fire. |
| Author left the organization | An org-owned automation is switched off the moment its author leaves or is removed, and reads Paused: its creator left the workspace. The runner refuses it too (and switches it off) if that ever fails. Nothing is charged. Switching it back on as it is is refused (409, AUTOMATION_AUTHOR_LEFT); a member who rewrites it takes it over, and the pause clears once the author is a member again. |
| Project | Stamped from the active project at creation and never rewritten, so an automation stays where it was authored. An automation created before this shipped carries none and shows in every project until its first edit files it in one. |
| Web research | On whenever a vendor key is set and config.ai.webSearch is not false; there is no per-automation toggle. Calls bill the workspace owner, itemized per call, so a chatty automation's cost is visible on the ledger. A user-key automation still pays for its tools - they spend YOUR vendor key. |
| How many | No ceiling on automations or schedules. Every create and edit spends one per-user write budget (cacheConfig.aiAutomationWriteRateLimit), charged by the automation service, so the routes, API keys and the chat and MCP tools share it; Run now and MCP requests share the AI one (cacheConfig.aiRateLimit). The server time they spend is bounded by the plan's run minutes below. |
| Limits per field and per read | Name 100, prompt 4000, cron 100 and model 256 characters on every lane (@repo/config/ai/automation-cadence). GET /ai/automations serves at most 500 rows per request (?limit= and ?offset=, with nextOffset in the answer), and the web, desktop and mobile screens follow nextOffset to show every row; the list_automations tool returns 50 per call. |
| Scheduling | Every 5 minutes the cron claims the due automations round-robin across payers, each payer's oldest first (up to 500 per sweep, and up to 20 waiting per payer; a payer whose queue is full takes no slot), and queues each as its own run. Each payer's runs go one at a time, at most 20 run at once across the deployment, so one account's backlog never delays another's. A payer with no minutes left is not queued. |
| Run minutes | Every cloud run - scheduled or Run now - reserves its time against the payer plan's automationMinutesPerMonth (plans) for the UTC calendar month and hands back what it did not use, so the month is charged its wall time; omit the field for unlimited. With nothing left, a run is skipped (nothing charged) and the row reads a translated "minutes used up" note. Usage shows on /automations and /settings/billing (GET /ai/automations/minutes). Nothing is counted while config.payment.enabled is false, and a count that cannot be read skips the run. |
| Time limit | One run stops at config.ai.automationRunMinutes (default 4, at most 55), or sooner when the month has less left, and records Stopped at the time limit; tool calls in flight are cancelled. Each run is one request to your backend, so keep the limit under the host's request limit - Vercel's default maxDuration is 300 seconds; raise both together. Containers (Docker, Render, Fly.io, Railway, Coolify) have no such limit. |
| Interrupted runs | A run the host kills (a deploy, a crash, its request limit) is settled by a sweep once its time limit and a 2-minute grace have passed with no sign of it: the month keeps the minutes up to when it was last seen, its slot is released, and it records Interrupted: the run stopped before it finished. |
| Auto-pause | A balance that cannot cover a run, or 3 failed runs in a row that the automation caused (no model, a missing or refused key, a tool or input error, the time limit), switches the automation off and sends its author an automation_paused notification (the payer too, for credits) - once, until it next succeeds. Network errors, provider outages and billing hiccups never count. A success, or switching it back on, resets the streak. A built-in users cannot switch off is never paused: it is skipped with a status instead. |
| At the concurrency cap | Every cloud run holds one of its payer's concurrent-run slots, on a provider key too. A due run WAITS: it defers to the next 5-minute sweep with nothing charged, nothing metered, and no failure on the row. Run now and the chat capability refuse instead of re-arming - a button press must never become a paid run minutes later. See plan limits. |
| End-user authoring | config.ai.userAutomations: false hides the create form, 403s the create route, and withholds the create_automation capability. Listing, running, editing and deleting stay. |
And, with the desktop app:
| Rule | Detail |
|---|---|
| Lane | cloud (the default, stored as no lane at all) or local-cli - see Lanes. |
Lanes
An automation's lane decides which store owns it, and therefore who fires it, what it costs, and whether it runs with the desktop app closed.
| Lane | Fires | Costs | Runs with the app closed |
|---|---|---|---|
cloud (default) | The sweep, server-side | Credits, billed to the workspace owner | Yes, always |
local-cli | Each member's own desktop app, on the machines where they turn it on | Nothing - their own CLI subscription | Only if they ship + enable background mode - off by default, so normally no |
The author picks the lane first. The create form leads with it and the model picker then offers only that lane's models, so the two most consequential facts about an automation - what it costs and when it stops firing - are chosen rather than inferred from a model key.
A local-cli row is a definition, not something this backend runs. Both frontends render it as ONE row: on a desktop the row carries that machine's own run state and its switch turns the automation on there, and on the web it states that it runs on the desktops where members turn it on.
| Rule | Detail |
|---|---|
| No model | It stores a name, prompt and cadence only - each member binds their own CLI and model on their own machine. |
| Never swept | listDueAutomations excludes the lane, and the automation runner refuses one that reaches it by any other route, so run-now answers 400. |
| Versioned | Editing the name, prompt or cadence bumps version. Members' desktop apps compare it against the version each machine took, and ask them to review; nothing on their machines changes until they do. |
| Revocable | Disabling it stops anyone new turning it on, and pauses the copies members already run - reaching each machine when its desktop app next opens. |
Turning a definition on is the trust boundary, and there is no role gate on it - any member can author a definition every other member is invited to run on their own filesystem. That is the same "shared means shared" rule the rest of the workspace follows, made safe by CONSENT rather than by permissions: the full prompt is shown first, the choice is explicit and per-machine, and each fire is still judged against that member's own folder grant.
Built-in automations
Declare automations once in packages/config/src/ai/automations.ts (@repo/config/ai/automations) and every user gets them. The registry is aiAutomations, an array of AiAutomationSpec; the boilerplate ships one disabled example.
| Field | Purpose |
|---|---|
id | Stable registry id; row ids derive from it, so never reuse one. |
name | Display name in the automations UI. |
description | Optional line shown under the name. |
prompt | The prompt the run executes. Hidden from end-users by default. |
promptVisible | true shows the prompt on the card (default false). |
intervalMinutes | Fixed cadence in minutes. Set this or cron, never both. |
cron | Cron cadence: server time on web (a spec carries no timezone), the device's timezone on desktop. |
enabled | Whether it starts on - ship toggleable examples false. |
toggleable | false FORCES the registry's enabled state: the switch disappears and both surfaces refuse enabled edits (default true). |
hidden | true keeps it off the automations page while it still runs; requires enabled: true, toggleable: false (default false). |
surfaces | ("web" | "desktop")[] - which surfaces materialize it (omitted means both). |
defaults | The model each surface PINS it to: web { model, effort }, desktop { cli, model }. Only for a surface it targets. |
- Materialization. Each spec becomes one row per USER with a deterministic id
builtin:<id>:<userId>- written when that user first lists their personal automations and, for an enabled spec, by the sweep, so an always-on automation fires even for a user who never opens the page. Never per organization: a second copy would run and bill the same built-in twice per cadence. - Registry-owned. Users may toggle a built-in (unless
toggleable: false) but never edit it, delete it, or change its model. The store enforces every lock, not just the UI: a hidden prompt serves asnull, a forcedenabledis clamped on read and re-forced each sweep, and a pinned model is rewritten back on drift. hiddenis a list decision only. The row still materializes, still fires, and stays reachable by id (run history, Run now); it is dropped where the list is served, so use it for background work the user has no decision to make about - which is why it must be forced on.- Source of truth after deployment. The sweep re-aligns rows to the current specs each tick, so edits propagate within minutes and removing an entry retires its rows. Changing an
idretires and re-creates - treat ids as permanent. - Validated at startup. A broken cadence, a non-kebab-case or duplicate id, an incoherent
hiddenentry, ordefaultsfor a surface the entry does not target fails the app at boot, naming it.
Capabilities
Capabilities are the tools the assistant uses to operate the app, so a user can ask for a thing instead of clicking through the UI. The shipped set manages automations and integrations and reads the acting user's own account - their profile, plan and credit balance, and model preferences; every call is scoped to the acting user and audited, and runs without a prompt unless it needs approval. Extend it with defineCapability({ name, description, inputSchema, execute }) in a domain file under packages/api/src/ai/capabilities/, spread into APP_CAPABILITIES (packages/api/src/ai/app-capabilities.ts) - it reaches chat and automated runs at once.
Approve before it runs
A capability that deletes data or spends money declares needsApproval; the chat then pauses on an Approve / Deny card and runs the call only after the click. The shipped delete_automation and run_automation (spends credits) do in the in-app chat - an outside agent over MCP runs them directly, as an API key may - and disconnect_integration (deletes stored credentials) does everywhere, as does every integration MCP tool declared destructive. Reversible edits (update_automation, the model settings) run unprompted.
defineCapability<{ id: string }>({
name: "delete_record",
needsApproval: true, // or (input, ctx) => boolean | Promise<boolean>, per call
approvalPreview: async ({ id }, ctx) => {
const row = await findRecord(ctx.userId, id); // resolve on the SERVER
if (!row) return { items: [], refused: true };
return { items: [{ label: "Delete record", target: { id: row.id, label: row.name } }] };
},
// ...description, inputSchema, execute
});| Guarantee | How |
|---|---|
| The model cannot approve | The click is recorded server-side by the route (resume: { approved, digest }), never read from tool arguments |
| A click covers one call | Bound to user + thread + call id + a fingerprint of the canonical input, resolved targets and current values; one-shot, 5-minute expiry |
| What runs is what was shown | Re-read at execute time; a changed input, target or current value is refused with approval_changed |
| Only the owner decides | The run must be suspended on the caller's own thread and the call must really wait on approval, or 404 |
| The card cannot be spoofed | Drawn only from server values: registry tool name, fixed labels, the whole canonical input, resolved target, values read now; hidden characters shown as [U+XXXX], nothing truncated; an input with a __proto__, constructor or prototype key is refused |
| Other surfaces refuse | MCP, a paired CLI, automations, the phone and any chat sent with an API key answer approval_required_in_app; nothing runs |
| One policy, one switch | Capabilities and destructive integration tools share one decision. A project's Let AI make changes without asking setting (Projects) lifts the card for every chat and automation run in that project (MCP clients work in no project, so never); each such call is written to the audit log (ai.change_without_approval) first, and when that write fails the card is shown instead |
execute knows | The context execute receives carries approved: true when that call needed approval as it ran and got it, so a rule whose answer can change (a budget) need not decide twice |
| Only a person approves | An Approve or Deny sent with an API key answers 403 approval_requires_signed_in_session before anything is read or spent |
- A rule may read the run context.
needsApprovalas a function gets the input and the run context and may be async; it is asked again when the call runs, and a rule that throws asks. - Write
approvalPreviewfrom the server's state. Resolve each target yourself, readbeforefrom it, and keeplabela fixed string. One target twice, more than 10 changes or an over-long value is offered as Deny only. - A new message cancels a waiting card. The call does not run, and the model is told it was not approved.
- The card settles only once the server takes the click. A call that changed since it was drawn (
409 approval_stale) offers Deny only; a busy run slot (429/503) says so and stays answerable. - Clients opt in. The web and desktop apps send
approvals: true; a chat client that draws no card gets an immediate refusal instead of a run parked on an invisible card.
Name a capability like an identifier - letters, digits, _ or -. Names LEAVE your server: a user's own coding CLI joins them into one comma-separated allowlist it reads as permission rules, so list_users,Bash would pre-approve unprompted shell execution on that machine. defineCapability throws at the declaration site, so a bad name fails your build.
The model-primitive toolkit: @repo/ai
@repo/ai re-exports the AI SDK v6 core (ai@^6) provider-agnostically - the model primitives Mastra itself runs on, and your toolkit for a bespoke endpoint or a custom agent tool.
| 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
Your own endpoint gets BYOK and metering for free by resolving its model through the same seam the shipped surfaces use.
| What you write | What the run bills |
|---|---|
resolveChatRun(...) - packages/api/src/ai/byok.ts | The workspace payer's key when they hold one for the pick, credits otherwise. Every shipped lane takes this path |
builtinModel(...) - packages/api/src/ai/builtin-model.ts | Refused by lint outside byok.ts: it is the metered half of resolveChatRun and never consults stored keys |
openai("gpt-5.5"), anthropic(...), or any provider SDK model | Refused by lint in packages/api/src: it would skip both the user's key and the ledger |
Build 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,
organizationId: session.session.activeOrganizationId ?? null,
runId: crypto.randomUUID(),
feature: "ai.my_surface",
resolveBilling: () => Billing(session)
});
const result = streamText({
model: resolved.model,
messages: await convertToModelMessages(uiMessages)
});
return result.toUIMessageStreamResponse();Work no user is behind, such as pnpm translate running from a git hook on your own key, lives in its own package outside that rule.
Never expose a provider key to the client. 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.enabledinpackages/config/src/index.ts- see configuration. With itfalse,/ai/*returns 400 (aiEnabledGuard) and no AI UI renders. - Credits or BYOK?
builtinto resell AI with a markup,byokto let users pay their provider directly, both to offer either. These are the web account's keys - keys added in the desktop app stay encrypted on that device. - Where do model calls run? Always on the shared backend, so no key reaches the browser. Streaming and client hooks for your own endpoints are framework-specific - see data fetching.
- How do I upgrade Mastra? Stay on the pinned
@mastra/*1.x line: run Mastra's codemod for a breaking change, then the full test suite.
Agents
Author the assistant agent: persona, tools, MCP clients, skills.
AI skills
One folder of instructions your AI loads on demand, everywhere it runs.
Configuration
The config.ai fields and every other feature flag.
AI prompts
Re-voice the prompts the product injects, from one module.
AI integrations
Third-party services the assistant can act on for a user.
Credits
The balance built-in AI runs are billed from.
Background jobs
The cron that fires automated AI runs.
External Agent (MCP)
Expose these capabilities to an external agent over MCP.
API
The shared Hono backend the /ai/* routes mount on.
Editorial checklist
What the schema refuses, what the template guarantees, and what is left for you to check before an alternatives roundup, a compare pair page or an article goes live.
Agents
Author, rename, and extend the assistant - a Mastra agent registered in code, with AI SDK tools, MCP clients, inline skills, and an editable persona.