> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contracts.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Replies

> Read inbound letters, stage decisions privately, and commit a round. Words move on unanimity, never on a write.

<Warning>
  **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](/glossary) maps them.
</Warning>

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

<ResponseField name="GET /contracts/{id}/letters/latest" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

<ResponseField name="GET /contracts/{id}/letters/{letter_id}" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

`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.

```json 200 OK theme={null}
{
  "id": "let_2",
  "kind": "sent",
  "sequence": 4,
  "from": "pty_acme",
  "note": "Two things and one number.",
  "sent_at": "2026-08-24T09:41:00Z",
  "asks": [
    {
      "id": "ask_3",
      "block": "blk_9",
      "type": "replace",
      "standing": "twelve (12) months",
      "proposed": "nine (9) months",
      "why": "We can't commit past our fiscal year.",
      "tally": { "agreed": ["pty_acme"], "awaiting": ["pty_north"] }
    }
  ]
}
```

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.

<Note>
  **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".
</Note>

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](/api/contracts#list-letters).

## List the live asks

<ResponseField name="GET /contracts/{id}/asks" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

<ParamField query="filter" type="string">
  `open` (default) · `all` · `undecided`
</ParamField>

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

<ResponseField name="POST /contracts/{id}/decisions" type="endpoint">
  Scopes: `decisions:write` · `If-Match` required
</ResponseField>

<ParamField body="ask_id" type="string">
  The ask being answered. Use this when somebody has already raised the question.
</ParamField>

<ParamField body="block_id" type="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`.
</ParamField>

<ParamField body="type" type="string" required>
  `accept` · `counter` · `decline`
</ParamField>

<ParamField body="text" type="string">
  Required on a `counter`: the words you propose instead.
</ParamField>

<ParamField body="why" type="string">
  Required on `counter` and `decline`. A counter without a reason is a fight.
</ParamField>

```json Request: answering an existing question theme={null}
{
  "ask_id": "ask_3",
  "type": "counter",
  "text": "ten (10) months",
  "why": "Ten lands after our renewal date."
}
```

```json 201 Created theme={null}
{ "id": "dec_9", "state": "staged", "sent": false,
  "ask": "ask_3", "words_hash": "sha256:9f2c..." }
```

### 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.

```json Request: opening a new question on a clause theme={null}
{
  "block_id": "blk_9",
  "type": "counter",
  "text": "nine (9) months",
  "why": "We cannot commit past the end of our financial year."
}
```

```json 201 Created theme={null}
{ "id": "dec_11", "state": "staged", "sent": false,
  "ask": "ask_12", "ask_created": true, "words_hash": "sha256:9f2c..." }
```

* **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.

<Note>
  **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.
</Note>

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

<ResponseField name="POST /contracts/{id}/decisions/apply-playbook" type="endpoint">
  Scopes: `decisions:write` · `If-Match` required
</ResponseField>

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.

<ParamField body="ask_ids" type="string[]">
  Which questions to work through. Defaults to every open one.
</ParamField>

<ParamField body="playbook_ids" type="string[]">
  Which rules to use. Defaults to every rule your org holds.
</ParamField>

<ParamField body="dry_run" type="boolean">
  When true, returns the identical body and stages nothing.
</ParamField>

```json 200 OK theme={null}
{
  "staged": [
    { "decision": "dec_9", "ask": "ask_3", "type": "accept", "rule": "pbk_3" }
  ],
  "skipped": [
    { "ask": "ask_4", "reason": "no_rule" },
    { "ask": "ask_5", "reason": "below_floor" },
    { "ask": "ask_6", "reason": "already_decided" }
  ],
  "words_hash": "sha256:9f2c..."
}
```

`reason` is a closed list: `no_rule`, `below_floor`, `above_ceiling`, `already_decided`,
`needs_a_human`, `not_a_negotiator`.

<Warning>
  **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.
</Warning>

## Read your unsent reply

<ResponseField name="GET /contracts/{id}/reply" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

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.

```json 200 OK theme={null}
{
  "decisions": [
    { "id": "dec_9", "ask": "ask_3", "type": "counter",
      "text": "ten (10) months", "why": "Ten lands after our renewal date.",
      "person_id": "per_9", "staged_at": "2026-08-24T10:02:00Z" }
  ],
  "note": "Two yeses and one number.",
  "words_hash": "sha256:9f2c...",
  "letter_preview": { "note": "...", "asks": [ ], "to": ["pty_acme"] },
  "landed_if_sent": ["blk_4"],
  "opens_if_sent": ["blk_9"]
}
```

