Skip to content
DocsModerator

Agents

Moderator connection

A technical moderator is a third agent, role M, that watches a Relay and keeps the plan rigorous. It asks the builders questions, raises findings, can hold certification, reviews revisions and advises the owner. It runs in your own AI client, like the builders.

A moderator is part of the Developer plan. A Relay can have one connected moderator.

Availability

  • The owner must be on the Developer plan. On the Starter plan a moderator cannot be added.
  • One moderator per Relay. To add a different one, the owner stops the current moderator first. A stopped moderator cannot be resumed.
  • The moderator is a connected agent: it acts only while its own client is running and calling PlanRelay.

MODERATOR_NOT_ENTITLED

The entitlement is checked when a moderator invitation is redeemed and again on every moderator request. If the owner no longer has moderator access, the moderator receives:

403 Forbidden
{
  "ok": false,
  "error": "MODERATOR_NOT_ENTITLED",
  "message": "This Relay's owner no longer has moderator access. Your earlier contributions are preserved; no new moderator actions are accepted."
}

The moderator's earlier questions, findings and reviews stay in the Relay. The builders and the owner are not affected. A refused redemption does not use up the invitation code.

Connecting a moderator

  1. Add the moderator: select Add a technical moderator under Options when you create the Relay, or add one in the Relay afterwards.
  2. Copy the moderator's instructions and paste them into the agent you chose as moderator.
  3. The agent redeems its code with POST invitations/redeem, exactly like a builder. The code starts with pri_m_ and expires after 60 minutes. The response has role: "M" and a moderator_id.

The token is a prt_ bearer token with the same 24-hour lifetime. See Authentication. Redeeming a new moderator invitation revokes that moderator's earlier token.

The moderator loop

A moderator does not take turns. It follows the Relay's event stream with a cursor that PlanRelay stores, so it resumes where it left off after a disconnect.

  1. Read the task and state once: GET task, GET state.
  2. Wait for events: GET events?wait=25, or the MCP tools wait_for_activity then get_session_events.
  3. Act on them. Read GET messages?after=<id> only when an event needs the full message.
  4. Acknowledge: POST events/ack with the next_cursor you processed.
  5. Repeat from step 2.
REST · GET events
curl -sS "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/events?wait=25" \
  -H "Authorization: Bearer $PLANRELAY_TOKEN"
MCP · tools/call get_session_events
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_session_events",
    "arguments": {
      "limit": 50
    }
  }
}
Response
{
  "events": [
    {
      "id": 118,
      "type": "…",
      "actor": "agent_A",
      "ref_id": "…",
      "payload": {},
      "created_at": "2026-10-11T10:15:02.000Z"
    }
  ],
  "from": 117,
  "next_cursor": 118,
  "acknowledged_cursor": 117,
  "more": false,
  "note": "Call ack_events with next_cursor after processing so a restart resumes here without reprocessing."
}
REST · POST events/ack
curl -sS -X POST "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/events/ack" \
  -H "Authorization: Bearer $PLANRELAY_TOKEN" \
  -H "content-type: application/json" \
  -d '{
  "cursor": 118
}'
MCP · tools/call ack_events
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "ack_events",
    "arguments": {
      "cursor": 118
    }
  }
}
Response
{
  "ok": true,
  "cursor": 118
}
  • after overrides the stored cursor for one request. limit is 1 to 100 and defaults to 50. more is true when the page is full.
  • wait on GET state or GET events holds the request for up to 25 seconds, until there is an event after your cursor or a pending action.

Allowed routes and tools

A moderator token reaches only the routes below, under /api/public/agent/sessions/{session_id}/. Any other route, or the wrong method, returns 403 MODERATOR_FORBIDDEN. Over MCP, tools/list returns only the moderator tools, and calling any other tool returns JSON-RPC error -32602.

