# Governed writes: approve an agent's API calls from your own tooling

An agent works against your internal API. Reads and low-risk writes run on their own. A refund, which moves money, stops until a person approves **that exact call**. The approval happens in your terminal or your own Slack app, not a vendor console.

```
customer email ─▶ agent ─▶ get_order        (read: runs)
                        ─▶ add_order_note   (autonomous write: runs)
                        ─▶ issue_refund     (approval: held)
                                   │  agent.pending_action.created (signed webhook)
                                   ▼
                     approver.py ─▶ you: approve / decline
                                   │  POST .../pending-actions/{id}/resolve/
                                   ▼
               platform executes the exact held call with the stored credential
```

The agent never holds the Acme Orders token. An approval is single-use and is bound to the call's arguments, credential and integration revision. Declining tells the agent not to make that call.

## Files

| File | What it is |
| --- | --- |
| `orders-api.openapi.json` | The API contract, with governance in the spec: `x-dt-policy` (`read`, `autonomous`, `approval`), `x-dt-tool-name` and `x-dt-redact` (strips `customer.email` from tool results). |
| `orders_api.py` | "Acme Orders", a tiny in-memory stand-in for your internal service. It logs every call, so you can see that a refund lands only after approval. |
| `setup.py` | Setup as code: imports the spec through Remote MCP, publishes it, creates the agent, binds the credential and approver, and runs the readiness preflight. |
| `approver.py` | Your approval tool. It verifies signed webhooks or polls, shows the held call, and resolves it from the terminal or Slack buttons. |

Everything is Python 3.12+ and the standard library. There is nothing to install.

## Before you start

- **API key:** Create an **Organization API key** with the Administrator access profile, as the person who will approve refunds. OpenAPI integrations belong to an Organization. The platform records the key's issuer as the approver, and only a configured approver can resolve the call.
- **Reachability:** The platform must be able to reach Acme Orders.
  - **Deployment:** Expose it at an HTTPS URL that passes destination validation.
  - **Laptop:** With `DT_RELEASE_ENV=local`, add the exact origin to `OPENAPI_LOCAL_HTTP_ORIGINS` (for example `http://127.0.0.1:8090`). Then restart the web process and the agent worker.

```bash
export DT_API_BASE="https://your-deployment.example/api/v1"   # or http://localhost:8000/api/v1
read -r -s -p "Organization API key: " DT_API_KEY; export DT_API_KEY
```

## 1. Run the service you want the agent to use

```bash
export ORDERS_API_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
python3 orders_api.py --port 8090
```

## 2. Import the spec and publish it

```bash
python3 setup.py import --base-url http://127.0.0.1:8090
```

```
Published Acme Orders revision 1
  GET   /orders                      list_orders      read
  GET   /orders/{order_id}           get_order        read
  POST  /orders/{order_id}/notes     add_order_note   autonomous
  POST  /orders/{order_id}/refunds   issue_refund     approval
```

The policies come from the spec, so a change to who may move money is reviewed in the same pull request as the API change. Re-running `import` publishes a new revision, and existing bindings carry forward. A call that is already held stays bound to the revision it was proposed under.

**Browser step:** `import` prints a console link. Open it and add `ORDERS_API_TOKEN` as the integration's bearer credential. Secrets never pass through MCP or this script.

## 3. Create the agent, bind it, activate it

```bash
python3 setup.py agent
python3 setup.py bind
export DT_AGENT_ID=...   # printed by bind
```

`bind` attaches the credential and makes the key's issuer the approver. It then activates the agent and runs `get_agent_readiness`. The preflight checks stored configuration only. Worker execution and network reachability show `unknown` until real work runs.

Some deployments require recent authentication for control changes. In that case the script prints an approval link, waits for you to approve in the browser, and then retries the identical request.

## 4. Start your approver

Pick whichever channel your deployment can reach.

**Webhooks (push).** Expose `approver.py serve` at a public HTTPS URL, using your ingress, reverse proxy or tunnel. Then subscribe:

```bash
python3 approver.py subscribe https://approvals.example.com/dt/events
python3 approver.py serve --port 8787
```