<ResponseField name="landed_if_sent" type="string[]">
  Which clauses would actually change if you committed this round right now. Computed by the same code
  that decides it for real.
</ResponseField>

<ResponseField name="opens_if_sent" type="string[]">
  Which clauses would become open questions instead.
</ResponseField>

<Note>
  **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.
</Note>

## Withdraw a staged decision

<ResponseField name="DELETE /contracts/{id}/decisions/{decision_id}" type="endpoint">
  Scopes: `decisions:write`
</ResponseField>

The Undo the margin already offers.

## Discard the whole round

<ResponseField name="DELETE /contracts/{id}/reply" type="endpoint">
  Scopes: `decisions:write`
</ResponseField>

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

<ResponseField name="PATCH /contracts/{id}/reply" type="endpoint">
  Scopes: `decisions:write`
</ResponseField>

```json Request theme={null}
{ "note": "Two yeses and one number." }
```

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.

<Note>
  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](#apply-your-playbook-to-a-whole-letter) too. See [the parity
  principle](/api/introduction#the-parity-principle) for why the reading tools are a different case.
</Note>

## Commit the round

<ResponseField name="POST /contracts/{id}/replies" type="endpoint" required>
  Scopes: `send` · `Idempotency-Key` required · `If-Match` required · `confirmed_by` required
</ResponseField>

```json Request theme={null}
{ "note": "Two yeses and one number.", "confirmed_by": "per_9" }
```

```json 200 OK: the counter above, committed theme={null}
{
  "letter": "let_3",
  "state": "negotiating",
  "landed": [],
  "re_asked": [],
  "asks_opened": ["ask_12"],
  "words_hash": "sha256:9f2c..."
}
```

<ResponseField name="landed" type="string[]">
  Blocks that reached unanimity on this Send. **The only way words ever change.**
</ResponseField>

<ResponseField name="asks_opened" type="string[]">
  Questions this round put on the table.
</ResponseField>

<ResponseField name="re_asked" type="string[]">
  Decisions invalidated because a concurrent letter moved that block. Re-decide those, and only those.
</ResponseField>

<Warning>
  **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.
</Warning>

`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

```json Request: the other side accepts theme={null}
{ "ask_id": "ask_12", "type": "accept" }
```

```json 200 OK, and now the round is committed theme={null}
{
  "letter": "let_4",
  "state": "agreed",
  "landed": ["blk_9"],
  "re_asked": [],
  "asks_opened": [],
  "words_hash": "sha256:be71..."
}
```

The hash changed, because the words did. `blk_9` now reads "nine (9) months", the contract is `agreed`,
and signing can start.

<Warning>
  **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.
</Warning>

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

<ResponseField name="POST /asks/{id}/withdraw" type="endpoint">
  Scopes: `decisions:write`
</ResponseField>

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

<ResponseField name="GET /playbooks" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Your org's negotiating rules: the floors and ceilings a decision gets measured against. Filter with
`?topic=`.

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "pbk_3",
      "term": "liability_cap",
      "rule_text": "Cap at 12 months' fees; never uncapped.",
      "floor": { "value": 6, "unit": "months_fees" },
      "ceiling": { "value": 12, "unit": "months_fees" },
      "preferred": { "value": 12, "unit": "months_fees" },
      "source": "org_settings"
    }
  ]
}
```

<ResponseField name="floor" type="object | null">
  The worst position that is still inside the rule. `null` on a rule that is prose-only.
</ResponseField>

<ResponseField name="ceiling" type="object | null">
  The best position the rule asks for. `null` on a prose-only rule.
</ResponseField>

<ResponseField name="source" type="string">
  `org_settings` · `template` · `inherited`: where the rule came from, so a reader can tell a
  deliberate floor from an inherited default.
</ResponseField>

<Note>
  **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`](/api/mcp), not
  the floors as data.
</Note>

## What does not exist

<CardGroup cols={2}>
  <Card title="No direct text write" icon="ban">
    `PUT /contracts/{id}/blocks/{id}` does not exist and will not. The one call that touches block text
    without an ask ([`blocks/{id}/confirm`](/api/imports#confirming-or-correcting-a-block))
    only settles a span to a value the parse proposed, and only before the first Send.
  </Card>

  <Card title="No decision.staged webhook" icon="ban">
    A staged decision is private to your side and must not leave it.
  </Card>

  <Card title="No comments API" icon="ban">
    Internal notes are a separate party-private layer, not designed at v1.
  </Card>

  <Card title="No approval routing" icon="ban">
    An approver acts in Needs attention, not over HTTP.
  </Card>
</CardGroup>
