# Developers

Rudopo's REST API and MCP server: three ways to connect, three access levels, and what makes a write safe.

Base URL: `https://rudopo.app/api/v1` · MCP server: `https://rudopo.app/api/mcp` · OpenAPI: `https://rudopo.app/developers/reference` · Markdown twins: `https://rudopo.app/developers.md`, `/developers/llms.txt`

## Start here

1. **Create a token.** In Rudopo, open Settings → API and create a token. Choose its access level (Read, Propose changes or Make changes) and which budgets it reaches. It is shown once — we store only a hash, so it cannot be recovered afterwards.
2. **Send it as a bearer token.** Every request carries Authorization: Bearer rudopo_pat_YOUR_TOKEN. A token acts as you and reaches only what you can, narrowed further to the budgets you chose.
3. **Ask for a budget.** Every budget-scoped resource takes a budget_id. Start with /budgets to find yours; a write names its budget the same way.

Tokens are yours alone. A token reaches only what your account can, can be narrowed to specific budgets, and can be revoked from Settings at any time; revocation takes effect on the next request. Its level and its budgets are fixed once made.

## Three ways to connect

1. **A token, for your own scripts.** A personal access token from Settings → API, sent as a bearer token to the REST API. A notebook, a spreadsheet, a cron job: anything that speaks HTTP, with no client library to install.
2. **An assistant that signs in.** Point an assistant that adds remote MCP servers at Rudopo's address and sign in when it asks. It starts at Read over every space you belong to; to let it propose or make changes, you approve it on a Rudopo screen, never in the chat.
3. **The xAI API, with a token.** A model you call yourself over the xAI API can reach the same MCP server with a token in the tool's authorization field. The token's level decides what the model may do.

## A first request

Every write example on this page sends dry_run=true, so pasting one into a terminal changes nothing in a real budget. There is no sandbox space yet; until there is, take the dry run's answer as your first test, then remove the flag and add an Idempotency-Key.

### List transactions

Newest first, a page at a time. Money is a decimal string beside its currency; has_more says whether to ask for the next page.

```bash
curl -s "https://rudopo.app/api/v1/transactions?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5&limit=2" \
  -H "Authorization: Bearer rudopo_pat_YOUR_TOKEN"
```

```python
import requests

BASE = "https://rudopo.app/api/v1"
AUTH = {"Authorization": "Bearer rudopo_pat_YOUR_TOKEN"}

budget = requests.get(f"{BASE}/budgets", headers=AUTH).json()["budgets"][0]

r = requests.get(
    f"{BASE}/transactions",
    params={"budget_id": budget["id"], "limit": 50},
    headers=AUTH,
)
r.raise_for_status()

for t in r.json()["transactions"]:
    print(t["date"], t["payeeName"], t["amount"], t["currency"])
```

```ts
const BASE = "https://rudopo.app/api/v1";
const headers = {
  Authorization: "Bearer rudopo_pat_YOUR_TOKEN",
  "Content-Type": "application/json",
};

const { budgets } = await fetch(`${BASE}/budgets`, { headers }).then((r) => r.json());
const url = new URL(`${BASE}/transactions`);
url.searchParams.set("budget_id", budgets[0].id);
url.searchParams.set("limit", "50");

const { transactions, pagination } = await fetch(url, { headers }).then((r) => r.json());
for (const t of transactions) console.log(t.date, t.payeeName, t.amount, t.currency);
if (pagination.has_more) console.log("more: offset", pagination.limit);
```

Response (200 OK):

