---
title: "Agents with an identity, a budget and approvals"
description: "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."
section: "agents"
status: "Building for 0.3"
spec: "agent-governance"
canonical: "https://mainsdk.dev/docs/agents/governance"
updated: "2026-10-28"
---

# 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: Building for 0.3 · Canonical: https://mainsdk.dev/docs/agents/governance · 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](https://mainsdk.dev/docs/agents)); an app agent is something your app runs.

## You need

- Actions written with `action()` ([MCP server and client](https://mainsdk.dev/docs/agents/mcp)).
- `ai` configured, 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:

```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 [TBD].

## 2. Configure the agent

```yaml title="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
```

| 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:

```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

| 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](https://mainsdk.dev/docs/guides/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.

```ts title="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 });
  },
});
```

```ts title="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 [TBD].

## 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

| 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

```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

- [MCP server and client](https://mainsdk.dev/docs/agents/mcp)
- [Actions and MCP](https://mainsdk.dev/docs/concepts/actions-and-mcp)
- [Build with agents](https://mainsdk.dev/docs/agents)
