GenerateSaaS

Your Agent on a Runner

A runner is a third model source alongside built-in AI and user keys - what it can run, the one call it cannot, and the single constraint your own agent tools must satisfy to reach a device.

A paired runner is a third model source, resolved by the same decision point as built-in AI and user API keys (resolveChatRun in packages/api/src/ai/byok.ts). The user picks their CLI in the model picker and the run streams from their own machine on their own subscription.

SourceWhose credentialDraws credits
Built-in AIYour operator keyYes - the only source that bills
User API key (BYOK)The user's provider keyNo - they pay their provider directly
RunnerThe user's own coding-CLI subscriptionNo - their subscription executes the run

A runner's MODEL calls are never metered by construction, not by a setting: it resolves to an unwrapped model at zero rates, with no path to the ledger - see metering is structural. Its web-tool calls ARE metered, on this lane like every other: a search spends your search vendor's key (Firecrawl, Parallel or TinyFish) rather than the user's machine, at the engine's price - nothing on a free engine unless you set one. It behaves like any other provider besides, with per-run knobs on providerOptions.runner and the resumed session id on the result's provider metadata.

What a runner model can run

CallOn a runner
streamTextWorks - the adapter maps the daemon's frames to stream parts as they arrive
generateTextWorks - drains that same stream into a one-shot result
generateObjectThrows UnsupportedFunctionalityError, naming responseFormat.type = json

generateObject is a permanent capability gap, not a TODO: a coding CLI answers in prose and offers no constrained decoding, so there is nothing to hand a JSON schema to.

How a runner differs from a cloud provider

A runner model is a real LanguageModelV4 running through the same Mastra agent, so most things are identical. The table says exactly which are not.

Built-in / BYOKRunner
Model resolution seamresolveChatRunsame
Memory threads, conversation historyyessame
Your agent's persona (instructions)yessame, on chat AND automations
Your agent's own toolsyeschat yes, automations not yet
Per-run knobs (effort, resumed session)providerOptionssame channel
Meteringbuilt-in bills credits; BYOK never doesnever - the user's own subscription pays
Balance gating and output clampingbuilt-in onlynever
Where the agent loop runsyour serverthe user's machine
Structured output (generateObject)yesnot supported
Offlinenot a staterefuses an interactive turn

Two of those rows have consequences worth spelling out:

  • The loop runs on the device. The CLI decides for itself when to call a tool, so your server sees one step, not a step per tool call: stopWhen: stepCountIs(n), onStepFinish, and anything else hooking Mastra's step lifecycle will not fire per tool call. Your tools still run - the device calls back over loopback MCP - but the orchestration is the CLI's.
  • Offline splits by who is waiting. An interactive turn refuses with a 409 rather than quietly running on a billable cloud model. An AUTOMATION makes the opposite call: nobody is watching, so it uses your configured fallback model, and fails cleanly with a recorded error when there is none.

Conversation continuity on a runner

Every run reports the CLI's own session id back and the next turn replays it, so the CLI resumes instead of starting cold. Where that handle is stored decides whether reopening an old chat resumes or starts over.

  • On this lane, the handle rides your thread. The id is written into the chat thread's metadata alongside the CLI and device that minted it, so reopening a thread days later resumes the same CLI session.
  • On the dashboard's direct-dispatch runner chat, it does not. That view holds the handle in memory only; POST /runner/dispatch accepts a conversationId but persists nothing, so reopening a stored chat there starts a fresh CLI session with the messages intact.
  • The handle belongs to one device AND one CLI. Both surfaces check both axes and start fresh on a mismatch rather than replay a foreign handle - Codex hard-fails on an unknown session id, so retargeting costs one turn of context where replaying would cost the turn itself.
  • You wire none of it. If you dispatch runs yourself, the id comes back on the result's provider metadata and goes out as the next dispatch's conversationId.

Automated runs on a runner

An automation pinned to a runner CLI dispatches straight to the device rather than running the agent loop, so the cron is not held open for the length of a CLI run.

  • It carries your agent's persona, exactly as chat and cloud automations do.
  • It finalizes through the same finalizeAutomationRun, so lastRunAt, lastResult, lastError and the history row match in shape.
  • It settles when the device reports back - the row is written when the daemon posts its terminal frame, not inline.
  • Offline with no fallback model, it fails immediately with a recorded error rather than retrying indefinitely.

Your own agent tools reach the device

Tools you declare on the assistant agent (new Agent({ tools: { myTool } })) work on a runner CHAT run. The run advertises them over the device's loopback MCP, and your backend resolves each call server-side under the verified user, so the tool's secrets never leave your server. An app capability wins a name collision (refused outright at dispatch), and a run may only call what its dispatch advertised.

execute must be reconstructible from the agent definition. The device posts the call back over HTTP long after the dispatching request ended, possibly into a different serverless instance, so the closure the tool was declared in is gone and the agent's tools are rebuilt from scratch. Whatever execute needs must come from its own arguments plus the run's persisted scope (owner, surface, org).

This works - everything it needs arrives in its arguments:

const saveAudit = tool({
  description: "Save a completed site audit.",
  inputSchema: z.object({ url: z.string(), score: z.number() }),
  execute: async ({ url, score }) => db.insert(audits).values({ url, score })
});

This does not, though it works fine on built-in AI and BYOK, where execute runs inside the request that declared it:

new Agent({
  // A dynamic `tools` function is re-resolved on the device's callback with a FRESH,
  // EMPTY request context - so `tenantId` is undefined and the captured client is gone.
  tools: ({ requestContext }) => {
    const client = clientFor(requestContext.get("tenantId"));
    return { saveAudit: tool({ /* ... */ execute: async (args) => client.save(args) }) };
  }
});

Three limits worth knowing:

  • Runner-pinned AUTOMATIONS get no agent tools yet - they advertise the app capability set alone, so use a capability if an automated runner run needs one.
  • The default assistant agent only. Mastra never tells a model which agent invoked it, so put device-bound tools on the assistant.
  • Memory-managed tools are not agent tools. With working memory on, updateWorkingMemory is advertised to the device and answered Unknown tool, which the CLI recovers from.

On this page