Skip to main content
Draft — ahead of the backend; nothing here is live yet. This surface is written against the design, not against a running server. There is no api.contracts.io to call, no key to issue, and every response below is an illustration rather than a recording. It is published so the shape can be argued with before it is built.It also predates CONTRACT-SCHEMA v0 and still uses the older nouns — ask for Proposal, receipt for Version, relationship for Room. The glossary maps them.
The reply cycle is where the negotiation actually happens. Two things are worth holding in mind before you read a single shape:
  1. Staged decisions are private. They live in your unsent reply and never leave your side until a human confirms a Send.
  2. You cannot write clause text. You propose. Words move when every required negotiator has accepted, and not before.

Read the latest letter

endpoint
Scopes: contracts:read
endpoint
Scopes: contracts:read
latest is an alias, not the only door. Any committed round is addressable by its let_… id. Letters are immutable, so a fetched letter never changes under you.
200 OK
An ask carries the standing words, the proposed words, the why, and a per-party tally. A grouped ask touches several clauses at once: one fact change, one ask, one decision for the counterparty.
Read the tally carefully: the sending party is always in agreed (reconciled 2026-08-24). The example here previously omitted it. A party agrees by proposing. let_2 came from: "pty_acme", so pty_acme sits in agreed and every other required negotiator sits in awaiting. Unanimity is countable only if the proposer is counted, so never treat agreed as “everyone except the sender”.
Every letter carries a kind and a sequence. kind is sent for a round somebody committed here, and imported for correspondence seeded from a mail thread that happened before the room existed. An imported letter always has asks: [] and decisions: [], counts toward nothing, and cannot be created through the API. See List letters.

List the live asks

endpoint
Scopes: contracts:read
string
open (default) · all · undecided
Every live proposal on the contract, rather than the asks that arrived in one letter. Each carries its standing words, proposed words, why, per-party tally, and decided_by_me drawn from your own unsent round. undecided is the working list: the asks waiting on you specifically.

Stage a decision

endpoint
Scopes: decisions:write · If-Match required
string
The ask being answered. Use this when somebody has already raised the question.
string
The clause you want to change, when nobody has raised a question about it yet. Only valid with type: "counter". Mutually exclusive with ask_id.
string
required
accept · counter · decline
string
Required on a counter: the words you propose instead.
string
Required on counter and decline. A counter without a reason is a fight.
Request: answering an existing question
201 Created

Raising a question nobody has raised yet

The first counter on a fresh contract has no ask to answer. Somebody sent you a draft, and you want to change clause nine. Name the clause instead of an ask, and one gets created for you.
Request: opening a new question on a clause
201 Created
  • Send one or the other, never both. Both, or neither, returns 400 invalid_request.
  • block_id only works with type: "counter". There is nothing to accept or decline yet, so accept or decline with a block_id returns 400 invalid_request.
  • why is still required. A proposal with no reason attached is the thing this product exists not to send.
  • The response always names the ask your decision sits on, whether it was found or freshly made, plus ask_created: true when it was made. You can address it later without another read.
A decline is implemented as a counter proposing the standing words. One mechanic, not two. That is why a decline still has a tally and can still land.
Staging does not move words_hash. Only a landed block does. That is why you can stage a whole round without racing the counterparty.

Apply your playbook to a whole letter

endpoint
Scopes: decisions:write · If-Match required
Stage decisions across many questions at once, using your org’s own rules. The useful half of the answer is what it refused to decide, which is why one-at-a-time cannot express this.
string[]
Which questions to work through. Defaults to every open one.
string[]
Which rules to use. Defaults to every rule your org holds.
boolean
When true, returns the identical body and stages nothing.
200 OK
reason is a closed list: no_rule, below_floor, above_ceiling, already_decided, needs_a_human, not_a_negotiator.
This stages and never sends. The round still sits in your private draft, and it still needs a person to press the button through POST /contracts/{id}/replies. dry_run: true is how an agent shows its working before anybody has agreed to anything.

Read your unsent reply

