# Aperture docs

> A safe way for AI agents to work in web systems that have no API. Every change is previewed, checked, read back, and undoable.

- MCP endpoint: https://calm-steel-d31rh.run.mcp-use.com/mcp (streamable HTTP)
- Server: aperture 0.2.0, 12 tools
- This page: https://getaperture.vercel.app/docs (markdown: https://getaperture.vercel.app/docs.md)
- Error reference on the server: https://calm-steel-d31rh.run.mcp-use.com/errors.md

## What it is for

Aperture is for changing records in a web admin portal or legacy business system that has no API: resident lists, user lists, customer or inventory screens, registrar pages. What it does that driving the page by hand does not: it turns the page into typed tools, shows what a change will affect before it happens (including records that go with it), reads the system back afterwards and compares it with the prediction, gives a receipt, and can undo. Reading is open: compile_site works on any public page without setup. Signing in and changing anything only happen on systems the operator listed, through the owner's authority. Start with find_systems (what you need, in plain words) or compile_site (a page address), then find to get exact keys, then delete or create with dry_run first. Compile every page whose records a change touches. Not for: systems that have a real API, sites you are not allowed to automate, or CAPTCHA and sign-in steps (a person does those).

## Connect

Claude Code:

```
claude mcp add --transport http "aperture" 'https://calm-steel-d31rh.run.mcp-use.com/mcp'
```

Any MCP client (JSON config):

```
{
  "mcpServers": {
    "aperture": {
      "type": "http",
      "url": "https://calm-steel-d31rh.run.mcp-use.com/mcp"
    }
  }
}
```

## How a change works

