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 |
- The pane the user picks is the billing choice, encoded in the selection key: a built-in pick always draws credits, an own-key pick always runs on their key and fails with "add your key" when it is gone. One decision point (
resolveChatRuninpackages/api/src/ai/byok.ts) serves chat and cloud automations. - 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 is always metered | That factory builds the model and wraps it in the credits middleware in ONE expression |
| 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 - 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 as a per-call override 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. - 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) | your 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 user's own key |
| 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), 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. |
| Author left the organization | An org-owned automation stops firing and records The author of this automation is no longer a member of the organization on the row. Nothing is charged and the schedule is untouched, so re-adding the author resumes it. |
| 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 | A fixed 100 per workspace, the same on every plan - an anti-abuse bound, not a tier feature, so nothing counts it on screen and no upgrade moves it. One creation choke point, so form, chat capability and MCP tool all refuse with 403 ABUSE_LIMIT_REACHED. Counted per workspace across ALL its projects, never per project. Built-ins never count. |
| At the concurrency cap | 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 runs with no approval prompt, scoped to the acting user, and audited. 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.
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 metering for free if it resolves its model through the same seam the shipped surfaces use, and nothing if it builds a provider model itself.
| 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 automated 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 |
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,
runId: crypto.randomUUID(),
feature: "ai.my_surface",
resolveBilling: () => Billing(session)
});
const result = streamText({
model: resolved.model,
messages: await convertToModelMessages(uiMessages)
});
return result.toUIMessageStreamResponse();Reach for a provider SDK directly in exactly one case: work you pay for on your own key with no user-facing meter, such as a nightly digest or a moderation pass.
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.