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.
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.
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
- Actions written with
action()(MCP server and client). aiconfigured, if the agent calls a model.
1. See who did what
When an agent acts, every audit event for an action or tool call carries the agent and the person:
{ 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
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| Key | What it does |
|---|---|
budget.spend | The most the agent may spend. Checked before each call; at the budget the call fails with a clear error. |
budget.calls | The most calls the agent may make. |
approve.risk | A step at this risk waits for a person. |
approve.spend-over | A step that moves more money than this waits for a person. |
enabled | false 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:
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:
action("credits.grant", { risk: "write", spend: ({ input }) => input.amount, ... })An action with no spend is held only by risk.
5. Know the four errors
| Code | When | Fix |
|---|---|---|
MAIN-AGENT-0001 | The call names an agent that is not in agents | Fix the name or add the agent |
MAIN-AGENT-0002 | The agent is over budget: at $4.98 of spend: $5/day, a call estimated at $0.10 is refused and nothing reaches the provider | Wait for the day to reset, or raise the budget on purpose |
MAIN-AGENT-0003 | The agent tried to approve its own step | A person approves; an agent never can |
MAIN-AGENT-0004 | The 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 grants | Within budget | Above spend-over | Result |
|---|---|---|---|
$0.50 | yes | no | Runs |
$5 | yes (if the day's budget has room) | yes | Waits 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.
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 });
},
});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:
- The model calls the
orders.refundtool. The budget is checked first; if the bot is over it, the call fails withMAIN-AGENT-0002. - The action's access check runs for user_42. An order that is not theirs gets
MAIN-DATA-0007. - The refund is
destructive, so it matchesapprove.riskand waits, whatever its amount. Nothing runs yet. - A person approves. The approval is its own audit event. An agent approving itself gets
MAIN-AGENT-0003. - The refund runs. The audit event holds
actor: { kind: "agent", id: "support-bot" },on_behalf_of: "user_42"and the surface. - If the bot misbehaves, set
enabled: false. Its next call fails withMAIN-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
npx mainsdk check --json --agentYou 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
| Code | Cause | Fix |
|---|---|---|
MAIN-AGENT-0001 | The call names an unknown agent | Fix the name or add it under agents |
MAIN-AGENT-0002 | Over budget | Wait, or raise budget on purpose |
MAIN-AGENT-0003 | An agent approved its own step | Have a person approve |
MAIN-AGENT-0004 | enabled: false | Turn it back on when a person decides |
MAIN-AI-0010 | ai.tool() was given a function that is not an action() | Declare it with action() |
MAIN-DATA-0007 | The user does not own the record | Scope with db.<model>.for(user) |
For agents
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?