```json
{
  "transactions": [
    {
      "id": "f29c3618-37aa-4de7-828b-fe454b75a5c5",
      "date": "2026-11-01",
      "accountId": "13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f",
      "accountName": "Arvest Checking",
      "payeeId": "b9ca3473-0507-4704-a281-0a7d7a283765",
      "payeeName": "Venmo",
      "categoryId": "85c27d89-3a18-4bca-ae3c-a947874c50c1",
      "categoryName": "Rent / Mortgage",
      "memo": "Mortgage payment",
      "amount": "-1850.00",
      "amountNative": null,
      "exchangeRate": null,
      "currency": "USD",
      "type": "normal",
      "cleared": false,
      "reconciled": false,
      "transferAccountId": null,
      "splitGroupId": null,
      "labels": [
        {
          "id": "f85ec658-d845-400d-97ec-699cdde517c1",
          "name": "Recurring"
        }
      ],
      "createdAt": "2026-11-01T00:00:00.000Z",
      "version": 0,
      "money": {
        "amount": {
          "amount": "-1850.00",
          "currency": "USD"
        },
        "amountNative": null,
        "rate": null,
        "rateDate": null
      }
    },
    {
      "id": "db6c3d30-9d84-4569-9e28-ec5a65cff890",
      "date": "2026-11-01",
      "accountId": "e6eca5f5-ee32-4b09-b2ef-15397b66958d",
      "accountName": "Chase Sapphire",
      "payeeId": "72937c0b-4550-4faf-a56a-36ba825958b8",
      "payeeName": "Spotify",
      "categoryId": "2ddaa9a1-1e56-4931-b629-b007f7c59515",
      "categoryName": "Streaming",
      "memo": "",
      "amount": "-10.99",
      "amountNative": null,
      "exchangeRate": null,
      "currency": "USD",
      "type": "normal",
      "cleared": false,
      "reconciled": false,
      "transferAccountId": null,
      "splitGroupId": null,
      "labels": [
        {
          "id": "f85ec658-d845-400d-97ec-699cdde517c1",
          "name": "Recurring"
        }
      ],
      "createdAt": "2026-11-01T00:00:00.000Z",
      "version": 0,
      "money": {
        "amount": {
          "amount": "-10.99",
          "currency": "USD"
        },
        "amountNative": null,
        "rate": null,
        "rateDate": null
      }
    }
  ],
  "pagination": {
    "limit": 2,
    "offset": 0,
    "has_more": true
  }
}
```

### Add a transaction, with an idempotency key

A dry run first: the answer is the row as the ledger would show it, and nothing is written. Drop dry_run and send an Idempotency-Key to file it — as a proposal under Propose changes, as a row under Make changes.

```bash
curl -s -X POST "https://rudopo.app/api/v1/transactions?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5&dry_run=true" \
  -H "Authorization: Bearer rudopo_pat_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"account_id":"13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f","date":"2026-11-01","amount":"-12.50","payee_name":"Corner Shop","memo":"Milk and bread"}'

# For real: remove dry_run and add a key you mint once per intended change.
#   -H "Idempotency-Key: $(uuidgen)"
```

```python
import requests

BASE = "https://rudopo.app/api/v1"
AUTH = {"Authorization": "Bearer rudopo_pat_YOUR_TOKEN"}
import uuid

body = {
  "account_id": "13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f",
  "date": "2026-11-01",
  "amount": "-12.50",
  "payee_name": "Corner Shop",
  "memo": "Milk and bread"
}

# A dry run: what would happen, and nothing written.
r = requests.post(
    f"{BASE}/transactions",
    params={"budget_id": "62a9e36f-6193-4e00-ae2e-ec0105e167e5", "dry_run": "true"},
    json=body,
    headers=AUTH,
)
r.raise_for_status()
print(r.json()["summary"])

# For real: drop dry_run and send one key per intended change, the same one on a retry.
# r = requests.post(
#     f"{BASE}/transactions",
#     params={"budget_id": "62a9e36f-6193-4e00-ae2e-ec0105e167e5"},
#     json=body,
#     headers={**AUTH, "Idempotency-Key": str(uuid.uuid4())},
# )
# print(r.status_code, r.json()["outcome"])  # 202 proposed, or 201 written
```

```ts
const BASE = "https://rudopo.app/api/v1";
const headers = {
  Authorization: "Bearer rudopo_pat_YOUR_TOKEN",
  "Content-Type": "application/json",
};

const body = {
  "account_id": "13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f",
  "date": "2026-11-01",
  "amount": "-12.50",
  "payee_name": "Corner Shop",
  "memo": "Milk and bread"
};

// A dry run: what would happen, and nothing written.
const preview = await fetch(`${BASE}/transactions?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5&dry_run=true`, {
  method: "POST",
  headers,
  body: JSON.stringify(body),
}).then((r) => r.json());
console.log(preview.would, preview.summary);

// For real: drop dry_run and send one key per intended change, the same one on a retry.
// const res = await fetch(`${BASE}/transactions?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5`, {
//   method: "POST",
//   headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
//   body: JSON.stringify(body),
// });
// console.log(res.status, (await res.json()).outcome); // 202 "proposed", or 201 "written"
```

