# The propose → commit contract

> Why no Invoia tool writes on its own, and what the two steps look like.

Source: https://invoia.io/developers/mcp/propose-commit

Invoia moves money and books to an accounting ledger. An agent that
misreads "five hours" and silently deletes seven is not a support ticket,
it is a wrong VAT return. So the server has no tool that both decides and
writes.

## Two steps, always [#two-steps-always]

1. **Propose.** A `plan_*` tool reads the current state, works out the
   change, and returns a plan in plain language plus an opaque
   `proposal_token`. Nothing has happened yet.
2. **Commit.** The separate `commit` tool takes that token and applies
   the plan exactly as written.

The agent is instructed to show you the plan and wait. Because the two
steps are different tools, a client that requires approval for writes can
gate `commit` alone and leave reading and drafting free.

## What a plan looks like [#what-a-plan-looks-like]

A ready proposal comes back as text the agent can read to you, with the
structured form alongside it:

```
Set Jane Doe to 12.0 h (was 7.0) and 60% complete on line
"SEO retainer — July" of invoice INV-2026-0142.

To apply, confirm with the user, then call `commit` with
proposal_token="mcpp_3f0c…" (expires 2026-08-07T09:12:33.000Z;
requires mcp:write).
```

You read the summary. If it is right, the agent calls `commit` with the
token. If it is not, you say so and nothing was ever written.

## The token has a short life [#the-token-has-a-short-life]

* **Fifteen minutes.** After that the token is `PROPOSAL_EXPIRED` and the
  `plan_*` tool has to run again. This is deliberate: a plan describes the
  world as it was when it was built, and an hour-old plan is a guess.
* **Single use.** Committing flips the proposal from pending to
  committed in one conditional write, so a client that retries a timed-out
  commit cannot apply it twice.
* **Bound to your account.** A token from one connection means nothing on
  another.

> [!NOTE]
> **PROPOSAL_STALE means the ground moved**
>
> Every plan records the `updated_at` of each row it depends on, and
> commit re-checks them. If someone edited that invoice in the app while
> you were reading the plan, the commit is refused rather than applied to
> a record it no longer describes. Re-run the same `plan_*` tool, read the
> new summary, commit that token.

## When the agent does not have enough to go on [#when-the-agent-does-not-have-enough-to-go-on]

A `plan_*` tool can come back with `needs_clarification` and a list of
questions instead of a plan. The agent must ask you and re-call the same
tool with your answers — it is not allowed to guess:

```
I need a bit more information before preparing this change:
1. [line_ref] Which line should the hours go on?
   - Dev hours (hourly, invoice INV-2026-0142)  [answer: line_7]
   - SEO retainer — July (value, invoice INV-2026-0142)  [answer: line_9]

Ask the user, then call this tool again with line_ref filled in.
```

Each option carries the exact value to send back, so a text-only agent is
never stuck able to read a choice but unable to express it.

The most common trigger is a scale ambiguity:

* **hours** is an absolute cumulative total, not an increment. Sending
  `hours: 5` to a line that already has 12 logged is a request to remove
  seven — so the tool stops and asks which you meant. Use `hours_delta`
  to add.
* **completion\_rate** is `0`–`1`; **completion\_percent** is `0`–`100`.
* **money** is in major units — kroner, not øre — in the integration's
  base currency.

## Every failure says what to do next [#every-failure-says-what-to-do-next]

Tool failures come back as readable errors, not transport faults, and
each carries a code and a next step:

```
Error [INVOICE_ALREADY_ISSUED]: This invoice is already issued.
Next: An issued invoice is frozen. Raise a credit note with
plan_create_credit_note instead of editing it.
```

The codes are the same vocabulary the REST API uses, so an integration
that touches both surfaces branches on one set of strings. The ones
specific to this contract:

| Code                  | What happened                           | What to do                      |
| --------------------- | --------------------------------------- | ------------------------------- |
| `PROPOSAL_EXPIRED`    | Older than fifteen minutes              | Re-run the `plan_*` tool        |
| `PROPOSAL_STALE`      | A record it depends on changed          | Re-run, re-read, re-commit      |
| `PROPOSAL_NOT_FOUND`  | Already committed, or another account's | Re-run the `plan_*` tool        |
| `AMBIGUOUS_REFERENCE` | A name matched several rows             | Pick from the listed candidates |

## Irreversible steps are still irreversible [#irreversible-steps-are-still-irreversible]

Commit is where the change becomes real, and some changes cannot be
undone from Invoia:

* **Issuing** books the invoice into Dinero and emails your customer.
  Correcting it means raising a credit note, not editing the invoice.
* **Credit notes** are themselves bookings.

Issuing lives behind its own scope, so an agent can be given the run of
everything else without it — see
[Auth and scopes](/developers/mcp/auth).