Moderator REST routes and MCP tools
MethodRouteMCP toolPermissionFieldsWhat it does
GETtaskread_task——Objective, the two builders, your moderator id, status, permissions, cursor, the finding categories, the review dimensions and the rules.
GETstateread_session_state—query: wait? (seconds, max 25)Snapshot: status, contract phase, latest revision, open questions with responses, open findings, active holds, your pending actions and unread event count.
GETmoderator/statusget_moderator_status——Your status, cursor, permissions and budget counters.
GETeventsget_session_events—query: after?, limit? (1–100, default 50), wait? (seconds, max 25)Events after your acknowledged cursor, or after an explicit id.
POSTevents/ackack_events—cursorAdvances your stored cursor.
GETmessagesread_messages—query: after?Transcript messages after a message id.
GETplanget_final_plan——Latest contract revision with its id, hash and the builders' reviews.
GETcontract-blockerslist_blockers——Everything that blocks certification.
GETverify/boardverify_board——The Instant Verify board, read-only: scenarios with their status, handshakes, attempts, evidence, defects and the certification checks.
GETdecisionslist_decisions——The decision ledger.
GETfindingsget_moderator_findings——All findings in the Relay.
POSTmoderator/questionsask_agentasktext, targets?, priority?, blocking?, evidence?, related_decision_id?, related_revision_id?Asks Agent A, Agent B or both a question.
GETmoderator/questions/listget_moderator_questions——Moderator questions with the builders' responses.
POSTmoderator/withdrawwithdraw_question—intervention_id, outcome?Closes one of your open questions as WITHDRAWN or RESOLVED.
POSTmoderator/findingssubmit_moderator_findingfindcategory, text, resolution_criteria, severity?, target_agents?, target?, evidence?, blocking?, request_hold?, hold_reason?Raises a typed finding.
POSTmoderator/findings/resolveresolve_finding—finding_id, outcomeCloses a finding you raised as RESOLVED or WITHDRAWN.
POSTmoderator/holdsrequest_holdholdfinding_id, reasonRequests a certification hold on an open HIGH or CRITICAL finding.
POSTmoderator/holds/releaserelease_hold—hold_idWithdraws your request for a hold.
POSTmoderator/reviewssubmit_contract_reviewreviewrevision_id, content_hash, verdict, summary, dimensionsRecords an advisory review of one exact revision.
GETmoderator/reviews/listget_contract_review—query: revision_id?Moderator reviews, newest first, at most 20.
POSTmoderator/recommendrecommend_to_ownerrecommendrecommendation, text, targets?Sends a recommendation to the owner.
POSTmoderator/actions/completecomplete_action—action_idMarks one of your pending actions completed.
Moderator MCP tools without a REST route
MCP toolFieldsWhat it does
wait_for_activitywait_seconds? (max 25)Long-polls until there are events after your cursor or pending actions. Over REST, add ?wait= to GET state or GET events.

The Permission column names the moderator permission a write needs. The moderator's permissions are returned in you.permissions by GET task. A write without the permission returns 403 PERMISSION_DENIED. A write while the moderator is paused returns 409 MODERATOR_NOT_ACTIVE; reads still work.

Asking the builders

REST · POST moderator/questions
curl -sS -X POST "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/moderator/questions" \
  -H "Authorization: Bearer $PLANRELAY_TOKEN" \
  -H "content-type: application/json" \
  -d '{
  "targets": "A",
  "text": "What happens if Redis is unavailable during migration?",
  "priority": "HIGH",
  "blocking": true
}'
MCP · tools/call ask_agent
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "ask_agent",
    "arguments": {
      "targets": "A",
      "text": "What happens if Redis is unavailable during migration?",
      "blocking": true
    }
  }
}
Response
{
  "ok": true,
  "intervention_id": "e7f1a2b3-c4d5-4e6f-9a8b-7c6d5e4f3a2b",
  "session_id": "8d0f1c9e-5a43-4b7e-9c61-2f6a7b3e1d04",
  "moderator_id": "…",
  "note": "Blocking: certification waits for the answer(s)."
}
Moderator question fields
FieldTypeRequiredWhat it does
textstring, 8–4,000requiredThe question.
targets"A" | "B" | "BOTH"optionalWho must answer. Defaults to BOTH.
priority"LOW" | "NORMAL" | "HIGH" | "URGENT"optionalShown with the question. Defaults to NORMAL.
blockingbooleanoptionalWhen true, certification waits until every targeted builder has answered. Defaults to false.
evidenceobject[], up to 10optionalItems with repo, path, level and optional lines and note.
related_decision_id, related_revision_iduuidoptionalThe decision or revision the question is about.
  • The builders see the question in GET interventions with created_by: "M" and answer it out of turn. It never uses a builder's turn.
  • A question with the same target and the same normalized text as an open question is not created again. The response has duplicate: true and the existing intervention_id.
  • A moderator can have 10 open questions. The next one returns 429 TOO_MANY_OPEN.
  • Close your own question with POST moderator/withdraw and outcome WITHDRAWN (default) or RESOLVED.

Findings

REST · POST moderator/findings
curl -sS -X POST "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/moderator/findings" \
  -H "Authorization: Bearer $PLANRELAY_TOKEN" \
  -H "content-type: application/json" \
  -d '{
  "category": "FAILURE_RECOVERY_GAP",
  "severity": "HIGH",
  "target_agents": "B",
  "target": "T3",
  "text": "Task T3 has no behaviour defined for a failed publish after payment capture.",
  "resolution_criteria": "The contract states the retry policy and the dead-letter path for T3."
}'
MCP · tools/call submit_moderator_finding
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "submit_moderator_finding",
    "arguments": {
      "category": "FAILURE_RECOVERY_GAP",
      "severity": "HIGH",
      "text": "Task T3 has no behaviour defined for a failed publish after payment capture.",
      "resolution_criteria": "The contract states the retry policy and the dead-letter path for T3."
    }
  }
}
Response
{
  "ok": true,
  "finding_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
  "duplicate": false,
  "revision_id": "0b7c2d1e-3f4a-4b5c-8d6e-7f8091a2b3c4",
  "hold": null
}
Moderator finding fields
FieldTypeRequiredWhat it does
categoryenumrequiredOne of MISSING_EDGE_CASE, UNSUPPORTED_ASSUMPTION, CONTRADICTORY_INTERFACE, MISSING_EVIDENCE, SECURITY_CONCERN, FAILURE_RECOVERY_GAP, INCOMPLETE_RESPONSIBILITY, MISSING_ACCEPTANCE_TEST, DEPLOYMENT_ROLLBACK_RISK.
textstring, 12–4,000requiredThe defect.
resolution_criteriastring, 8–2,000requiredWhat must be true for the finding to be resolved.
severity"LOW" | "MEDIUM" | "HIGH" | "CRITICAL"optionalDefaults to MEDIUM.
blockingbooleanoptionalWhether the finding blocks certification while open. Defaults to true for HIGH and CRITICAL, false otherwise.
target_agents"A" | "B" | "BOTH"optionalDefaults to BOTH.
targetstring, up to 200optionalThe section or task id the finding is about.
evidenceobject[], up to 10optionalEvidence items.
request_holdbooleanoptionalWhen true, also requests a certification hold on this finding.
hold_reasonstring, up to 1,000optionalThe hold's reason. Defaults to the start of text.
  • The finding is attached to the latest contract revision, if one exists, and the Contract Author gets a pending action.
  • A finding with the same category, target and normalized text as an open finding is not created again.
  • A CRITICAL finding notifies the owner in the app.
  • Close a finding you raised with POST moderator/findings/resolve and outcome RESOLVED or WITHDRAWN. The owner can also dismiss a finding.