Response (200 OK):

```json
{
  "outcome": "dry_run",
  "dry_run": true,
  "would": "propose",
  "summary": "Add a transaction of -12.50 USD on Nov 1, 2026",
  "flags": [],
  "diff": {
    "type": "create",
    "rows": [
      {
        "date": "2026-11-01",
        "accountId": "13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f",
        "accountName": "Arvest Checking",
        "labels": [],
        "isCleared": false,
        "isIncome": false,
        "payee": "Corner Shop",
        "categoryId": null,
        "categoryName": null,
        "memo": "Milk and bread",
        "amount": {
          "amount": "-12.50",
          "currency": "USD"
        },
        "amountNative": null,
        "rate": null,
        "rateDate": null
      }
    ]
  }
}
```

### Propose a categorisation

An edit names the row and the version you read it at, so a change the person made in between is a 409, not an overwrite. The dry run shows each field before and after.

```bash
curl -s -X PATCH "https://rudopo.app/api/v1/transactions/db6c3d30-9d84-4569-9e28-ec5a65cff890?dry_run=true" \
  -H "Authorization: Bearer rudopo_pat_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":0,"category_id":"85c27d89-3a18-4bca-ae3c-a947874c50c1"}'
```

```python
import requests

BASE = "https://rudopo.app/api/v1"
AUTH = {"Authorization": "Bearer rudopo_pat_YOUR_TOKEN"}

r = requests.patch(
    f"{BASE}/transactions/db6c3d30-9d84-4569-9e28-ec5a65cff890",
    params={"dry_run": "true"},
    json={
  "version": 0,
  "category_id": "85c27d89-3a18-4bca-ae3c-a947874c50c1"
},
    headers=AUTH,
)
r.raise_for_status()
for f in r.json()["diff"]["fields"]:
    print(f["field"], f["before"], "->", f["after"])
```

```ts
const BASE = "https://rudopo.app/api/v1";
const headers = {
  Authorization: "Bearer rudopo_pat_YOUR_TOKEN",
  "Content-Type": "application/json",
};

const diff = await fetch(`${BASE}/transactions/db6c3d30-9d84-4569-9e28-ec5a65cff890?dry_run=true`, {
  method: "PATCH",
  headers,
  body: JSON.stringify({
  "version": 0,
  "category_id": "85c27d89-3a18-4bca-ae3c-a947874c50c1"
}),
}).then((r) => r.json());
for (const f of diff.diff.fields) console.log(f.field, f.before, "->", f.after);
```

Response (200 OK):

```json
{
  "outcome": "dry_run",
  "dry_run": true,
  "would": "propose",
  "summary": "Edit a transaction (category)",
  "flags": [],
  "diff": {
    "type": "update",
    "row": {
      "id": "db6c3d30-9d84-4569-9e28-ec5a65cff890",
      "date": "2026-11-01",
      "accountId": "e6eca5f5-ee32-4b09-b2ef-15397b66958d",
      "accountName": "Chase Sapphire",
      "payee": "Spotify",
      "categoryId": "2ddaa9a1-1e56-4931-b629-b007f7c59515",
      "categoryName": "Streaming",
      "memo": "",
      "isCleared": false,
      "isIncome": false,
      "amount": {
        "amount": "-10.99",
        "currency": "USD"
      },
      "amountNative": null,
      "rate": null,
      "rateDate": null
    },
    "fields": [
      {
        "field": "category",
        "before": "Streaming",
        "after": "Rent / Mortgage"
      }
    ],
    "pair": null
  }
}
```

### Read the inbox

What your client proposed and what became of it. A dismissed proposal carries the person's reason, so a word there teaches the client. Cursor-paged.

```bash
curl -s "https://rudopo.app/api/v1/proposals?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5&status=open" \
  -H "Authorization: Bearer rudopo_pat_YOUR_TOKEN"
```

```python
import requests

BASE = "https://rudopo.app/api/v1"
AUTH = {"Authorization": "Bearer rudopo_pat_YOUR_TOKEN"}

r = requests.get(
    f"{BASE}/proposals",
    params={"budget_id": "62a9e36f-6193-4e00-ae2e-ec0105e167e5", "status": "dismissed"},
    headers=AUTH,
)
for p in r.json()["proposals"]:
    print(p["summary"], "->", p["dismissReason"])
```

