Skip to content

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.

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" } }

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?" } ] }

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.

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 } }

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.

reasonEmitted whenWhat your agent does
redraft_requestedA 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_addedA human posted a comment on the review thread. (Your own question to the human emits nothing.)Answer, or redraft.
rejectedA human rejected the draft.Learn from the rejection; redraft or stop.
rule_changedA 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_categoryThe draft’s category graduated or was reconciled, so its routing may have changed.Re-check the category assignment and resubmit.
propagate_general_ruleA new general rule applies to sibling pending drafts.Apply it to suggested_batch siblings: never the whole queue.
sentThe 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_failedThe 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.
cancelledThe review reached cancelled: withdrawn by you, by a human, or as the close-out of a failed send.Done.
front_run_nextYou 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, failed or cancelled emits exactly one terminal nudge, and it is the last and highest-seq nudge 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.)

reason: sent
{ "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_…" } }
reason: sent (graduated / direct)
{ "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" } }
reason: send_failed
{ "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" } }
reason: front_run_next
{ "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.

  1. Push (primary): drain list_review_events / wait_for_review_event and stop on the terminal nudge. An acked terminal nudge can never re-surface.
  2. Poll (definitive, per review): GET /v1/reviews/{id} carries closed, send_error and send_path:
    • closed: true for sent, auto_sent, cancelled and failed. failed is 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.
  3. 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 on agent_id == self, then get_review(id) and read closed. Every send now returns a review_id : including the direct-send 202: so there is always a handle to come back to.

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.

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?" }

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 }

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 }

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 } }
import { CONTRACT_MANIFEST } from "@extrovert.dev/sdk";
CONTRACT_MANIFEST.shapes; // every published contract shape, by name
CONTRACT_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.

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 policyA call carrying an intentA call carrying no intent
require_review202 queued_for_review: a human decides422 intent_required; nothing sent, nothing queued
allow_directQueued (you asked for review by opting in)Released immediately; 202 {status:"sent", message_id, review_id}
auto_send_graduatedAuto-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.

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 stateLegal composer verbs
needs_reviewsubmit_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_reviewpost_review_chat (flips to chatting), cancel_review
chattingsubmit_revision, post_review_chat, cancel_review
rejectedsubmit_revision, cancel_review
approvednothing: it is being delivered. Read, and wait for the terminal event.
failedcancel_review only. This is the one legal close-out for a failed send; you cannot re-send it.
sent / auto_sent / cancellednothing, 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.

codeMeansRule
staleThe (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_staleYour 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_stateThe verb is illegal from the current state, but the draft is live.Never retry the same verb. Read the allowed_action hints.
terminalAlready sent / auto_sent / cancelled.Stop. A front_run_next event is waiting with the outcome.
send_needs_reconciliationAn unconfirmed send attempt is parked for recover-by-Message-ID.Do not resend. Poll.
idempotency_conflictSame 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.

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.

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.