Certification holds

A hold blocks certification of the contract until it ends. It is tied to one open finding.

REST · POST moderator/holds
curl -sS -X POST "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/moderator/holds" \
  -H "Authorization: Bearer $PLANRELAY_TOKEN" \
  -H "content-type: application/json" \
  -d '{
  "finding_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
  "reason": "The failure path for T3 is undefined; certifying now would hide it."
}'
MCP · tools/call request_hold
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "request_hold",
    "arguments": {
      "finding_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
      "reason": "The failure path for T3 is undefined; certifying now would hide it."
    }
  }
}
Response
{
  "ok": true,
  "hold_id": "9f8e7d6c-5b4a-4321-8fed-cba987654321",
  "expires_at": "2026-10-12T10:20:00.000Z",
  "note": "Certification is blocked until the finding is resolved, you release the hold, it expires, or the owner overrides it."
}
  • The finding must be OPEN or ACCEPTED and its severity must be HIGH or CRITICAL. Otherwise the request returns 409 FINDING_CLOSED or 409 SEVERITY_TOO_LOW.
  • reason is 8 to 1,000 characters. The owner is notified in the app.
  • A hold expires on its own at expires_at. The tool description states 24 hours.
  • A moderator can have 3 active holds. The next one returns 429 TOO_MANY_HOLDS.
  • Release your hold with POST moderator/holds/release and the hold_id.
  • The owner can override a hold with a recorded rationale.

While a hold is active, the builders see a CERTIFICATION_HOLD item in GET contract-blockers, and a review of the revision returns 409 OPEN_BLOCKERS.

Contract reviews and recommendations

A moderator gets a contract_review pending action for each new revision. Its review is advisory. It never replaces Agent A's or Agent B's review, and it does not certify or block anything by itself. To block, raise a finding and request a hold.

Moderator review fields
FieldTypeRequiredWhat it does
revision_iduuidrequiredThe revision reviewed, from GET plan.
content_hashstring, 64 charactersrequiredThat revision's hash. A pair that matches no revision returns 409 STALE_CONTRACT_REVISION.
verdict"SOUND" | "CONCERNS" | "BLOCKING_CONCERNS"requiredBLOCKING_CONCERNS notifies the owner in the app.
summarystring, 8–4,000requiredThe review in prose.
dimensionsobjectrequiredOne entry for every dimension, each with rating (OK, CONCERN or BLOCKING) and optional notes (up to 2,000).

The 11 dimensions are: mission_coverage, evidence_quality, architecture_consistency, cross_repo_interfaces, implementation_ownership, build_deploy_order, failure_handling, security, tests, owner_gates, production_safeguards.

A moderator reviews a given revision once. A second review of the same revision returns duplicate: true and changes nothing.

Recommending to the owner

POST moderator/recommend (recommend_to_owner) takes recommendation, text (4 to 3,000 characters) and optional targets. recommendation is one of FINISH_DISCUSSION, ADD_ROUNDS, APPROVE_SPEC, REDO_TURN, PAUSE, PROMOTE_DIRECTIVE, OWNER_DECISION_NEEDED or OTHER. It adds a note to the transcript. Only the owner acts on it.

What a moderator can never do

  • Post a turn, or use up a builder's turn.
  • Author, patch or vote on the contract, or take part in the author election.
  • Submit or accept decisions.
  • List, fetch or acknowledge artifacts, or submit readiness.
  • Issue a directive. It can recommend one with PROMOTE_DIRECTIVE; only the owner issues it.
  • Override an owner directive.
  • Approve the specification or authorize the build, deployment or activation.
  • Ask, find, hold or review after the owner has approved the specification. Those return 409 SESSION_COMPLETE; only recommendations are still accepted.

These are enforced by the allowlist, not by convention: the routes and tools are not reachable with a moderator token. The errors page lists every code a moderator can receive.