`subscribe` stores the signing secret in `.approver-state/` (mode 600). `serve` does the following:
- verifies `X-DT-Signature` over the raw body;
- records each event in a SQLite inbox before acting, de-duplicating repeats;
- re-reads the current pending action rather than trusting the event snapshot;
- on startup, catches up on anything that arrived while it was down.

Delivery goes out through the platform's egress proxy, so a `localhost` receiver will not get events.

**Polling (no inbound network).** Use this from a laptop, or anywhere the platform cannot reach you:

```bash
python3 approver.py watch
```

**One-off commands:**

```bash
python3 approver.py list
python3 approver.py approve openapi_approval:<uuid>
python3 approver.py decline openapi_approval:<uuid>
```

## 5. Make the agent want to refund something

Email the agent's address from an allowed sender. Alternatively, post the message through the API (`sender` must be allowed by the agent's contact policy):

```bash
curl -X POST "$DT_API_BASE/agents/$DT_AGENT_ID/messages/" \
  -H "X-Api-Key: $DT_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"channel":"email","sender":"you@example.com","subject":"Order A-1042 arrived damaged",
       "body":"My order A-1042 arrived and the stoneware kettle is cracked. The mugs are fine. Can I get a refund for the kettle?"}'
```

The Acme Orders log shows the read and the note immediately, and no refund. Your approver then shows:

```
── Approval needed ─────────────────────────────────────────
  Action       openapi_approval:1cbc9b3f-…
  Integration  Acme Orders
  Call         POST /orders/{order_id}/refunds   (issue_refund)
  Arguments    {
                 "reason": "Kettle (KTL-220) arrived cracked; mugs unaffected. Partial refund for the kettle only.",
                 "order_id": "A-1042",
                 "amount_cents": 8900,
                 "notify_customer": false
               }
  Expires      2026-10-23T12:03:14+00:00
────────────────────────────────────────────────────────────
  Approve this exact call? [a]pprove / [d]ecline / [s]kip:
```

- **Approve:** Acme Orders prints `REFUND ISSUED A-1042 $89.00` and the agent continues with the result.
- **Decline:** the agent is told not to make that call and records why in an order note.

Read the arguments, not just the operation name. The platform does not yet check held arguments against the spec before asking you. If an approved call's arguments break the published schema (an `enum`, say), it fails before anything is sent, but is currently reported as an *uncertain* outcome. Keep approval-required inputs tolerant: `reason` is free text here for that reason. Check the provider before letting the agent propose again.

## Slack buttons (optional)

`serve --slack` posts each held call to a channel with **Approve** and **Decline** buttons. This is your own Slack app calling the platform API. It does not use, or need, any platform Slack integration.

1. Create a Slack app with the `chat:write` bot scope, and install it in your workspace.
2. Enable **Interactivity**, with the request URL `https://approvals.example.com/slack/actions`.
3. Set the following, then run `python3 approver.py serve --slack`:

```bash
export SLACK_BOT_TOKEN=xoxb-...        # from your secret store
export SLACK_SIGNING_SECRET=...
export SLACK_CHANNEL=C0123456789
export SLACK_APPROVER_USER_IDS=U0AAA111   # the key issuer's own Slack user id
```

Clicks are verified with Slack's request signature. Clicks from users not on `SLACK_APPROVER_USER_IDS` are ignored. Keep that list to the key's issuer: the platform attributes every decision to the issuer, so a wider list would let other people approve in that person's name.

## Production notes

- **Retries:** Decisions are single-use. A repeated decision returns `409`, never a second refund. On deployments with extended retry admission, `approver.py` also sends an `Idempotency-Key` for exact replay. Elsewhere it falls back to a plain request.
- **Expiry:** Approvals expire after `OPENAPI_APPROVAL_TTL_HOURS` (24 by default). After an expiry or an uncertain outcome, check Acme Orders before letting the agent propose again.
- **Retired webhooks:** Delivery retries for about 2.5 hours (60 s, 5 min, 30 min, 2 h). If you retire a receiver, reconcile with `approver.py list`.
- **Attribution:** Each decision is recorded against the deciding user, alongside the executed call's outcome.