```ts
const BASE = "https://rudopo.app/api/v1";
const headers = {
  Authorization: "Bearer rudopo_pat_YOUR_TOKEN",
  "Content-Type": "application/json",
};

const { proposals } = await fetch(
  `${BASE}/proposals?budget_id=62a9e36f-6193-4e00-ae2e-ec0105e167e5&status=dismissed`,
  { headers },
).then((r) => r.json());
for (const p of proposals) console.log(p.summary, "->", p.dismissReason);
```

Response (200 OK):

```json
{
  "proposals": [
    {
      "id": "dd258dcd-7f87-48f2-b12d-09d28c7487af",
      "kind": "transaction.create",
      "summary": "Add a transaction of -12.50 USD on Nov 1, 2026",
      "status": "open",
      "flags": [],
      "actorLabel": "Docs examples",
      "dismissReason": null,
      "createdAt": "2026-11-01T14:37:51.000Z",
      "decidedAt": null,
      "resultId": null,
      "stale": false
    }
  ],
  "pagination": {
    "limit": 100,
    "has_more": false,
    "next_cursor": null
  }
}
```

Without `dry_run`, the same create under Propose changes answers 202 Accepted with a proposal, and nothing has changed yet:

```json
{
  "outcome": "proposed",
  "proposal": {
    "id": "dd258dcd-7f87-48f2-b12d-09d28c7487af",
    "status": "open",
    "summary": "Add a transaction of -12.50 USD on Nov 1, 2026",
    "flags": [],
    "diff": {
      "type": "create",
      "rows": [
        {
          "date": "2026-11-01",
          "accountId": "13c8f90c-58d5-4f3e-9e3c-2a89df4f9b4f",
          "accountName": "Arvest Checking",
          "labels": [],
          "isCleared": false,
          "isIncome": false,
          "payee": "Corner Shop",
          "categoryId": null,
          "categoryName": null,
          "memo": "Milk and bread",
          "amount": {
            "amount": "-12.50",
            "currency": "USD"
          },
          "amountNative": null,
          "rate": null,
          "rateDate": null
        }
      ]
    }
  }
}
```

## Read, propose, or write

| Level | What it may do | Where a change lands |
| --- | --- | --- |
| Read | Sees budgets, accounts and transactions. Changes nothing. | Nowhere. Every tool and endpoint reads; nothing changes. |
| Propose changes | Every change waits in your review inbox until you accept it. | In the review inbox, as a proposal with a diff. The person accepts, edits or dismisses it; until then nothing has changed. |
| Make changes | Changes land directly. Everything is logged; most can be undone for 15 minutes. | In the budget directly, logged under the token's name, within the limits set on it. Over a limit it lands in the inbox instead. A delete without the deletes box asks the person in the assistant where it can — a yes deletes — and lands in the inbox when the client cannot ask. |

A level is chosen by the person on a Rudopo screen. A client cannot ask its way up.

### The review inbox

A proposal is a change written down but not made. It is checked when it is filed, against the same rules a real change passes, so a proposal never starts out as something Rudopo would refuse — and checked again when the person opens it, so a row that moved on since shows Superseded instead of being applied. The card shows a diff: a new row as the ledger would show it, each field before and after for an edit, each category's Available before and after for a budget move, the count and the first five rows for a bulk change, with every other row one tap away (Show all) so the person can read each before accepting. The diff your client gets back carries those first five rows. The person accepts it, edits a proposed transaction before accepting, or dismisses it with a one-line reason your client can read back. An open proposal expires after 30 days, and nothing is applied when it does.

### The limits a person sets

Empty means the default, never unlimited.

| Limit | Default | Read on |
| --- | --- | --- |
| Per transaction | 95th percentile of 12 months' outflows; 500 with no history | Read on the absolute converted budget-currency figure of one change. An edit is read at the larger of its before and after. |
| Per day, in total | 10× the per-transaction limit | What a Make changes grant may change directly in one UTC day. Proposals spend nothing from it. |
| Rows per call | 100 | How many rows one request may touch, such as categorizing many transactions at once. |
| Rows per day | 500 | How many rows a Make changes grant may change directly in a day. |

## Safe by construction

### Idempotency keys, everywhere