1. **Identify.** find returns unique keys. Names repeat, so every change takes a key, never a name.
2. **Preview.** A dry run shows what will change, including records that go with it (a resident's devices).
3. **Rules.** Owner limits apply. Inside them it commits; outside them it waits for the owner (awaiting_approval) or refuses with next moves.
4. **Commit.** The change runs once. A page that hangs is replaced after 17 s; the outcome is then read, never retried blindly.
5. **Read back.** The system is read again and compared with the prediction. The receipt says verified, and what was checked.
6. **Undo.** A committed change can be reversed from the saved copy. Fields that could not be put back are listed.

## Rules for agents

- Act on keys from find, never on names.
- Compile every page whose records a change touches (for example residents and devices), or the preview refuses with dependency_not_compiled.
- Prefer apply and apply_batch: they preview, check the rules and commit in one call.
- On awaiting_approval, wait with get_plan(wait_seconds). Do not retry a commit in a loop.
- On policy_denied, call escalate once and continue with other items.
- A receipt with not_yet_live means the change is recorded but not sent anywhere. Only the owner publishes.
- Aperture never types a password and never passes a CAPTCHA. A person does those.

## Tools

| Tool | Effect | Purpose |
| --- | --- | --- |
| `find_systems` | Reads only | Find the systems you may work in, from a plain-words need. |
| `get_instructions` | Reads only | A short guide to the connected system: record kinds and rules. Read it once. |
| `compile_site` | Reads only | Read a page and turn its lists and forms into typed record kinds. Signs in with a stored login when needed. |
| `find` | Reads only | Look up records and get their unique keys. Names are not unique; act on the key. |
| `apply` | Changes records | The normal way to change one record: preview, rules, commit, read-back and receipt in one call. |
| `apply_batch` | Changes records | Many changes in one call, inside the rules. Dry-run shows the whole blast radius; canary checks the first few. |
| `delete` | Changes records | Delete one record by key, in two steps: dry_run to preview, then plan_id to commit. |
| `create` | Changes records | Create one record with a unique key, in two steps: dry_run, then plan_id. |
| `undo` | Changes records | Reverse a committed change from the copy saved before it, and say what could not be put back. |
| `get_plan` | Reads only | Wait for the owner's decision, or find out what really happened when an outcome is unknown. |
| `escalate` | Reads only | Ask the owner for help when a rule blocks you. One message goes out; keep working on other items. |
| `publish` | Reaches outside | Send recorded changes to the outside world (for example, a gate controller). Always needs the owner. Cannot be undone. |

### find_systems

**Reads only.** Start here when you are not given an address. Describe what you need to do in plain words (for example 'remove a resident from the access list' or 'update a customer record'). Returns the systems you are allowed to work in that match, with the address to compile and any profile other agents shared. It never searches the open web.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `need` | string | yes | What you need to do or find, in plain words. |

Example:

```
find_systems({ need: "remove a resident from the gate access system" })

{ "status": "found", "matches": [ { "name": "GateAdmin replica, residents (sign-in required)",
    "address": ".../Residents.aspx",
    "sign_in": "compile_site with login 'REPLICA', url '.../Login.aspx', then '.../Residents.aspx'" } ] }
```

### get_instructions

**Reads only.** Start here. Returns a short guide to this system, the record kinds, and the rules. Read it once.

No parameters.

### compile_site

**Reads only.** Point Aperture at a web page and it reads the page's forms, read-only, and turns each one into a typed record you can fill. Only works on hosts the operator has authorized. It never submits anything and never tries to get past a CAPTCHA: a person does that. Returns each form's id, fields (label, type, required, allowed values) and submit button.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | no | The page address. With login set, this is the sign-in page. Leave it out to read the current page again (after a submit, or when a page changed). |
| `login` | string | no | Name of a login the operator stored on the server. You never see the password. |
| `then` | string | no | With login: the page to compile after signing in. |

Example:

```
compile_site({ url: ".../Login.aspx", login: "REPLICA", then: ".../Residents.aspx" })

{ "status": "compiled", "tables": [ { "id": "residents", "key": "Dir", "rows": 91 },
    { "id": "vendors", "key": "Dir", "rows": 3 } ] }
```

### find

**Reads only.** Look up records. Returns small typed items with their unique keys. Names are not unique: always act on the key. Free, nothing changes.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | yes | Record kind, as compile_site or find_systems named it (for example residents or devices). Call compile_site first if you have none. |
| `query` | string | no | Text to match in any field, like a name. Leave empty to list. |
| `limit` | integer | no | Most items to return. |

Example:

```
find({ kind: "residents", query: "Dev Alder" })

{ "kind": "residents", "count": 2, "note": "Several matches. Names are not unique, use the key.",
  "items": [ { "key": "1004", "Resident": "Dev Alder", "Notes": "Unit 103D" },
             { "key": "1026", "Resident": "Dev Alder", "Notes": "Unit 125B" } ] }
```

### apply

**Changes records.** The normal way to make a change. Previews inside the server. If the rules allow it, it commits at once and returns a receipt with a read-back. If a person must decide, it returns awaiting_approval with a plan_id to poll with get_plan.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `op` | "delete" \| "create" \| "update" \| "publish" | yes | One of delete, create, update, publish. |
| `kind` | string | no | Record kind, as compile_site or find_systems named it. |
| `key` | string | no | The unique key from find. Never a name. |
| `record` | object | no | For create: the fields of the new record, including its key. |
| `patch` | object | no | For update: the fields to change, like { city: 'Leeds' }. |

Example:

```
apply({ op: "delete", kind: "residents", key: "1004" })

{ "status": "done", "receipt": "rcpt_7",
  "verified": { "ok": true, "subject_present": false, "matches_prediction": true,
                "counts_after": { "residents": 90, "devices": 166 } },
  "not_yet_live": "Recorded in this system. It also has a separate step ('Snd Data') that Aperture has not pressed...",
  "undo_until": "2026-10-19T10:10:57Z" }
```

### apply_batch

**Changes records.** Apply many changes in one call. Everything inside the rules commits. Items that need the owner are listed with plan_ids. Refusals are listed with next moves. Stops at the first failed commit. Returns one summary, so you do not need many round trips.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `ops` | object[] | no | Up to 50 operations, each shaped like an apply call. |
| `plan_ids` | string[] | no | Instead of ops: commit plans the owner already approved, in order, with the first `canary` checked first. |
| `stop_on_failure` | boolean | no | Stop at the first failed commit (the default). |
| `dry_run` | boolean | no | Change nothing. Returns the total blast radius: everything that would be removed, dependents included. |
| `canary` | integer | no | Commit only the first N, check they did exactly what was predicted, and halt the rest if any surprised or failed. |

Example:

```
apply_batch({ ops: [ { op: "delete", kind: "residents", key: "1001" }, ... ], dry_run: true })

{ "would_remove": { "residents": 91, "devices": 168 },
  "refused": [ { "key": "1006", "error": "policy_denied", "detail": "Daily delete limit reached: 5 of 5.",
                 "next": ["escalate(reason) to ask the owner for more"] } ] }
```

### delete

**Changes records.** Delete one record by key. With dry_run: true it only previews: what is removed, the count change, which rule applies. Nothing changes. To commit, call again with the plan_id. For most jobs use apply, which does both when the rules allow.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | yes | Record kind, as compile_site or find_systems named it (for example residents or devices). Call compile_site first if you have none. |
| `key` | string | yes | The unique key from find, like S014. Never a name. |
| `dry_run` | boolean | no | Preview only: what would be removed, the count change, and which rule applies. Nothing changes. |
| `plan_id` | string | no | Commit a plan you already previewed. |

Example:

```
delete({ kind: "residents", key: "1004", dry_run: true })

{ "status": "previewed", "plan_id": "pln_23",
  "diff": ["- residents 1004", "- 2 devices",
           "(the devices above go with this residents automatically: do not delete them separately)"] }
```

### create

**Changes records.** Create one record. The record needs a unique key. With dry_run: true it only previews. To commit, call again with the plan_id.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | yes | Record kind, as compile_site or find_systems named it (for example residents or devices). Call compile_site first if you have none. |
| `record` | object | yes | Fields of the new record. Must include a unique key. |
| `dry_run` | boolean | no | Preview only. Nothing changes. |
| `plan_id` | string | no | Commit a plan you already previewed. |

### undo

**Changes records.** Reverse a committed change using the copy saved before it. Reads the system back to verify. Works within the undo window. Published changes cannot be recalled.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `receipt_id` | string | yes | The receipt id from the commit result. |

Example:

```
undo({ receipt_id: "rcpt_9" })

{ "status": "undone", "restored": true, "fields_not_restored": ["Phone"],
  "verified": { "ok": true, "counts_after": { "residents": 91, "devices": 168 } } }
```

### get_plan

**Reads only.** Check a plan: awaiting_approval, approved, expired, committed. Use it to wait for the owner's decision instead of retrying. If the outcome of a change is unknown (the server stopped while it ran), this reads the system and says what really happened; it never runs the change again.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | The plan id from a preview, apply or apply_batch. |
| `wait_seconds` | integer | no | Wait up to this long for the owner to decide, so you do not burn turns polling. |

Example:

```
get_plan({ plan_id: "pln_23", wait_seconds: 10 })

{ "plan_id": "pln_23", "status": "committed", "diff": ["- residents 1020", "- 1 devices"] }
```

### escalate

**Reads only.** Ask the owner for help when a rule blocks you, like the daily limit. One message goes out. Keep working on other items. Do not retry the blocked one.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | yes | One sentence on what you need and why. |

Example:

```
escalate({ reason: "Daily delete limit reached after 5 of 91; need owner approval to continue." })

{ "status": "pending_human", "ticket": "esc_2", "agent_can_continue_with": ["find", "create", "other deletes inside the limit"] }
```

### publish

**Reaches outside.** Send staged changes to the outside world (for example to devices, a gate, or another system). It always needs the owner. Without plan_id it previews and asks the owner. It cannot be undone.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | no | Commit a send the owner has approved. Without it, publish previews and asks the owner. |

Example:

```
publish({})

{ "status": "refused", "error": "send_needs_owner",
  "detail": "3 changes are recorded but not sent. The send step is 'Snd Data' on Accounts.aspx; only the owner sends." }
```

## Errors

Every refusal returns `error`, `detail` and a `next` list of valid next moves.

| error | meaning | next move |
| --- | --- | --- |
| `not_authorized` | A sign-in or change on a system the operator did not list, or an internal address. | Read public pages freely; for changes ask the operator. Never look for a workaround. |
| `human_required` | A CAPTCHA or similar needs a person. | A person completes it, then call compile_site again. |
| `no_stored_login` | The named login does not exist. | Ask the operator to store it. Never type a password into chat. |
| `not_found` | No record with that key. | Use find and pass the key, not the name; see did_you_mean. |
| `already_exists` | A create would duplicate a key. | Use a different key or find the existing record. |
| `dependency_not_compiled` | The change affects a page that has not been compiled. | compile_site that page (find_systems lists it), then try again. |
| `awaiting_approval` | The owner has not approved this change. | get_plan with wait_seconds. Do not retry the commit. |
| `delegation_ended` | The owner's delegation ended before the commit. | Poll get_plan; it now waits for the owner. |
| `changed_since_preview` | The record changed after the preview. | Run a new preview. |
| `capability_changed` | The page changed and Aperture could not prove a re-learned version. | Nothing changed. Do not retry; tell the owner. |
| `policy_denied` | A cap such as deletes per day was reached. | escalate with a reason. |
| `send_needs_owner` | Recorded changes wait for a send step only the owner can take. | Tell the user the change is not live yet. |
| `outcome_unknown` | The server stopped while the change ran. | get_plan reads the system. Never commit it again. |
| `worker_busy` | The browser was busy, or a page hung and was replaced. | Retry a read after a few seconds; for a change, get_plan first. |
| `undo_failed` | The app refused to take the record back. | The saved values are in the response; re-enter by hand and tell the owner. |

A receipt can also say done_with_surprise (the system changed more than predicted; the extra items are listed), failed (the app refused or errored; the real state is reported), or carry fields_not_restored after an undo. verified.scope says which tables were compared; tables Aperture has not compiled are not checked.

## Limits

- One browser per worker, so calls to one system run one at a time.
- Product and search grids are read as read-only items; they cannot be changed.
- Effects in tables Aperture never compiled are not checked (stated in every receipt's scope).
- Sites with bot checks or CAPTCHAs need a person.