endpoint
Scopes: contracts:read
The unsent round: your staged decisions, your note, and the server’s own dry run of what would happen if you sent it. Your seat only. There is exactly one reply per contract per seat, and the counterparty’s reply is not fetchable, summarizable, countable, or hinted at.
200 OK
string[]
Which clauses would actually change if you committed this round right now. Computed by the same code that decides it for real.
string[]
Which clauses would become open questions instead.
Never work this out yourself. Two implementations of “did everyone agree” is two answers to the only question that moves words. The server computes it here so your preview and the real thing can never disagree.

Withdraw a staged decision

endpoint
Scopes: decisions:write
The Undo the margin already offers.

Discard the whole round

endpoint
Scopes: decisions:write
Drops every staged decision and the note. Destructive to a human’s private draft. Treat it as confirmation-class in your own UI.

Set the reply note

endpoint
Scopes: decisions:write
Request
The covering sentence on your unsent round, written before you commit anything. note on the commit call below still sets it at Send; this sets it earlier, on the private draft, and sends nothing.
This is where the harness’s set_reply_note lands (reconciled 2026-08-24). A staging verb with no endpoint is a real parity break. apply_playbook was the other one, and it now has an endpoint too. See the parity principle for why the reading tools are a different case.

Commit the round

endpoint
required
Scopes: send · Idempotency-Key required · If-Match required · confirmed_by required
Request
200 OK: the counter above, committed
string[]
Blocks that reached unanimity on this Send. The only way words ever change.
string[]
Questions this round put on the table.
string[]
Decisions invalidated because a concurrent letter moved that block. Re-decide those, and only those.
A counter never lands words. Look at landed: [] above. That is not an empty case, it is what a counter always does: it puts a new question on the table, resets the clock and waits for the other side. Words move only when everybody has said yes.
re_asked is empty here because nothing happened at the same time. It fills only when a letter from the other side moved a clause while your round was staged, which is the 409 words_changed story below.

The second half of the cycle, where words actually move

Request: the other side accepts
200 OK, and now the round is committed
The hash changed, because the words did. blk_9 now reads “nine (9) months”, the contract is agreed, and signing can start.
Omitting confirmed_by returns 409 confirmation_required with a fully assembled letter preview. Do not retry it. Put the preview in front of a person and call again once they have pressed the button.
If your If-Match hash was stale you get 409 words_changed carrying the current hash and a non-empty re_asked list. Refetch the contract, re-decide the named asks, retry. If you sent no If-Match at all, you get 428 if_match_required instead, which is a different problem with a different fix.

Withdraw a sent ask

endpoint
Scopes: decisions:write
Pulls back a proposal you have already sent. Distinct from withdrawing a staged decision, which never left your side in the first place. An ask that a counter has superseded is already dead. Calls against it return ask_voided_by_counter.

Playbooks

endpoint
Scopes: contracts:read
Your org’s negotiating rules: the floors and ceilings a decision gets measured against. Filter with ?topic=.
200 OK
object | null
The worst position that is still inside the rule. null on a rule that is prose-only.
object | null
The best position the rule asks for. null on a prose-only rule.
string
org_settings · template · inherited: where the rule came from, so a reader can tell a deliberate floor from an inherited default.
Read-only, and that is the whole shape (reconciled 2026-08-24). The endpoint was public in the draft with no documented response. There is no POST, PATCH or DELETE: teaching a floor is org settings, and a rule a caller could write is a rule the API would have to arbitrate.Your playbook is your side’s only. It is never returned to the counterparty, and the MCP server deliberately does not expose it as a raw tool. An agent gets readings from risk_scan, not the floors as data.

What does not exist

No direct text write

PUT /contracts/{id}/blocks/{id} does not exist and will not. The one call that touches block text without an ask (blocks/{id}/confirm) only settles a span to a value the parse proposed, and only before the first Send.

No decision.staged webhook

A staged decision is private to your side and must not leave it.

No comments API

Internal notes are a separate party-private layer, not designed at v1.

No approval routing

An approver acts in Needs attention, not over HTTP.