The agent contract (Review Loop)
The Review Loop is the review system for agent email. An agent submits a draft, and a human or linked review agent approves, edits, rejects, or escalates it. Human edits can become reusable writing rules. A mature category can later graduate to auto-send. Extrovert stores the state and computes the diff. The composing agent applies the writing rules and makes drafting decisions.
This page defines the versioned JSON shapes used by the SDK, MCP tools, and agent skills.
Version provisional 0.x
Section titled “Version ”import { CONTRACT_VERSION, CONTRACT_MANIFEST } from "@extrovert.dev/sdk";
CONTRACT_VERSION; // "0.1.0-pre.6"CONTRACT_MANIFEST.stability; // "provisional"CONTRACT_MANIFEST.kind; // "sdk+skill-contract" (NOT a protocol)CONTRACT_VERSION is 0.1.0-pre.6 and is reconciled across every surface: the SDK package
version, the MCP server version, and the OpenAPI info.version all read 0.1.0-pre.6. Pin it.
It is provisional and pre-1.0: open and documented, but it MAY still evolve (additively) before 1.0. There are no external users yet, and a platform-wide shared-pool auto-send governor is a prerequisite before there are. Treat 0.x as “code against it, but expect additive change.”
CONTRACT_MANIFEST is the machine-readable list a harness pins against: core_shapes (the five
canonical shapes below), the full shapes surface, and the skills that are part of the contract.
The five canonical shapes
Section titled “The five canonical shapes”These are the core review-contract shapes: the highest-traffic agent-facing JSON. Every shape keys on
opaque, typed ids (rr_, turn_, cat_, rule_, rln_, ndg_, …) and never on names -
renaming a category or a rule never breaks a reference.
Intent: submitted with every outbound message
Section titled “Intent: submitted with every outbound message”The agent’s “for the human reviewer” summary. Required whenever the resolved mode is review -
which, under the require_review policy every account starts on, is every send, reply and
forward. Without it the request is rejected 422 intent_required and nothing is sent and nothing
is queued; the problem’s detail carries the whole fix. Keep summary to one sentence
(8–200 characters): it is the only context the human gets, so a boilerplate one is worse than useless.
{ "summary": "Re-engage cold lead at Acme about Q3 pilot", "meta": { "goal": "book_meeting", "recipient": "vp@acme.com", "prior_touches": 2, "urgency": "normal" } }Feedback: returned by get_review_feedback
Section titled “Feedback: returned by get_review_feedback”The human’s assembled decision: the unified + structured diff of their edit, their comments /
rejection feedback, the decision, and the rule_ ids born from this review.
{ "review_id": "rr_…", "decision": "edited", "diff_unified": "@@ -1,3 +1,3 @@ …", "diff_json": { "fields_changed": ["body_text"], "hunks": [ … ] }, "comments": [ { "turn_id": "turn_…", "actor_kind": "human", "body": "be more pushy, we need MRR" } ], "new_rules": [ "rule_…" ] }diff_json: the structured proposed-vs-sent diff
Section titled “diff_json: the structured proposed-vs-sent diff”Computed deterministically in Go ($0 LLM). fields_changed names the changed fields; hunks carry
the per-field before/after. Published in the SDK as the named DiffJson / DiffHunk types.
{ "fields_changed": ["subject","body_text"], "hunks": [ { "field": "body_text", "op": "replace", "before": "Let me know your thoughts.", "after": "Can you do Thursday 2pm?" } ] }Rule: an element of get_rules
Section titled “Rule: an element of get_rules”A learned writing preference, either house style (scope:"general") or category-specific. Append-only by
supersession. Your agent reads the ordered ladder at compose/redraft time; we never apply it.
{ "id": "rule_…", "lineage_id": "rln_…", "rev": 3, "rule_layer": "project", "org_id": "org_…", "project_id": "prj_…", "scope": "category", "category_id": "cat_…", "kind": "soft", "priority": 0, "rule_text": "Always propose a concrete time, never 'let me know'.", "source_review_id": "rr_…", "author_kind": "human", "status": "active" }rule_layer is the ownership layer: org (house-style inherited by every project in the org) or
project (layered on top). get_rules returns the effective stack for your key’s {org_id, project_id} with the precedence ladder applied server-side (per-agent project rule → project category
rule → project general rule → org house-style rule). Agent-plane save_rule always writes a
project-layer rule; org-wide house-style is authored from the console. These fields are additive
and optional: older harnesses that only read scope / category_id keep working.
An unfiltered get_rules response also carries house_style_version, category_rules_version,
rule_high_water, and a short-lived composition_token. Keep that token with the exact rule snapshot
used to compose a draft, and pass it to send_email, reply_to_email, forward_email, or
submit_revision. If the rules change or the token expires, the mutation is rejected without sending;
fetch the full stack again, re-apply it, and retry with the new token. Scope-filtered rule queries are
for browsing and do not return a composition token.
Nudge: an element of list_review_events
Section titled “Nudge: an element of list_review_events”A durable, idempotent work item drained from the authoritative liveness queue. seq is the per-review
monotonic ordinal your ack cursor advances against. The propagate_general_rule nudge carries the
sibling pending set + a suggested_batch so your agent front-runs the human a few drafts at a time -
never the whole queue.
{ "seq": 4127, "id": "ndg_…", "reason": "propagate_general_rule", "review_id": "rr_…", "category_id": "cat_…", "payload": { "rule": { "id": "rule_…", "rule_text": "…" }, "siblings": { "category_id": "cat_…", "pending_count": 499 }, "suggested_batch": 3 } }Every reason that is emitted
Section titled “Every reason that is emitted”Your drain switch should cover all eleven, plus a default: arm that acks and ignores anything it
does not recognize: this enum is additive across 0.x releases.
reason | Emitted when | What your agent does |
|---|---|---|
redraft_requested | A draft came back to needs_review: a reviewer rejected or escalated it, a breaker fired, or the sweep returned it. | Redraft via submit_revision. |
feedback_added | A human posted a comment on the review thread. (Your own question to the human emits nothing.) | Answer, or redraft. |
rejected | A human rejected the draft. | Learn from the rejection; redraft or stop. |
rule_changed | A rule that governs this draft moved: including a born-stale redraft that was refused. | get_rules, re-apply, then submit_revision: or restamp_review if nothing genuinely changed. |
recheck_category | The draft’s category graduated or was reconciled, so its routing may have changed. | Re-check the category assignment and resubmit. |
propagate_general_rule | A new general rule applies to sibling pending drafts. | Apply it to suggested_batch siblings: never the whole queue. |
sent | The message was delivered. Covers both flavors: payload.state is sent (a human or reviewer released it) or auto_sent (direct / graduated). | Done. Record payload.message_id, ack, stop polling this review. |
send_failed | The provider refused the send (approved → failed). | Done with this review. Compose and submit a new message; payload.agent_retryable is false because nothing can move this row back to approved. |
cancelled | The review reached cancelled: withdrawn by you, by a human, or as the close-out of a failed send. | Done. |
front_run_next | You hit a 409 terminal chasing a review a human already closed. | STOP RETRYING. The payload carries what actually happened. |
staleness | : | RESERVED. Never emitted today: the inbound-reply correlation detector that would produce it is not built. Ack and ignore. |
approved | : | RESERVED. Never emitted, by design. approved is transient (milliseconds before the provider call) and would be actively misleading if that call then failed. Terminal success is sent. |
Terminal events: how an agent knows it is done
Section titled “Terminal events: how an agent knows it is done”Every review that reaches
sent,auto_sent,failedorcancelledemits exactly one terminal nudge, and it is the last and highest-seqnudge that review will ever produce.
It is emitted unconditionally: including for your own inline direct send and your own cancel.
That uniformity is the point: an agent that crashed between issuing the request and reading the
response learns the outcome from the durable queue, which is exactly the window in which “the agent
already knows” is false. (The one review that emits two is a failed send you then close out: it emits
send_failed, then cancelled.)
{ "seq": 12, "id": "ndg_…", "reason": "sent", "review_id": "rr_…", "payload": { "state": "sent", "message_id": "msg_…", "send_path": "human_reviewed", "decision": "edited", "sent_at": "2026-08-31T09:14:02Z", "thread_ref": "thread_…" } }{ "seq": 3, "id": "ndg_…", "reason": "sent", "review_id": "rr_…", "payload": { "state": "auto_sent", "message_id": "msg_…", "send_path": "graduated_auto", "gate_outcome": "", "sent_at": "2026-08-31T09:14:02Z" } }{ "seq": 9, "id": "ndg_…", "reason": "send_failed", "review_id": "rr_…", "payload": { "state": "failed", "error": "<vendor-scrubbed reason>", "from_state": "approved", "agent_retryable": false, "next_action": "compose_and_submit_a_new_message" } }{ "seq": 14, "id": "ndg_…", "reason": "front_run_next", "review_id": "rr_…", "payload": { "state": "sent", "sent_message_id": "msg_…", "your_parent_revision": 3, "current_revision": 5, "diff_available": true } }decision on a sent payload is "edited" when the reviewer changed your draft and "approved"
when they released it as-is: derived exactly as get_review_feedback derives it, so the two can
never disagree. send_path is human_reviewed | reviewer_approved | graduated_auto |
agent_direct. thread_ref is present only when the review actually threads one: a bare send has
none, and an empty string would read as “there is a thread and it is unnamed”. front_run_next is
keyed on (review, terminal state, your parent_revision), so a retry loop hammering the same 409
collapses to one row.
Three ways to know a review is finished
Section titled “Three ways to know a review is finished”- Push (primary): drain
list_review_events/wait_for_review_eventand stop on the terminal nudge. An acked terminal nudge can never re-surface. - Poll (definitive, per review):
GET /v1/reviews/{id}carriesclosed,send_errorandsend_path:closed: trueforsent,auto_sent,cancelledandfailed.failedis included deliberately: nothing re-approves a failed review, so treating it as open means waiting forever.send_error: the vendor-scrubbed delivery failure, present on a failed review.send_path: how the message was released, without fetching the turns.
- After a crash, re-derive: your un-acked frontier is durable, so drain first. For anything still
open,
list_reviews?state=needs_review&state=rejected&…, filter client-side onagent_id == self, thenget_review(id)and readclosed. Every send now returns areview_id: including the direct-send202: so there is always a handle to come back to.
The rest of the surface
Section titled “The rest of the surface”The contract is one provisional-0.x surface (no tiers). Beyond the five core shapes it covers
the whole built loop. The four most common are shown below with example JSON; the full set is
enumerable at runtime via CONTRACT_MANIFEST.shapes.
Chat: post_review_chat request
Section titled “Chat: post_review_chat request”Posts an agent_question turn on the review thread (the first chat flips in_review → chatting).
Idempotent on the Idempotency-Key header so a retry never doubles the turn.
{ "text": "Do you want me to lead with the pilot ROI, or the case study?" }Category: an element of list_categories
Section titled “Category: an element of list_categories”The shared customer registry your agent fuzzy-matches a draft against (the matching is
yours: no model on our side). Keyed on the opaque cat_…; the name/description are mutable
display metadata. state is the graduation rung; the maturity counters (clean_approval_count,
drift_count) drive the gate.
{ "id": "cat_…", "name": "Sales Outreach", "description": "cold outbound to prospects about the pilot", "scope": "org_shared", "state": "supervised", "author_kind": "agent", "rule_high_water": 12, "rules_version": 7, "never_graduate": false, "clean_approval_count": 18, "drift_count": 1 }Graduation status: get_graduation_status
Section titled “Graduation status: get_graduation_status”Read-only progress toward the next rung (supervised → auto_notify → auto_silent). Agents
propose graduation; only a human flips the bit (D16). can_graduate reports whether a human
graduate would succeed right now; the maturity gate (approvals_met + age_met) is the
auto_silent precondition.
{ "category_id": "cat_…", "state": "supervised", "next_state": "auto_notify", "never_graduate": false, "clean_approval_count": 18, "graduate_min_approvals": 20, "approvals_met": false, "age_hours": 73.5, "graduate_min_age_hours": 48, "age_met": true, "maturity_gate_met": false, "drift_count": 1, "drift_demote_after": 3, "can_graduate": false }Risk dial: get_risk_dial
Section titled “Risk dial: get_risk_dial”The resolved brand-risk dial: the account default plus each category’s overrides, every value
already in force. Agents read it; flipping the dial is a console (human) action. A null
override field means the category inherits the account default.
{ "account": { "min_confidence": 0.7, "first_contact_gate": true, "drift_demote_after": 3, "canary_rate": 0.1, "graduate_min_approvals": 20, "graduate_min_age_hours": 48, "auto_send_cap_per_day": 200 }, "categories": [ { "category_id": "cat_…", "min_confidence": 0.85, "first_contact_gate": null, "effective": { "min_confidence": 0.85, "first_contact_gate": true, "drift_demote_after": 3, "canary_rate": 0.1, "graduate_min_approvals": 20, "graduate_min_age_hours": 48, "auto_send_cap_per_day": 200 } } ] }Reviewer decision: the BYO review-agent plane (M8)
Section titled “Reviewer decision: the BYO review-agent plane (M8)”A linked review-agent’s decision request, and the result. action is approve | edit | reject | escalate; revision is the optimistic CAS (a mismatch is 409 stale with no mutation: the
human always wins, D17; see the 409 taxonomy). The result’s kind is sent (the platform relayed with the composer’s
creds) or sent_to_human (returned to the human queue); forced_by_breaker names the tripped
circuit breaker when one overrode the intent.
// request{ "action": "edit", "revision": 4, "version": 9, "subject": "Q3 pilot: quick question", "body": "Can you do Thursday 2pm? Pilot ROI attached.", "feedback": "tightened the CTA" }// result{ "kind": "sent", "sent": true, "sent_to_human": false, "message_id": "msg_…", "thread_id": "thread_…", "review": { "id": "rr_…", "state": "sent", "revision": 5 } }Enumerate the full set
Section titled “Enumerate the full set”import { CONTRACT_MANIFEST } from "@extrovert.dev/sdk";CONTRACT_MANIFEST.shapes; // every published contract shape, by nameCONTRACT_MANIFEST.core_shapes; // ["ReviewIntent","ReviewFeedback","DiffJson","Rule","ReviewEvent"]Beyond what’s shown here, the surface also includes submit + review states, the per-message
revision (submit_revision) + restamp, the rules + audit/undo store, and backlog reconciliation +
pacing. Each shape is an exported, documented TypeScript type in @extrovert.dev/sdk (re-exported from
the SDK’s contract module) and is mirrored on the MCP tools. The same shapes are
the OpenAPI component schemas served at /openapi.yaml.
Where a review comes from
Section titled “Where a review comes from”There is no unsupervised outbound verb on the agent plane. send, reply and forward all
create a review row, and the account’s resolved
review policy decides what happens to
it. Forward is included on purpose: it is an outbound message to arbitrary new recipients that
quotes an inbound thread, so leaving it unenforced would have made it the documented bypass: and the
worst one, because it exfiltrates a received conversation.
| Resolved policy | A call carrying an intent | A call carrying no intent |
|---|---|---|
require_review | 202 queued_for_review: a human decides | 422 intent_required; nothing sent, nothing queued |
allow_direct | Queued (you asked for review by opting in) | Released immediately; 202 {status:"sent", message_id, review_id} |
auto_send_graduated | Auto-sent if the category is graduated and every gate passes; otherwise queued with gate_outcome: "held:<reason>" | 422 intent_required |
Contact-list blocks, unsubscribe suppression and the outbound quota are checked at submit as well
as at delivery, so a human never approves a draft that then fails on the way out. Those rejections
(403 recipient_blocked, 422 recipient_suppressed, 403 quota_exceeded) come before the intent
gate: an intent would not fix them.
A reply’s Re: subject and recipients, and a forward’s subject and quoted parent body, are
materialized at submit. The human reviews the exact bytes that will be delivered, and a reviewer’s
edit to a forwarded quote is what actually goes out. reply_to, custom headers and attachments
ride the review row through to delivery; submit_revision may replace the attachments (omit the field
to keep them, [] to clear them). A forward is not threaded to its parent: the delivered message
carries no In-Reply-To.
What a composing agent may legally do
Section titled “What a composing agent may legally do”The state machine is actor-aware. These are the edges available to the agent that composed the draft; approve / edit / reject / reconfirm and the risk dial are human authority and are never agent verbs (D17).
| Draft state | Legal composer verbs |
|---|---|
needs_review | submit_revision (a redraft is legal from needs_review: it bumps revision, which is a real change), post_review_chat (posts your question without opening the draft: an agent may not conjure a reviewer), restamp_review, cancel_review |
in_review | post_review_chat (flips to chatting), cancel_review |
chatting | submit_revision, post_review_chat, cancel_review |
rejected | submit_revision, cancel_review |
approved | nothing: it is being delivered. Read, and wait for the terminal event. |
failed | cancel_review only. This is the one legal close-out for a failed send; you cannot re-send it. |
sent / auto_sent / cancelled | nothing, ever. Reads only. |
get_review and list_review_events are legal from every state, which is why a 409’s
allowed_action hints are never empty: from a terminal state the honest answer is “nothing will
mutate this: go look at what happened”.
stale and stalled appear in the state enum but have no producer today: nothing correlates an
inbound reply into stale and nothing runs the composer-deadline sweep that would write stalled.
Do not write a branch that waits for either, and note that the “human can reconfirm a stale draft”
affordance does not exist in the product.
The 409 taxonomy: what each conflict means
Section titled “The 409 taxonomy: what each conflict means”Every review-loop error is RFC-9457 problem+json with a code from the
closed enum. Branch on code, never on status alone: three of
these used to be one indistinguishable 409 conflict, and one of the three can never succeed.
code | Means | Rule |
|---|---|---|
stale | The (revision[, version]) you named is no longer current: a human moved the draft. No mutation. | Retry, ≤3. Re-read, re-apply on top of theirs, resubmit with the new parent_revision. |
born_stale | Your redraft was built against an older rule high-water. No mutation; you were re-nudged. | Retry, ≤1 per rule high-water. get_rules, retain its composition_token, re-apply, and submit with that token: or restamp_review if nothing genuinely changed. |
wrong_state | The verb is illegal from the current state, but the draft is live. | Never retry the same verb. Read the allowed_action hints. |
terminal | Already sent / auto_sent / cancelled. | Stop. A front_run_next event is waiting with the outcome. |
send_needs_reconciliation | An unconfirmed send attempt is parked for recover-by-Message-ID. | Do not resend. Poll. |
idempotency_conflict | Same Idempotency-Key, different body. | Caller bug. Do not retry with that key. |
The problem carries its recovery facts as repeated errors[] entries: state, revision,
version, and one allowed_action per legal verb: so a stale retry needs no extra GET. A
terminal problem swaps the CAS keys for sent_message_id. The allowed_action list is derived from
the transition table itself, so it can never advertise a verb that would 409.
restamp_review is for the honest case only: “I re-read the rules and nothing needed to change”. Do
not use it to clear a born-stale flag on a draft whose body did change: that makes the draft lie to
the born-stale accounting, and the reconciliation sweep will release a pre-rule draft.
How the contract stays honest
Section titled “How the contract stays honest”The published shapes can never silently drift from the wire: a conformance/drift test validates the canonical example JSON above against both the OpenAPI component schemas (Go) and the SDK types (TS), and asserts the version is reconciled across all surfaces. A negative test proves the guard is not a tautology. Rename a field anywhere and the build breaks.
The skills are part of the contract
Section titled “The skills are part of the contract”D14 publishes a skill + SDK contract. The judgment that drives the loop lives in the agent skills, which code against exactly these shapes:
extrovert-send-email: the monitoring loop: drain nudges → apply-rule / answer-chat / re-check-pending / redraft; read a diff; stop on a terminal event; process a few at a time, not all 500.extrovert-writing-rules: match a draft to a category; classify where a rule applies (house-style vs category); turn a diff + comment into a rule body; propose graduation.
Authoring house-style rules, predefining categories and setting the risk dial / review policy are console actions, not a skill: they are human authority by design, and an agent can never widen its own policy.