# Approvals

> How the moatbox gateway holds risky agent actions, how you approve, deny or undo them, and where each decision is recorded.

moatbox runs coding agents in spaces: microVMs with their own disk, on moatbox servers or on a server your team links. A gateway inside each space stands between the agent and the outside services it calls, and it can hold a risky action until a person on your team decides. This page covers which actions the gateway holds, the rules and strategies behind each hold, where you approve, deny or undo, what the agent sees while it waits, and the audit trail.

## What the gateway holds

| Action | Held when |
|---|---|
| HTTPS calls to a connected integration (GitHub and the other services on the Integrations page) | The integration classifies the request as risky, or your rule says to hold it. See [Integrations](https://docs.moatbox.dev/integrations.md) for each service's list. |
| An integration request with a body over 1 MiB | The gateway cannot inspect the body. A request that would otherwise go through, or that has no undo, waits as `<integration>.uninspected` with the `approve-only` strategy. |
| MCP tool calls | The tool's name matches one of the `hold` patterns you pass when you start a run, such as `"hold": ["linear.*"]`. See [MCP server](https://docs.moatbox.dev/mcp.md). |
| Browser actions in a desktop run | A navigation, click or submit whose URL, button text or form action names payment, checkout, account, password, send, delete, publish, deploy and similar words. |
| Deploys from a run | A first public deploy of a name, a deploy of a name with no live release, or a deploy that replaces a release another run made. A new database from a run is held every time. |

GitHub calls from SSH shells in the space follow the same rules. In the record, those calls end with "by the SSH workspace". SSH shells get no other integration.

Nothing else waits for a person. Connections to hosts outside the space's network allowlist are refused and logged, and you can allow the host from that log entry.

## Strategies

Each held action carries a strategy. The strategy decides what approval does and whether you can undo it later. The dashboard shows the label from this table on every held action.

| Strategy | Dashboard label | What happens |
|---|---|---|
| `pass` | (recorded as "Allowed by policy") | The gateway forwards the request at once and records it. |
| `approve-only` | Can't be undone | Waits for a decision. Nothing undoes it once it runs. |
| `checkpoint` | Snapshot first, can be undone | Waits for a decision. After approval the gateway saves the state the action will change, then sends it. Undo restores that state. |
| `compensate` | Can be undone by a reverse action | Waits for a decision. Undo runs the reverse action, such as pushing a ref back to its old commit. |
| `hold` | Delayed, can be cancelled | Waits for a decision. With a window, nobody has to act: the request goes out when the window ends unless someone denies it first, and Approve sends it straight away. |

App, function and static-site deploys use `compensate`. Undo switches the app back to its previous release, and takes the app offline when it had no earlier release. You can undo only the live release, so undo newer deploys first. A new database has no undo.

## Rules

A rule sets how strict the gateway is with one integration. Your team has three modes:

| Dashboard label | API value | Effect |
|---|---|---|
| Hold risky actions | `destructive` | The default. The integration's own classification decides what waits. |
| Hold every action | `hold_all` | Requests the integration does not classify go through when they are `GET`, `HEAD` or `OPTIONS`. Every other request waits for a decision. A delayed send loses its window, and the summary gains "(team rule: hold every action)". |
| Auto-approve listed actions | `auto` | Action kinds you list go through without asking, as long as their strategy is `hold` or `approve-only`. Listed kinds with `checkpoint` or `compensate` still wait. Everything else is held as under the default. |

With no rule saved, an integration uses `destructive`.

### Edit rules in the dashboard

1. Open **Integrations** and click the service.
2. Under **Default rule** ("Keys without a rule of their own follow this one."), pick a mode.
3. For **Auto-approve listed actions**, type kinds such as `github.write` under "Actions that go through without asking" and click **Add**. The editor suggests kinds it has seen in one of your running spaces. Click **Save rule**.

Each key has its own rule as well. Under the key, next to **Rule:**, click **Change** and pick **Follow the default** or one of the three modes. A key's own rule applies wherever that key is used and overrides the default rule.

Any team member can make a rule stricter. Only owners and admins can loosen one: the dashboard greys out the looser options for members ("Owners and admins can loosen this rule").

moatbox sends the saved rule to every running space. When a space has not taken it yet, the dashboard says "Saved. Reaches <space> once it answers." and moatbox keeps retrying every few seconds. A space that cannot read its rule file holds every action until moatbox sends the rules again.

### Rules API

An API key needs the `team.read` action to list rules and `team.admin` to change them. See [API keys](https://docs.moatbox.dev/api-keys.md).

| Method and path | What it does |
|---|---|
| `GET /api/v1/teams/{team}/rules` | Lists the default rule for each integration, with who changed it last. |
| `PUT /api/v1/teams/{team}/rules/{integration}` | Sets the default rule. |
| `DELETE /api/v1/teams/{team}/rules/{integration}` | Resets the default rule to `destructive`. |
| `PUT /api/v1/teams/{team}/integrations/{integration}/rule[?space={slug}]` | Sets the rule of the team key, or of the space's own key with `?space=`. |
| `DELETE /api/v1/teams/{team}/integrations/{integration}/rule[?space={slug}]` | Makes that key follow the default rule again. |

The body of a `PUT`:

```json
{"mode": "auto", "auto": ["github.write"]}
```

`mode` is `hold_all`, `destructive` or `auto`. `auto` takes up to 64 kinds, each starting with the integration's name and a dot. The response lists in `pending` the spaces that have not taken the rule yet.

## Approve or deny

Only team owners and admins can approve, deny or undo. Anyone else gets `403 <email> is not an approver for this team`. If the space is asleep when you decide, moatbox wakes it first.

### Dashboard

Open the space and go to **Approvals**. The nav item shows a count of waiting actions. Each card shows "Waiting · #<id>", the strategy label, the action, the statement or request, who asked (the agent and run), the run's task and when the gateway held it. Click **Approve** or **Deny**.

Held actions also appear inline in the run's conversation view, with the same two buttons. While a run waits, the space's **Agents** page marks it "Waiting for you", and the **Live** page shows "Waiting on approval" with a **Review #<id>** link to the run. Below the waiting cards, "Decided recently" lists the last ten decisions, and **Everything in Audit** opens the full log.

### Phone and browser push

Under **Settings** → "Approvals where you are", click **Turn on push** for each device. On iPhone, add the app to your Home Screen first. Owners and admins get a notification titled "moatbox: approval needed" with **Approve** and **Deny** actions. Tapping the notification itself opens the space's Approvals page.

### Slack and Discord

Slack and Discord approvals need a paid plan, and only owners and admins can set them up. You bring your own Slack app or Discord application.

Slack, under **Settings** → **Connect Slack**:

1. Set your Slack app's Interactivity Request URL to the address the form shows: `https://app.moatbox.dev/api/v1/slack/interactions/<team-id>`.
2. Give the bot the `chat:write` scope and invite it to the channel.
3. Enter the **Bot token** (`xoxb-...`), the **Signing secret** and the **Channel ID**, then **Save**.

Discord, under **Settings** → **Connect Discord**:

1. Set the application's Interactions Endpoint URL to `https://app.moatbox.dev/api/v1/discord/interactions/<team-id>`.
2. Add the bot to the channel's server.
3. Enter the **Application ID**, the **Public key** (hex), the **Bot token** and the **Channel ID**, then **Save**.

For each held action, moatbox posts one message to the channel: "Held #<id> in <space>: <kind>", the statement or request (cut at 500 characters), a link to the Approvals page, and **Approve** and **Deny** buttons. A decision replaces the Slack message with a line such as `my-space: #12 approved by ada@example.com via slack`. In Discord, moatbox appends that line to the message and removes the buttons. When moatbox refuses a press before deciding, such as one from an unlinked account, the reason comes back as a reply only you can see.

Buttons act as the moatbox account linked to your chat account, so link it once:

1. Press **Approve** or **Deny**. The private reply gives you your account ID and a link to `https://app.moatbox.dev/api/v1/teams/<team-id>/links/<slack|discord>`.
2. Open that link while signed in to moatbox, enter the account ID and click **Request a link**. The request expires after 15 minutes.
3. Press the button in chat again. The private reply now holds a confirmation link.
4. Open it while signed in as the same user and click **Confirm this chat account**.
5. Press the button in chat once more to decide.

moatbox refuses a press on a message it did not send or on a message someone edited, with "That button is not one moatbox sent."

### API

```bash
curl -X POST https://app.moatbox.dev/api/v1/spaces/my-space/entries/12/approve \
  -H "Authorization: Bearer mbx_xxx"
```

```json
{"message": "#12 approved"}
```

| Method and path | What it does |
|---|---|
| `GET /api/v1/spaces/{space}/pending` | Lists actions waiting for a decision. |
| `POST /api/v1/spaces/{space}/entries/{id}/approve` | Approves a held action. |
| `POST /api/v1/spaces/{space}/entries/{id}/deny` | Denies a held action. |
| `POST /api/v1/spaces/{space}/entries/{id}/undo` | Undoes an executed action that supports undo. |
| `GET /api/v1/spaces/{space}/entries` | Lists the space's log; filters below. |

An API key needs the `approve` permission, and only owners and admins can create such a key. See [API keys](https://docs.moatbox.dev/api-keys.md). The hosted MCP server offers `list_pending_approvals` and no tool that decides. See [MCP server](https://docs.moatbox.dev/mcp.md).

`approval.requested` and `approval.decided` events let your own systems react to holds. See [Events, webhooks and telemetry](https://docs.moatbox.dev/events-and-webhooks.md).

### Timeouts

A held action waits five minutes. With no decision by then, the gateway denies it and records "(no decision within 5m0s)". The dashboard shows that outcome as **Expired**. A `hold` action with a window does the reverse: it goes out when the window ends, recorded as approved by "hold window expiry".

If the agent's connection closes while the action waits, the gateway marks it **Abandoned**, and it never runs. Approving it afterwards fails with "its client disconnected while it was held, so it will never run".

## What the agent sees

The agent's request blocks until someone decides or the hold times out.

| Action | On approval | On deny or timeout |
|---|---|---|
| Integration call | The service's real response, plus a `Moatbox-Hold: approved #12 by <who>` header | `403` with a plain-text body such as `moatbox: #12 was denied (no decision within 5m0s)` |
| MCP tool call | The tool's result, with that notice appended as text | A tool result marked as an error, with the same message as text |

If an approved action fails when it runs, the agent gets the failure and the record shows the action as failed.

## Undo

You can undo an executed action when its strategy is `checkpoint` or `compensate`, or when it is the live release of an app, function or static site. Undo refuses an `approve-only` action and any action whose undo state moatbox could not record.

Run undo through the API with `POST /api/v1/spaces/{space}/entries/{id}/undo`.

An integration's undo can fail when someone changed the same thing after the agent. GitHub's ref restore names the commit the agent pushed, so GitHub refuses the restore if anyone pushed since.

## Audit trail

moatbox keeps two records.

**The space's log** lives in the space and records each action the gateway held or classified, plus refused connections: what it was, the strategy, the target, the decision, who decided and the reason. Open the space and go to **Audit** ("This space"). Filter by time frame, **Who**, **Run**, **Decision** (Any decision, Approved, Denied, Waiting, Allowed by policy, Undone) and **Integration**, then **Export CSV**. moatbox reads this log from the running space, so an asleep space shows only the team's record of it. The API equivalent is `GET /api/v1/spaces/{space}/entries`, with `decision`, `integration`, `run`, `before` and `n` (up to 200) as query parameters.

**The team log** lives in moatbox and keeps who did what across all spaces. Go to **Audit** → **Whole team** and pick **Approvals** or **Integration rules** in the event filter. It records:

- `approval.held`: the gateway held an action
- `approval.approved`, `approval.refused`, `approval.undone`, `approval.abandoned`: the outcome, with the person and the channel: `web` (dashboard and push), `cli` (API key), `slack` or `discord`
- `approval.attempt`: a decision that did not go through, such as one from an account without approver rights or an unlinked chat account
- `rule.changed`: a rule change, with the new mode and auto-approved kinds

Owners and admins see every event. Members see events tied to a space and their own. Through the API, use `GET /api/v1/teams/{team}/audit` with `space`, `actor`, `kind`, `since`, `until`, `before` and `limit` (up to 200), or `format=csv` for up to 10,000 rows. moatbox keeps the team log while the team exists, including under zero data retention. See [Privacy and data retention](https://docs.moatbox.dev/privacy.md).
