MainSDK
Search⌘K
Docs

Agents with an identity, a budget and approvals

Give an app agent an audit identity, a spend budget, human approval for risky steps and a kill switch, and see a support bot refund an order end to end.

Building for 0.3

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

An app agent runs inside your app and acts for a user. MainSDK makes every step it takes attributable to the agent and to the person it acts for, gives it a budget, makes a person approve risky steps, and lets you switch it off. This page takes about ten minutes.

This is planned for milestone 0.3 (spec agent-governance, stability experimental) and is pre-release: the YAML and error codes below are the design and do not run yet.

An app agent is not a coding agent. A coding agent builds your app (Build with agents); an app agent is something your app runs.

You need

1. See who did what

When an agent acts, every audit event for an action or tool call carries the agent and the person:

ts
{ actor: { kind: "agent", id: "support-bot" }, on_behalf_of: "user_42", ... }

on_behalf_of is never empty for a user-initiated call. The event also holds the surface (HTTP, MCP and so on). The rest of the fields are in the audit log reference.

2. Configure the agent

main.yaml
agents:
  support-bot:
    budget: { spend: $5/day, calls: 500/day }
    approve: { risk: destructive, spend-over: $1 }
    enabled: true                    # false stops it on the next call
KeyWhat it does
budget.spendThe most the agent may spend. Checked before each call; at the budget the call fails with a clear error.
budget.callsThe most calls the agent may make.
approve.riskA step at this risk waits for a person.
approve.spend-overA step that moves more money than this waits for a person.
enabledfalse stops the agent on its next call, with no deploy. It works offline from local config.

3. Name the agent in the call

A call is charged to an agent's budget when it names the agent:

ts
ai.generate({ ..., agent: "support-bot", user })
mcp.client("docs").call(tool, args, { agent: "support-bot", user })
pay.agent({ agent: "support-bot", amount: "$0.04", to: "weather-api" })

A name that is not under agents fails with MAIN-AGENT-0001. An action reached over MCP takes its agent from the OAuth client of the delegated token, so you do not pass it.

4. Declare what an action spends

spend-over needs a number to compare. An action says what it moves with spend:

ts
action("credits.grant", { risk: "write", spend: ({ input }) => input.amount, ... })

An action with no spend is held only by risk.

5. Know the four errors

CodeWhenFix
MAIN-AGENT-0001The call names an agent that is not in agentsFix the name or add the agent
MAIN-AGENT-0002The agent is over budget: at $4.98 of spend: $5/day, a call estimated at $0.10 is refused and nothing reaches the providerWait for the day to reset, or raise the budget on purpose
MAIN-AGENT-0003The agent tried to approve its own stepA person approves; an agent never can
MAIN-AGENT-0004The agent is switched off (enabled: false)Set enabled: true when a person decides to

6. The approval path, worked through

With spend-over: $1 and credits.grant declaring spend: ({ input }) => input.amount, support-bot has budget left in both cases:

The bot grantsWithin budgetAbove spend-overResult
$0.50yesnoRuns
$5yes (if the day's budget has room)yesWaits for a person

The $5 grant is inside the budget, yet it does not run: it waits until a person approves. The approval is its own audit event. If the bot tries to approve itself, it gets MAIN-AGENT-0003. The same rule holds for money: a pay.agent payment of $2 to a payee with max: $5/call waits for a person when spend-over is $1 (see Web3 and agent payments).

7. End to end: a support bot refunds an order

The support bot answers a customer and can refund an order. The refund is a destructive action. The bot reaches it through ai.tool(), which accepts only actions declared with action() and runs the action's own access check.

app/actions/refund-order.ts
import { action, db, pay } from "mainsdk";
import { s } from "mainsdk/schema";

export const refundOrder = action({
  name: "orders.refund",
  intent: "Refund an order the signed-in user owns",
  input: s.object({ orderId: s.string(), amount: s.string(), reason: s.string() }),   // amount is Money, such as "$12.00"
  risk: "destructive",
  spend: ({ input }) => input.amount,      // what the action moves, compared with budget.spend and approve.spend-over
  run: async ({ input, user }) => {
    const order = await db.orders.for(user).get(input.orderId);
    return pay.refund(order.paymentId, { amount: input.amount, reason: input.reason });
  },
});
app/api/support/route.ts
import { ai, auth } from "mainsdk";
import { refundOrder } from "@/app/actions/refund-order";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const { message } = await req.json();
  return ai.generate({
    model: "anthropic/claude-sonnet-4-5",
    messages: [{ role: "user", content: message }],
    feature: "support-bot",
    agent: "support-bot",
    user: session.user,
    tools: [ai.tool(refundOrder)],
  });
}

What happens when customer user_42 asks for a refund:

  1. The model calls the orders.refund tool. The budget is checked first; if the bot is over it, the call fails with MAIN-AGENT-0002.
  2. The action's access check runs for user_42. An order that is not theirs gets MAIN-DATA-0007.
  3. The refund is destructive, so it matches approve.risk and waits, whatever its amount. Nothing runs yet.
  4. A person approves. The approval is its own audit event. An agent approving itself gets MAIN-AGENT-0003.
  5. The refund runs. The audit event holds actor: { kind: "agent", id: "support-bot" }, on_behalf_of: "user_42" and the surface.
  6. If the bot misbehaves, set enabled: false. Its next call fails with MAIN-AGENT-0004, with no deploy.

Where an approval waits locally (the terminal, a webhook or the dev console) is not decided yet.

Works without Cloud

All of this runs locally with no account and no network: identity in the audit log, budgets, approvals and the kill switch (ADR-0007, ADR-0003). MainSDK Cloud adds shared budgets across apps and a console for approvals. Your app never waits on Cloud for a request. Signed, hardware-bound approvals come later.

Verify

bash
npx mainsdk check --json --agent

You should see "passed": true and exit code 0. Then switch the agent off and make a call: it should fail with MAIN-AGENT-0004.

If it fails

CodeCauseFix
MAIN-AGENT-0001The call names an unknown agentFix the name or add it under agents
MAIN-AGENT-0002Over budgetWait, or raise budget on purpose
MAIN-AGENT-0003An agent approved its own stepHave a person approve
MAIN-AGENT-0004enabled: falseTurn it back on when a person decides
MAIN-AI-0010ai.tool() was given a function that is not an action()Declare it with action()
MAIN-DATA-0007The user does not own the recordScope with db.<model>.for(user)

For agents

text
Goal:    Add or change an app agent without loosening its budget.
Files:   the agent's entry in main.yaml, the actions it may call, the route that runs it.
Verify:  npx mainsdk check --json --agent   (pass = "passed": true and exit code 0)
Rules:   expose only declared actions through ai.tool(); name the agent in every ai, mcp and pay call; keep destructive actions at
         risk "destructive"; never raise a budget, lower approve.* or set enabled: true
         without the developer's say; never let an app agent approve its own step.

Next

Open questions

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet