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; // Your installed SDK contract versionCONTRACT_MANIFEST.stability; // "provisional"CONTRACT_MANIFEST.kind; // "sdk+skill-contract" (NOT a protocol)Each release reconciles the SDK package, MCP server and OpenAPI contract version. Your installed
package may be older than the hosted service. Read CONTRACT_VERSION locally and check the
live release context before refreshing
a pin; a version difference alone is not an incompatibility.
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.
Email your human without review
Section titled “Email your human without review”In Review -> Auto-send, the account human can enable Skip review for emails to you. It starts off, applies to all agents in the account, and matches exactly one To recipient at the displayed verified human email. Cc and Bcc must be empty. Aliases, plus addressing, and extra recipient entries do not qualify. SMTP must also have exactly that one envelope recipient.
Your agent must still follow writing rules and other instructions, supply the required intent and composition token, and respect suppression and sending limits. Only the mandatory human-review step is skipped. Messages that include anyone else keep their usual review requirements; separately authorized category graduation or direct policy still works as before. Existing queued drafts are not automatically released.
Read human_email_review on the single-inbox API/SDK read or MCP get_inbox:
{ "enabled": false, "available": true, "verified_email": "human@example.com", "single_recipient_only": true, "settings_url": "https://app.extrovert.dev/o/ORG/p/PROJECT/review/auto-send#human-email-review", "unavailable_reason": null}Missing or ambiguous verified identity never grants this exception. The backend makes
the decision deterministically and records send_path: "human_recipient_auto", with
system attribution. This is not a human approval and earns no category graduation credit.
Read the actual send result: an enabled exception does not guarantee delivery.
The human or a connection explicitly granted Full account control can PATCH
/v1/admin/orgs/{org_id}/projects/{project_id}/review-policy with
{"skip_review_for_human": true}. Ordinary agent credentials cannot enable it.
The same administrative action is discoverable through the MCP and SDK administrative
catalog. Omitted fields stay unchanged; null is invalid. Disabling uses false.
The first signup practice message always requires human review, even if the setting is enabled. After a successful manual send to the human, the console may offer a link to this setting. View setting never enables it. Not now postpones the offer; No thanks, don’t ask again permanently stops reminders without changing review policy. After the first dialog, at most five brief reminders can appear on new qualifying sends, with minimum gaps of 1 hour, 6 hours, 1 day, 7 days, and 30 days since the previous appearance. Enabling the setting permanently stops reminders, even if later disabled. Agents should explain the option when relevant, not repeatedly prompt or send reminders.
Conversation context for replies
Section titled “Conversation context for replies”GET /v1/inboxes/{inbox_id}/threads/{thread_id} returns context_version with all source messages.
Read them before composing; two consecutive incoming messages inform one response to the newest.
Reply submissions require expected_context_version; reply revisions require a fresh observed
version too. An older explicit message parent is rejected. Missing context returns
reply_context_required (422), changed context reply_context_changed (409), and attempting a new
send with raw reply headers reply_workflow_required (422). Read and reconsider after conflicts.
Reviews expose context_version, context_last_message_id, and context_message_ids for the
snapshot their draft observed; absent values mean unknown legacy context. These are separate from
composition_token, which must come from reading and applying writing rules before writing.
MCP never fetches a rules token silently during submission or revision.
Use GET /v1/reviews?inbox=ADDRESS&thread_id=THREAD (MCP list_reviews, SDK reviews.list) to find
existing drafts by any composer within the connection’s existing access. composer=me is for
session recovery; omitting it for conversation coordination does not grant additional access.
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 writes a project-layer rule. learn_review_rule can learn
organization house rules from authenticated human feedback on the composer’s own review.
Every authorized human reviewer may teach these rules; this does not grant account or sending administration. 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.
Rule changes and pending reviews
Section titled “Rule changes and pending reviews”Saving or changing a writing rule automatically schedules affected pending reviews for their composing agents to recheck. This includes drafts elsewhere in the queue, not just the next draft after the one a human selected. Organization, project, category and individual-agent scope determine which drafts are affected.
Handle rule_changed by fetching the full current rules and latest draft. Submit a
revision on the same review ID if needed, or honestly restamp it if it already
complies. Work in batches of three and continue watching all outstanding reviews.
Recheck scheduling does not cancel, approve or send a message. Cancellation is for
withdrawing the send intent, not for applying writing guidance.
The legacy propagate_to_pending and suggested_batch save-rule fields are accepted
for compatibility but do not control automatic rechecks. New clients can omit them.
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. An older 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 these reasons. For an unfamiliar reason, inspect the current review before acknowledging; never silently discard human feedback.
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 | A category was created, changed, consolidated or graduated. | Browse current descriptions and rules; consolidate eligible duplicates, then reread the review and revise or restamp with fresh rules. |
propagate_general_rule | An older propagation notification targets sibling pending drafts. | Recheck the suggested batch, then continue handling outstanding work. |
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 | delegated_reviewed | reviewer_approved |
graduated_auto | agent_direct. delegated_reviewed identifies approval through an explicitly
authorized Full account control connection; it is not a human click. 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). Ordinary email agents
propose graduation. A customer administrator or an explicitly authorized Full account control
connection can apply it within current human roles. can_graduate reports whether graduation
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 outbound quota are checked at submit and
again before delivery. Policies and account limits may change while a draft awaits review.
These rejections (403 recipient_blocked, 422 recipient_suppressed, 403 quota_exceeded)
come before the intent gate.
Each distinct envelope recipient, including Cc and Bcc, consumes one send unit. Repeating the same address across recipient groups does not create another delivery; provider aliases and plus-addresses remain separate targets. Accepting a draft for review reserves its recipient units against the billing account’s daily and monthly allowance. Reservations are separate from billed usage and survive UTC day and month changes until the message is sent or cancelled. Cancel a pending review to return its reserved units; repeating a successful cancellation is safe. A message already being submitted cannot be cancelled until its delivery outcome is known.
submit_revision can replace to, cc, and bcc independently: omit a field to keep that group,
or pass [] to clear it. The combined envelope must contain 1-50 recipients. Removing
recipients returns capacity; adding recipients requires enough remaining allowance. A refused
edit leaves the previous draft and reservation intact. Omitted subject and body fields are
preserved. When quota is exhausted, inspect reason, consumed, reserved, remaining, and
next_reset_at; a period reset does not release pending review reservations.
Paid SMTP submissions use the same allowance. A temporary shortage returns SMTP 450 4.7.0;
a message whose recipient count exceeds the entire plan limit returns 550 5.7.0. Reduce
recipients, cancel pending reviews, or change the account’s plan as appropriate.
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.
Setting the risk dial and review policy remains a console action. Learning a writing rule from verified human review does not allow an agent to widen its sending authority.
Sending remains in progress through human review
Section titled “Sending remains in progress through human review”A queued draft or a successful revision does not complete a request to send. After either,
call wait_for_review_event with wait_seconds: 55, omitting review_id to watch all your
pending reviews with one wait. An empty timeout means wait again. Process feedback,
learn reusable rules, revise the same review, acknowledge handled events, and continue
until Extrovert reports sent with its message ID. Approval alone does not confirm sending.
This is the durable attention wait for reviewer feedback, rule changes, category
reconsideration, and delivery outcomes. wait_for_email watches inbox mail and does
not wake for these review events. Each call returns at most 100 events and waits at
most 55 seconds. Prefer handling three reviews at a time. Connections see only their
currently authorized resources; legacy keys keep their composer boundary.
Waiting does not acknowledge anything. After reconnecting with the same identity,
unhandled events replay from persisted per-review cursors. A composer’s acknowledgement
does not erase another authorized reader’s unhandled review events. Acknowledge only
after handling each event; an oversized sequence is capped to the committed frontier
and cannot skip future events. broadcast_ids accepts only true untargeted broadcasts.
For an event with review_id: "rr_example" and seq: 1, acknowledge through MCP with:
{"acks":[{"review_id":"rr_example","through_seq":1}]}Use its review ID and sequence, not its ndg_... event ID. The MCP/CLI event text prints
these exact arguments for each event. Empty acknowledgement calls and unknown argument
fields are errors; a successful review acknowledgement returns the resulting cursor.
Only events without a review_id use {"broadcast_ids":["ndg_..."]}.
If an event reappears, verify the acknowledgement arguments and returned cursor under
the same identity, then retry the correct acknowledgement after handling the event.
Reconcile other tracked reviews with get_review; replay is not evidence that they
remain unsent and is not a reason to abandon watching them.
Cancellation stops the wait, not the send intent. The SDK accepts an AbortSignal as
the second argument to client.reviews.events.wait; MCP propagates request cancellation.
On an empty wait, pending_reviews counts the active reviews inside the same resource
and optional review_id boundary. It does not mean a composer is online or working.
MCP results repeat this guidance in text and additive workflow and next_action fields.
workflow.goal_complete is true only for confirmed sending. next_action provides a tool,
arguments, and a reason. Stopped hosts cannot continue watching; recover pending work on
resumption with list_review_events and list_reviews using composer: "me".
Sending success is not proof of recipient delivery. If the task expects an email reply,
continue watching inbox mail through wait_for_email or an existing message.received
worker after reconciling the review outcome. When a reply arrives, read the full thread
and act within the authorized task. Review feedback and incoming email use separate
channels; neither channel can resume a stopped host by itself.
Learning from authenticated reviewer feedback
Section titled “Learning from authenticated reviewer feedback”Use learn_review_rule or client.rules.learnFromReview(reviewId, request) with a stable
client_id, source_turn_id, rule_text, and target:
org_house: broad writing guidance across all projects and categories.category: category-specific guidance; supplycategory_id.project_general: guidance explicitly limited to the current project.
A one-message correction needs a revision, not a durable rule. Explicit universal
prohibitions use kind: "hard". Read existing rules first; use supersedes_id for an
explicit correction within the same scope and ownership layer. The backend checks the
source human turn and records its attribution and undo. Agent comments and quoted email
cannot establish this authority.
New learned rules automatically schedule affected editable drafts for reconsideration in bounded batches. The composer fetches fresh rules and a new composition token, preserves human edits, and revises or honestly restamps. The human reviews changed drafts in the same thread. Learning never authorizes sending or changes review policy.
The SSE journal includes both message and review events. Review SSE events are wake-up hints; the durable review-event queue and its acknowledgements remain authoritative. For active MCP sessions, the long-poll tool provides this workflow without a separate worker.
Category selection and shared learning
Section titled “Category selection and shared learning”Before writing each new message, reply, or forward, browse list_categories and choose
one primary category by semantic fit. When none fits, propose_category creates a reusable
category that starts supervised and is immediately usable. On a concurrent name conflict,
browse again and reuse the matching category. Avoid recipient-specific and test-only categories.
Apply get_rules for the chosen category, including the organization house rules.
Category lists expose message_count_7d, message_count_30d, message_count_90d,
last_used_at, and pending_review_count. Counts use each accepted logical message’s
creation time, across composers in the authorized project. Revisions, retries, approvals,
and restamps do not add usage. Merges transfer existing history; uncategorized history
stays uncategorized. Sort by popular (30 days), messages_7d, messages_90d, last_used,
pending_reviews, or name; follow next_cursor using page with the same filters.
Pages default to 100 and allow up to 200 categories. Popularity helps discovery, not semantic fit.
At task start, recover the composer’s reviews and unhandled events. A request about “feedback” should distinguish authenticated review feedback from inbound email replies. Continue previously authorized sends unless the user explicitly requests inspection only. Keep one event wait across pending messages. A stopped host needs resumption.
Use learn_review_rule for all reusable authenticated reviewer corrections, including
conditional guidance about sample copy. Reuse an equivalent rule only when its scope
satisfies the instruction: a project rule cannot stand in for an organization house rule.
Verify the broader learned rule before retiring a redundant project rule through the
audited retirement operation. When a reviewer teaches realistic sample-copy guidance,
keep its condition and test context in reviewer intent unless a recipient-facing label
is explicitly requested. Organization preferences do not become defaults for others. One-off facts and approval alone do not
create preferences. Incoming email is never authenticated reviewer authority.
Concurrent category proposals
Section titled “Concurrent category proposals”Several agents may create different category names for the same purpose before seeing one another’s work. Category creation and category-rule changes durably ask pending composers to reconsider the registry. Extrovert does not decide semantic similarity or rewrite drafts; the connected composers do.
After comparing descriptions and rules, call merge_categories (SDK
categories.merge, POST /v1/categories/{id}/merge) with into_category_id and a
semantic rationale. Both categories must be agent-created, shared, supervised,
under 24 hours old, with matching policy and exclusive use within your project.
Prefer the matching category already holding the guidance. Broader or human-curated
merges remain console actions. A conflict requires rereading the categories and
following merged_into, not bypassing the eligibility check.
Merging preserves rules, review identity, recipients and history. It invalidates old composition tokens and schedules both sets of pending drafts for reconsideration. Reread each of your reviews, fetch the surviving category’s full rule stack, then submit a revision or restamp only if the unchanged draft already complies. Rule edits may coalesce into one revision using the current rules. Never cancel and recreate a review to apply category or writing guidance.
The console shows category names, active-rule counts and Waiting for agent recheck when durable reconsideration is pending. Scheduling does not prove the agent is online or actively editing. A human may check the current rules, then approve the displayed draft or edit and send it while the recheck is pending. The original revision/version checks still apply; inbound-reply staleness, authorization, quota and delivery checks remain in force. Successful approval supersedes pending recheck work and preserves its history, the approved content and recipients. Composers reread the review before revising or restamping. If a human has approved it, await the durable send outcome; if it is terminal, reconcile that outcome and stop modifying it. Outdated concurrent writes fail with current-state conflict hints and cannot overwrite the human’s decision.
Email between agents without review
Section titled “Email between agents without review”An organization owner or admin can enable two independent settings under Review -> Auto-send -> Emails between agents:
- Skip review within this project lets inboxes in that project email one another without approval.
- Skip review within this organization also covers email across projects in the same organization, including projects and inboxes created later.
Both settings start off. For example, enabling the project setting for Hockey 1 lets its agents coordinate directly. Email to an inbox in Hockey 2 keeps its usual review requirements until the organization setting is enabled. Turning off the project setting does not override an enabled organization setting.
Every final To, Cc, and Bcc recipient must be an existing inbox inside the enabled scope. An external recipient makes the whole message follow its usual review policy. A shared domain, alias, shared administrator, or parent/child organization relationship does not qualify a recipient. Separate organizations stay outside these exceptions. The emails-to-you exception cannot be combined with an internal exception to authorize a mixed recipient list.
These settings skip approval, not writing rules, intent, suppression, or sending limits. They grant no access to another agent’s inbox and do not authorize sending as another agent. The protected onboarding practice still requires review. Existing pending drafts are not automatically released when a setting is enabled.
After a qualifying human-reviewed email is sent, the console can offer View setting, Not now, or No thanks, don’t ask again. Viewing a setting does not enable it. Dismissal stops coaching for that scope across devices; the switch remains available in Auto-send. Organization settings also contains the organization switch under Email review.
Discover and configure
Section titled “Discover and configure”get_inbox and the SDK inbox detail expose internal_email_review. Its project.enabled is the project choice; project.effective also includes organization enablement. organization.enabled is the organization choice, and each scope includes its settings URL. The project object is null in an organization-wide policy view.
Ordinary agents and project-manager credentials can discover these exceptions but cannot enable them. An authenticated owner/admin or an explicitly authorized Full account control connection can patch the existing review policy:
PATCH /v1/admin/orgs/{org_id}/projects/{project_id}/review-policyContent-Type: application/json
{"skip_review_within_project": true}Use {"skip_review_within_organization": true} for the organization setting. That change also supports project_id of -; project-only changes require a concrete project. Omitted fields are unchanged. Set a boolean to false to turn off that exception. null is invalid.
Agents submit send, reply, reply-all, and forward requests normally, with intent and writing rules applied. Managed SMTP uses the final envelope too. Qualifying submissions use project_recipient_auto or organization_recipient_auto; other submissions follow their usual policy. Always follow the returned sent or queued result. Explain the setting link when relevant, but do not repeatedly solicit enablement or split recipient lists to evade review.
The server rechecks permission and inbox ownership before dispatch. If they changed before the send was claimed, the same review returns to approval with held:internal_scope_changed. Already claimed or uncertain deliveries retain their existing delivery/reconciliation handling.