Every POST, PUT and PATCH takes an Idempotency-Key; DELETE may. The same key with the same body replays the first answer with Idempotency-Replayed: true. The same key with a different body is a 409 idempotency_mismatch. A key whose row has since been deleted is idempotency_gone; a key still running is idempotency_in_progress. A retry never double-writes.

Check it: Send one POST twice with the same key, then once more with a changed amount: 202 or 201, the same again with Idempotency-Replayed, then 409.

### Dry run

dry_run=true on any write answers what it would do — the summary, the flags, the diff with every Money figure converted and rounded as the write would store it — and changes nothing. It needs no key and spends none. Every example on this page sends it.

Check it: Send the same body with and without dry_run and compare the Money objects in the two answers: byte for byte the same.

### Limits set by the person

Four limits on every proposing or writing grant: an amount per change, an amount per day, rows per call and rows per day. The amount defaults are drawn from the person's own data — the 95th percentile of the last 12 months' outflows in that space, 500 units with no history — and read on the converted budget-currency figure, two-sided, so a large inflow is over the limit too. A trusted write over a limit is filed as a proposal (202, reason ceiling_exceeded), never written and never dropped.

Check it: Set a 50-unit limit and write 60 under Make changes: a 202 with reason ceiling_exceeded, a badged proposal, no row.

### The activity log names the client

Every change from a token or an assistant is recorded under the connection's name, and a row it added remembers which connection, so the ledger's Added by filter shows what an app did. The name stays on its proposals and in the log after the token is revoked; the row itself then reads as from A disconnected app. A client with Propose changes or more can read who changed what in a space, without amounts or text.

Check it: Write a row, revoke the token, then filter the ledger by Added by: the row and its history remain.

### Undo, for the person and the client

Where a change can be undone the window is about 15 minutes, the same as the person's own undo, and a trusted client may undo only its own changes, inside that window, through rudopo_undo. A new transaction, a new payee or a rename leaves nothing to undo: delete or edit it instead.

Check it: Edit a row under Make changes, call rudopo_activity_list, undo the undo_id it names, then try the same id again: Already undone.

### Reconciled rows stay locked

A reconciled transaction's amount, date, direction, account and transfer destination cannot change and it cannot be deleted or uncleared — for a client as for the person, with the same refusal (409, reason reconciled). Its memo, payee and category stay editable.

Check it: Under Make changes, PATCH a reconciled row's amount: 409 reconciled. PATCH its memo: 200 (under Propose changes, a 202 proposal).

### One door

A write from the API or an MCP tool is the ordinary procedure the app itself calls: the same schema, the same ownership checks, the same currency conversion, the same locks. The same rules decide on every door, so there is nothing a client can do that Rudopo would refuse the person at the keyboard. The API and the MCP server share one refusal vocabulary; the app says the same thing in its own words. The one money check the API makes on its own is the grid: an amount finer than the currency allows is refused there, where the app would round it.

Check it: Edit a reconciled row's amount in the app, then through the API and an MCP tool: the app refuses it in the row, the API answers 409 reconciled, the tool answers the same sentence with the code.

### Never offered, at any level

- Your account, your sign-in, your plan or your other tokens.
- Creating or deleting a space, changing its currency, or sharing it.
- Closing an account, reconciling one, or a balance adjustment.
- A full export. Everything is read one page at a time.
- Moving money. Rudopo does not move money.

## Connect an assistant

Rudopo runs an MCP server at `https://rudopo.app/api/mcp`. Any assistant that can reach a remote MCP server with a bearer token can use it with your token; one that signs in with OAuth connects without a token and starts at Read. The full page: https://rudopo.app/developers/mcp (Markdown: https://rudopo.app/developers/mcp.md).

```bash
curl -s https://api.x.ai/v1/responses \
  -H "Authorization: Bearer YOUR_XAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "YOUR_GROK_MODEL",
    "input": "What is my Ready to Assign this month?",
    "tools": [{
      "type": "mcp",
      "server_url": "https://rudopo.app/api/mcp",
      "server_label": "rudopo",
      "authorization": "rudopo_pat_YOUR_TOKEN"
    }]
  }'
```

From grok.com, no token needed:

1. On grok.com, open Connectors.
2. Choose Custom.
3. Paste https://rudopo.app/api/mcp as the server URL.
4. Sign in to Rudopo when asked, and approve the connection.

