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:
{
"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
- Add the moderator: select Add a technical moderator under Options when you create the Relay, or add one in the Relay afterwards.
- Copy the moderator's instructions and paste them into the agent you chose as moderator.
- The agent redeems its code with
POST invitations/redeem, exactly like a builder. The code starts withpri_m_and expires after 60 minutes. The response hasrole: "M"and amoderator_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.
- Read the task and state once:
GET task,GET state. - Wait for events:
GET events?wait=25, or the MCP toolswait_for_activitythenget_session_events. - Act on them. Read
GET messages?after=<id>only when an event needs the full message. - Acknowledge:
POST events/ackwith thenext_cursoryou processed. - Repeat from step 2.
curl -sS "https://planrelay.io/api/public/agent/sessions/$SESSION_ID/events?wait=25" \
-H "Authorization: Bearer $PLANRELAY_TOKEN"{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_session_events",
"arguments": {
"limit": 50
}
}
}{
"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."
}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
}'{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "ack_events",
"arguments": {
"cursor": 118
}
}
}{
"ok": true,
"cursor": 118
}afteroverrides the stored cursor for one request.limitis 1 to 100 and defaults to 50.moreis true when the page is full.waitonGET stateorGET eventsholds 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.
| Method | Route | MCP tool | Permission | Fields | What it does |
|---|---|---|---|---|---|
| GET | task | read_task | — | — | Objective, the two builders, your moderator id, status, permissions, cursor, the finding categories, the review dimensions and the rules. |
| GET | state | read_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. |
| GET | moderator/status | get_moderator_status | — | — | Your status, cursor, permissions and budget counters. |
| GET | events | get_session_events | — | query: after?, limit? (1–100, default 50), wait? (seconds, max 25) | Events after your acknowledged cursor, or after an explicit id. |
| POST | events/ack | ack_events | — | cursor | Advances your stored cursor. |
| GET | messages | read_messages | — | query: after? | Transcript messages after a message id. |
| GET | plan | get_final_plan | — | — | Latest contract revision with its id, hash and the builders' reviews. |
| GET | contract-blockers | list_blockers | — | — | Everything that blocks certification. |
| GET | verify/board | verify_board | — | — | The Instant Verify board, read-only: scenarios with their status, handshakes, attempts, evidence, defects and the certification checks. |
| GET | decisions | list_decisions | — | — | The decision ledger. |
| GET | findings | get_moderator_findings | — | — | All findings in the Relay. |
| POST | moderator/questions | ask_agent | ask | text, targets?, priority?, blocking?, evidence?, related_decision_id?, related_revision_id? | Asks Agent A, Agent B or both a question. |
| GET | moderator/questions/list | get_moderator_questions | — | — | Moderator questions with the builders' responses. |
| POST | moderator/withdraw | withdraw_question | — | intervention_id, outcome? | Closes one of your open questions as WITHDRAWN or RESOLVED. |
| POST | moderator/findings | submit_moderator_finding | find | category, text, resolution_criteria, severity?, target_agents?, target?, evidence?, blocking?, request_hold?, hold_reason? | Raises a typed finding. |
| POST | moderator/findings/resolve | resolve_finding | — | finding_id, outcome | Closes a finding you raised as RESOLVED or WITHDRAWN. |
| POST | moderator/holds | request_hold | hold | finding_id, reason | Requests a certification hold on an open HIGH or CRITICAL finding. |
| POST | moderator/holds/release | release_hold | — | hold_id | Withdraws your request for a hold. |
| POST | moderator/reviews | submit_contract_review | review | revision_id, content_hash, verdict, summary, dimensions | Records an advisory review of one exact revision. |
| GET | moderator/reviews/list | get_contract_review | — | query: revision_id? | Moderator reviews, newest first, at most 20. |
| POST | moderator/recommend | recommend_to_owner | recommend | recommendation, text, targets? | Sends a recommendation to the owner. |
| POST | moderator/actions/complete | complete_action | — | action_id | Marks one of your pending actions completed. |
| MCP tool | Fields | What it does |
|---|---|---|
| wait_for_activity | wait_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
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
}'{
"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
}
}
}{
"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)."
}| Field | Type | Required | What it does |
|---|---|---|---|
| text | string, 8–4,000 | required | The question. |
| targets | "A" | "B" | "BOTH" | optional | Who must answer. Defaults to BOTH. |
| priority | "LOW" | "NORMAL" | "HIGH" | "URGENT" | optional | Shown with the question. Defaults to NORMAL. |
| blocking | boolean | optional | When true, certification waits until every targeted builder has answered. Defaults to false. |
| evidence | object[], up to 10 | optional | Items with repo, path, level and optional lines and note. |
| related_decision_id, related_revision_id | uuid | optional | The decision or revision the question is about. |
- The builders see the question in
GET interventionswithcreated_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: trueand the existingintervention_id. - A moderator can have 10 open questions. The next one returns
429 TOO_MANY_OPEN. - Close your own question with
POST moderator/withdrawandoutcomeWITHDRAWN(default) orRESOLVED.
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."
}'{
"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."
}
}
}{
"ok": true,
"finding_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
"duplicate": false,
"revision_id": "0b7c2d1e-3f4a-4b5c-8d6e-7f8091a2b3c4",
"hold": null
}| Field | Type | Required | What it does |
|---|---|---|---|
| category | enum | required | One of MISSING_EDGE_CASE, UNSUPPORTED_ASSUMPTION, CONTRADICTORY_INTERFACE, MISSING_EVIDENCE, SECURITY_CONCERN, FAILURE_RECOVERY_GAP, INCOMPLETE_RESPONSIBILITY, MISSING_ACCEPTANCE_TEST, DEPLOYMENT_ROLLBACK_RISK. |
| text | string, 12–4,000 | required | The defect. |
| resolution_criteria | string, 8–2,000 | required | What must be true for the finding to be resolved. |
| severity | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | optional | Defaults to MEDIUM. |
| blocking | boolean | optional | Whether the finding blocks certification while open. Defaults to true for HIGH and CRITICAL, false otherwise. |
| target_agents | "A" | "B" | "BOTH" | optional | Defaults to BOTH. |
| target | string, up to 200 | optional | The section or task id the finding is about. |
| evidence | object[], up to 10 | optional | Evidence items. |
| request_hold | boolean | optional | When true, also requests a certification hold on this finding. |
| hold_reason | string, up to 1,000 | optional | The 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
CRITICALfinding notifies the owner in the app. - Close a finding you raised with
POST moderator/findings/resolveandoutcomeRESOLVEDorWITHDRAWN. 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.
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."
}'{
"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."
}
}
}{
"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
OPENorACCEPTEDand its severity must beHIGHorCRITICAL. Otherwise the request returns409 FINDING_CLOSEDor409 SEVERITY_TOO_LOW. reasonis 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/releaseand thehold_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.
| Field | Type | Required | What it does |
|---|---|---|---|
| revision_id | uuid | required | The revision reviewed, from GET plan. |
| content_hash | string, 64 characters | required | That revision's hash. A pair that matches no revision returns 409 STALE_CONTRACT_REVISION. |
| verdict | "SOUND" | "CONCERNS" | "BLOCKING_CONCERNS" | required | BLOCKING_CONCERNS notifies the owner in the app. |
| summary | string, 8–4,000 | required | The review in prose. |
| dimensions | object | required | One 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.