- A token's level decides what the tools may do: Read lists the read tools alone; Propose changes and Make changes add the write tools, described for what will happen under that grant. A delete is listed only under Make changes.
- A token, or a connection made by signing in, reaches what a token reaches on the REST API, under the same rate limits. A signed-in connection starts at Read, over every space you belong to.
- Rudopo never sees the conversation. It answers the assistant's requests and logs the changes it asked for; what you said to the assistant stays with the assistant, under its own policies.

## Things worth knowing first

**Money is a string.** Amounts are decimal strings in major units with the currency's own decimals ("-45.00" in USD, "1840" in JPY, "1.234" in KWD), never JSON numbers. Parse them with a decimal type — a float will quietly lose cents on someone's ledger. A value finer than its currency allows is a 400, never rounded.

**Dates have no timezone.** Transaction dates are YYYY-MM-DD and months are YYYY-MM, exactly as entered. They are calendar dates, not instants; converting them to UTC invents a timezone the data never had.

**A bad limit is an error, not a trim.** Ask for more rows than the maximum and you get a 400. We would rather fail loudly than hand back fewer rows than you asked for and let you ship a partial sync believing it was complete. The same goes for every other parameter: an unrecognised filter value and a date that does not exist are errors, never a request that quietly did something else.

**404 never tells you an id is real.** An id that does not exist and an id belonging to someone else answer identically, so a token cannot be used to probe for other people's data. A 403 is only ever about your own token — a missing scope, or a budget outside the list you scoped it to.

**A proposal is not a row.** A 202 with outcome proposed means nothing has changed yet. The row does not exist, the balance has not moved, and a GET will not find it. Read /proposals to see what became of it, and never tell the person it is done.

## Rate limits

| Scope | Limit |
| --- | --- |
| Per IP, before authentication | 300 / min |
| Per token, reads | 60 / min |
| Per token, writes | 30 / min |
| Per user, all tokens | 120 / min |
| Per user, daily | 20,000 / day |
| Per user, writes made (burst window, every door) | 60 / 10 s |
| Per user, writes made (hour window, every door) | 1,800 / hour |
| Per user, writes made (day window, every door) | 10,000 / day |

Every response to a request that got past the token check carries `X-RateLimit-*` and the IETF `RateLimit` / `RateLimit-Policy` fields for whichever of the per-token and per-user windows will refuse first; a refusal carries `Retry-After`, and a write refused by a per-user write window says that window's own wait. The write windows count only writes that were allowed, across every door.

## Reference

- API reference, generated from the OpenAPI document: https://rudopo.app/developers/reference (Markdown: https://rudopo.app/developers/reference.md)
- Errors: https://rudopo.app/developers/errors (Markdown: https://rudopo.app/developers/errors.md)
- MCP tools, from the live catalogue: https://rudopo.app/developers/mcp (Markdown: https://rudopo.app/developers/mcp.md; JSON: https://rudopo.app/developers/mcp.json)
- Changelog: https://rudopo.app/developers/changelog (Markdown: https://rudopo.app/developers/changelog.md)

There is no official client library, on purpose: generate one from the OpenAPI document in whichever language you use.

## Changelog

- **2026-11-01** — The developer pages grow: /developers/mcp renders the live tool catalogue (also as JSON at /developers/mcp.json), /developers/errors explains every code and reason, /developers/changelog is this list on its own page, and every developer page is served as Markdown at the same path with .md. /developers/llms.txt links a single-page Markdown reference. Every write example defaults to dry_run.
- **2026-11-01** — The next phone app release reviews proposals: Home counts the changes to review and opens Review, where a proposal is accepted, edited or dismissed. Its API tokens screen offers the same three access levels, limits, the deletes box and the budget allowlist as the web.
- **2026-11-01** — Propose changes and Make changes can now be minted: Settings → API offers the three access levels, the four limits with their defaults, Allow deletes and merges, a budget allowlist, and a 90-day default expiry on a token that changes your budget. An assistant that signed in can be allowed to propose or make changes on the approval page, and Connected apps lists what it may do. The review inbox is live at /review. Nothing changes for an existing token.

Every entry: https://rudopo.app/developers/changelog. Additive changes never break anything; a breaking change gets six months' notice and `Deprecation` headers first.
