REST API

Base URL https://rest.agentfabric.dev. Resource schemas and per-endpoint request/response shapes.

Envelope & errors

Base URL: https://rest.agentfabric.dev. All successful handler responses:

{ "data": { /* payload */ } }

Errors:

{ "errors": [{ "title": "Human-readable message", "path": ["field"] }] }
Status Typical cause
400 Validation; path may point at the field
401 Missing/invalid API key, user token, or review code
403 Admin permission required
404 Unknown request, comment, or user
409 Duplicate reviewer email
410 Cancelled/expired request
500 Internal server error

Bodies may be flat JSON or { "data": { … } }. Auth: x-api-key / Authorization: Bearer ak_… (tenant), Cognito bearer (dashboard), or reviewer auth code. ID prefixes: tenant_, rr_, item_, reviewer_, comment_, ak_, key_, rrc_, event_.

Resources

Shapes returned inside data (and nested on detail responses).

Tenant

{
  "tenantId": "tenant_…",
  "name": "Acme Agents",
  "ownerEmail": "ops@example.com",
  "ownerName": "Ops",
  "status": "active" | "suspended",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "updatedAt": "2026-01-01T00:00:00.000Z"
}

AppUser

{
  "tenantId": "tenant_…",
  "userId": "user_…",
  "email": "ops@example.com",
  "name": "Ops",
  "role": "admin" | "consumer",
  "cognitoSub": "…",
  "status": "active" | "disabled",
  "createdAt": "…",
  "updatedAt": "…"
}

ApiKeyRecord

{
  "tenantId": "tenant_…",
  "keyId": "key_…",
  "label": "CI consumer",
  "scope": "admin" | "consumer",
  "status": "active" | "revoked",
  "createdAt": "…",
  "lastUsedAt": "…"
}

Plaintext secret ak_… is returned only on create/signup/rotate — never as keyHash.

WebhookEndpoint

{
  "endpointId": "whk_…",
  "url": "https://agent.example.com/hooks/agentfabric",
  "description": "Content agent",
  "events": ["review_request.decided"],
  "secretHint": "whsec_1a2b…",
  "status": "active" | "disabled",
  "createdAt": "…",
  "lastSucceededAt": "…",
  "lastFailedAt": "…",
  "lastFailureReason": "Endpoint responded 503"
}

No events means every event. The signing secret whsec_… is returned only on create — see Webhooks.

Document

{
  "type": "url" | "image" | "pdf" | "text" | "html",
  "url": "https://…",
  "title": "optional",
  "content": "inline body…",
  "metadata": { }
}

Policy

{
  "type": "ALL_APPROVE" | "ANY_APPROVE" | "ANY_REJECT" | "QUORUM" | "ADVISORY",
  "quorum": 2,
  "rejectionThreshold": 1,
  "decisionScope": "request" | "item"
}

ADVISORY collects feedback and gates nothing: it resolves to advised once every reviewer has answered, and no answer — not even rejected — blocks it. rejectionThreshold is ignored there.

ReviewRequest

{
  "tenantId": "tenant_…",
  "requestId": "rr_…",
  "title": "Approve landing page",
  "description": "optional",
  "policy": { "type": "ALL_APPROVE", "decisionScope": "request" },
  "status": "pending" | "approved" | "rejected" | "changes_required" | "advised" | "cancelled"
    | "expired" | "precheck_running" | "precheck_failed",
  "subscribers": ["ops@example.com"],
  "externalId": "landing-page-v2",
  "domain": "landing-pages",
  "smartPrecheck": { "enabled": true, "instructions": "…", "blockOn": "major" },
  "metadata": { "env": "staging" },
  "createdAt": "…",
  "updatedAt": "…"
}

ReviewItem

{
  "itemId": "item_…",
  "title": "Preview",
  "document": { "type": "url", "url": "https://example.com/page" },
  "status": "pending" | "approved" | "rejected" | "changes_required" | "cancelled" | "expired",
  "order": 0,
  "externalId": "preview-asset-1",
  "metadata": { "branch": "main" }
}

Reviewer

{
  "reviewerId": "reviewer_…",
  "email": "reviewer@example.com",
  "name": "Alex",
  "channel": "email" | "none",
  "decision": "pending" | "approved" | "rejected" | "changes_required",
  "authCodeExpiresAt": "…",
  "openedAt": "…",
  "decidedAt": "…"
}

Detail responses may also include authCodeHash. Plaintext authCode (rrc_…) is omitted in production create/add responses.

channel decides who delivers the review link, and is email when absent — see Reviewer delivery channels.

ReviewItemDecision

{
  "requestId": "rr_…",
  "tenantId": "tenant_…",
  "itemId": "item_…",
  "reviewerId": "reviewer_…",
  "decision": "approved" | "rejected" | "changes_required",
  "decidedAt": "…"
}

ReviewComment

{
  "commentId": "comment_…",
  "requestId": "rr_…",
  "tenantId": "tenant_…",
  "itemId": "item_…",
  "reviewerId": "reviewer_…",
  "authorEmail": "reviewer@example.com",
  "authorRole": "reviewer" | "owner",
  "threadId": "comment_…",
  "parentCommentId": "comment_…",
  "body": "Please fix the hero CTA.",
  "status": "open" | "resolved",
  "createdAt": "…",
  "resolvedAt": "…",
  "resolvedBy": "ops@example.com",
  "groupId": "fg_…",
  "context": { },
  "metadata": { }
}

threadId points at the comment that started the thread and equals commentId on that root; parentCommentId is set on replies. status, context and groupId live on the root, so resolve and group by it.

ReviewNotice

An activity digest waiting to be emailed. Returned as notices on the request detail.

{
  "audience": "subscribers" | "reviewer",
  "reviewerId": "reviewer_…",
  "notifyAfter": "2026-07-09T10:30:00.000Z",
  "firstPendingAt": "…",
  "lastActivityAt": "…",
  "pendingComments": 3,
  "pendingDecisions": 1,
  "notifiedAt": "…"
}

notifyAfter is when the email goes out, and it is absent once nothing is owed. Each new comment pushes it out, so one quiet period covers a whole burst.

ReviewViewContext

What the reviewer had on screen when they wrote the comment.

{
  "itemId": "item_…",
  "documentType": "url" | "image" | "pdf" | "text" | "html",
  "documentUrl": "https://example.com/page",
  "contentHash": "sha256 of inline content",
  "viewport": { "width": 1440, "height": 900, "dpr": 2 },
  "scroll": { "x": 0, "y": 1200 },
  "selectionText": "the exact text they highlighted",
  "userAgent": "…",
  "capturedAt": "…",
  "snapshot": {
    "status": "pending" | "ready" | "failed" | "skipped",
    "url": "presigned png url, present when ready",
    "width": 1440,
    "height": 900,
    "capturedAt": "…"
  }
}

Check

{
  "checkId": "chk_…",
  "status": "queued" | "running" | "passed" | "failed" | "error",
  "domain": "landing-pages",
  "title": "Pre-flight check",
  "description": "…",
  "items": [ /* ReviewItem */ ],
  "instructionsUsed": "…",
  "model": "grok-4.5",
  "blockOn": "any_finding" | "major" | "critical",
  "shareUrl": "https://cloud.browser-use.com/share/…",
  "groupsConsidered": ["fg_…"],
  "findings": [ /* Finding, same shape as on Precheck */ ],
  "summary": "…",
  "metadata": { },
  "startedAt": "…",
  "finishedAt": "…",
  "updatedAt": "…"
}

The same gate as Precheck, run on its own: no review request, no reviewers, nothing withheld.

Precheck

{
  "precheckId": "pchk_…",
  "requestId": "rr_…",
  "attempt": 1,
  "status": "queued" | "running" | "passed" | "failed" | "error",
  "domain": "landing-pages",
  "instructionsUsed": "…",
  "blockOn": "any_finding" | "major" | "critical",
  "shareUrl": "https://cloud.browser-use.com/share/…",
  "groupsConsidered": ["fg_…"],
  "summary": "The pricing table still contradicts the ad copy.",
  "findings": [
    {
      "findingId": "find_…",
      "severity": "critical" | "major" | "minor",
      "title": "Price mismatch",
      "detail": "The page shows 19 EUR while the ad promises 9 EUR.",
      "itemId": "item_…",
      "groupId": "fg_…",
      "suggestedFix": "Update the pricing table to 9 EUR.",
      "evidenceUrl": "presigned png url"
    }
  ],
  "startedAt": "…",
  "finishedAt": "…"
}

FeedbackGroup

{
  "groupId": "fg_…",
  "domain": "landing-pages",
  "title": "Pricing table contradicts the ad copy",
  "summary": "Integrated content of every comment in this group.",
  "rule": "The price on the page must match the price promised in the ad copy.",
  "severity": "critical" | "major" | "minor",
  "memberCount": 4,
  "status": "active" | "muted" | "archived",
  "mergedInto": "fg_…",
  "firstSeenAt": "…",
  "lastSeenAt": "…",
  "version": 4
}

PrecheckConfig

{
  "domain": "landing-pages",
  "instructions": "Walk the full page and submit the signup form.",
  "model": "optional agent model override",
  "blockOn": "any_finding" | "major" | "critical",
  "enabledByDefault": false,
  "learnFromFeedback": true,
  "maxGroups": 60,
  "updatedAt": "…"
}

learnFromFeedback off keeps enforcing the rules already learned in this domain but stops new reviewer feedback from becoming a rule.

AuditEvent

{
  "eventId": "event_…",
  "tenantId": "tenant_…",
  "requestId": "rr_…",
  "itemId": "item_…",
  "reviewerId": "reviewer_…",
  "type": "request_created" | "reviewer_added" | "reviewer_reminded" | "link_opened"
    | "item_viewed" | "commented" | "comment_resolved" | "comment_unresolved"
    | "approved" | "rejected" | "changes_required" | "policy_resolved" | "cancelled"
    | "precheck_queued" | "precheck_passed" | "precheck_failed" | "precheck_error" | "precheck_overridden",
  "createdAt": "…",
  "metadata": { }
}

Outcome

{
  "status": "pending",
  "summary": "0/2 approved · 2 awaiting",
  "tally": {
    "approved": 0,
    "rejected": 0,
    "changes_required": 0,
    "pending": 2
  }
}

Presentation fields on list/detail: outcome, evaluationScope ("request" | "item"), optional itemOutcomes: [{ itemId, status }].

Auth & session

POST /v1/signup

Auth: public · MCP create_tenant

Request

{
  "tenantName": "Acme Agents",
  "ownerEmail": "ops@example.com",
  "ownerName": "Ops",
  "password": "change-me-now"
}

password min 8 characters.

Response data

{
  "tenant": { /* Tenant */ },
  "user": { /* AppUser, role admin */ },
  "adminApiKey": "ak_…",
  "adminApiKeyRecord": { /* ApiKeyRecord without keyHash */ },
  "consumerApiKey": "ak_…",
  "consumerApiKeyRecord": { /* ApiKeyRecord without keyHash */ },
  "apiKey": "ak_…"
}

apiKey is a deprecated alias of adminApiKey.

POST /v1/auth/signin

Auth: public

Request

{ "email": "ops@example.com", "password": "…" }

Response data

{ "tokens": { /* Cognito AuthResult */ }, "user": { /* AppUser */ } }

POST /v1/auth/refresh

Auth: public

Request

{ "refreshToken": "…" }

Response data

{ "tokens": { /* Cognito AuthResult */ } }

POST /v1/auth/forgot-password

Auth: public

Request

{ "email": "ops@example.com" }

Response data

{ "sent": true }

POST /v1/auth/confirm-forgot-password

Auth: public

Request

{
  "email": "ops@example.com",
  "code": "123456",
  "newPassword": "NewPass1"
}

code is 6 characters. Password: min 8, must include digit, upper, and lower case.

Response data

{ "reset": true }

GET /v1/me

Auth: tenant

Response data

// user session
{ "user": { /* AppUser */ }, "role": "admin" | "consumer" }

// API key session
{ "apiKey": { /* ApiKeyRecord without keyHash */ }, "role": "admin" | "consumer" }

Review requests

POST /v1/review-requests

Auth: tenant · MCP reviews (create)

Request

{
  "title": "Approve landing page",
  "description": "optional",
  "externalId": "landing-page-v2",
  "domain": "landing-pages",
  "smartPrecheck": { "enabled": true, "instructions": "…", "blockOn": "major" },
  "policy": { "type": "ALL_APPROVE", "decisionScope": "request" },
  "reviewers": [{ "email": "reviewer@example.com", "name": "Alex", "channel": "email" }],
  "subscribers": ["ops@example.com"],
  "items": [{
    "id": "optional-client-id",
    "title": "Preview",
    "externalId": "preview-asset-1",
    "document": { "type": "url", "url": "https://example.com/page" },
    "metadata": { "branch": "main" }
  }],
  "metadata": { "env": "staging" }
}

Response data

{
  "request": { /* ReviewRequest, status pending */ },
  "items": [ /* ReviewItem */ ],
  "reviewers": [{
    "reviewerId": "reviewer_…",
    "email": "reviewer@example.com",
    "name": "Alex",
    "channel": "email",
    "decision": "pending",
    "authCodeExpiresAt": "…"
  }],
  "reviewerLinks": [ /* only reviewers you have to deliver to yourself; see Channels */ ],
  "next_actions": [
    "wait_for_reviewers",
    "fetch_review_request",
    "open_dashboard"
  ]
}

With smartPrecheck.enabled (or enabledByDefault on the domain config), no email is sent: the request comes back as precheck_running with a queued precheck and next_actions: ["wait_for_precheck", "fetch_precheck"]. See Smart precheck.

Reviewer delivery channels

reviewers[].channel decides who delivers the review link. It defaults to email, which is the behaviour every existing request already has.

email  AgentFabric emails the invitation, the reminders and the activity digests.
none   AgentFabric mints the link and sends nothing. You deliver it — your product,
       your chat, your agent — and reminder/digest emails are skipped for that reviewer.

A none reviewer's link comes back in reviewerLinks on create, POST …/reviewers and POST …/remind:

{
  "reviewerId": "reviewer_…",
  "email": "agent@example.com",
  "channel": "none",
  "reviewUrl": "https://review.agentfabric.dev/review?authCode=rrc_…",
  "sent": false
}

The list is empty when every reviewer is on email, so an empty list means nothing is owed. Since no digest email goes out for that reviewer, the review_comment.created webhook is how you learn about their replies. Treat reviewUrl as a credential: it authenticates as that reviewer.

GET /v1/review-requests

Auth: tenant · MCP reviews (list)

Query: optional filter[externalId]. Newest first.

Response data

[
  {
    /* ReviewRequest fields… */
    "outcome": { /* Outcome */ },
    "evaluationScope": "request" | "item",
    "itemOutcomes": [{ "itemId": "item_…", "status": "pending" }]
  }
]

Cancelled/expired rows omit presentation fields.

GET /v1/review-requests/{requestId}

Auth: tenant · MCP reviews (get)

Response data

{
  "request": { /* ReviewRequest */ },
  "items": [ /* ReviewItem */ ],
  "reviewers": [ /* Reviewer */ ],
  "itemDecisions": [ /* ReviewItemDecision */ ],
  "comments": [ /* ReviewComment */ ],
  "notices": [ /* ReviewNotice: pending activity digests */ ],
  "events": [ /* AuditEvent */ ],
  "outcome": { /* Outcome */ },
  "precheck": { /* latest Precheck, when the gate has run */ },
  "evaluationScope": "request" | "item",
  "itemOutcomes": [{ "itemId": "item_…", "status": "approved" }]
}

Comment snapshots and precheck evidence come back as short-lived presigned URLs.

GET /v1/review-requests/{requestId}/items

Auth: tenant

Response data

[ /* ReviewItem */ ]

GET /v1/review-items

Auth: tenant · MCP reviews (list_items)

Query: filter[externalId] required. Newest first.

Response data

{
  "items": [{
    "requestId": "rr_…",
    "request": { /* ReviewRequest */ },
    "item": { /* ReviewItem */ }
  }]
}

GET /v1/review-requests/{requestId}/comments

Auth: tenant · MCP comments (list)

Query: optional itemId, status=open|resolved. Replies follow their thread root, and a status filter keeps or drops whole threads.

Response data

[ /* ReviewComment */ ]

POST /v1/review-requests/{requestId}/comments

Auth: tenant · MCP comments (create)

Request

{
  "body": "Which price did you expect?",
  "parentCommentId": "comment_…",
  "reviewerId": "reviewer_…",
  "itemId": "item_…"
}

Pass parentCommentId to reply inside a thread; the reply inherits that thread's itemId. Pass reviewerId instead to open a new thread aimed at one reviewer. 404 if the parent comment or reviewer does not exist, 410 if cancelled.

Response data

{ /* ReviewComment */, "notifyAfter": "2026-07-09T10:30:00.000Z" }

notifyAfter is when the reviewer's digest email goes out unless there is more activity.

POST /v1/review-requests/{requestId}/notify

Auth: tenant · MCP comments (notify)

Request

{ "reviewerIds": ["reviewer_…"] }

Sends pending replies immediately instead of waiting out the quiet period. Omit reviewerIds to flush every reviewer with unsent replies. 410 if cancelled or expired.

Response data

{
  "notified": [{
    "reviewerId": "reviewer_…",
    "outcome": "sent" | "idle" | "claimed_by_other",
    "recipients": ["reviewer@example.com"]
  }]
}

POST /v1/review-requests/{requestId}/comments/{commentId}/resolve

Auth: tenant · MCP comments (resolve)

No body. Sets resolvedBy to the caller email or api-key:{label}. Resolution is per thread, so commentId must be the comment that started it — a reply returns 400.

Response data

{ /* ReviewComment, status resolved */ }

POST /v1/review-requests/{requestId}/comments/{commentId}/unresolve

Auth: tenant · MCP comments (unresolve)

Thread root only, same as resolve.

Response data

{ /* ReviewComment, status open */ }

POST /v1/review-requests/{requestId}/reviewers

Auth: tenant · MCP reviews (add_reviewer)

Request

{
  "reviewers": [{ "email": "new@example.com", "name": "Sam" }]
}

409 if email already assigned.

Response data

{ /* added reviewers (same shape as create) */ }

POST /v1/review-requests/{requestId}/remind

Auth: tenant · MCP reviews (remind)

Request

{ "emails": ["reviewer@example.com"] }

emails optional — defaults to all pending reviewers. Regenerates auth codes. 410 if cancelled/expired.

Response data

{
  "reminded": [
    { "reviewerId": "reviewer_…", "email": "reviewer@example.com", "sent": true }
  ]
}

POST /v1/review-requests/{requestId}/cancel

Auth: tenant · MCP reviews (cancel)

Response data

{ "status": "cancelled" }

Smart precheck

The quality gate that runs before reviewers are emailed. See Concepts → Smart precheck.

GET /v1/review-requests/{requestId}/precheck

Auth: tenant · MCP precheck (get)

Response data

{
  "precheck": { /* latest Precheck */ },
  "history": [ /* Precheck, newest attempt first */ ]
}

Findings carry presigned evidenceUrl screenshots.

POST /v1/review-requests/{requestId}/precheck/rerun

Auth: tenant · MCP precheck (rerun)

No body. Queues attempt N+1 and moves the request back to precheck_running. 409 if the gate is not enabled, 410 if cancelled/expired.

Response data

{ "precheck": { /* Precheck, status queued */ } }

POST /v1/review-requests/{requestId}/precheck/override

Auth: tenant · MCP precheck (override)

No body. Sends the reviewer invitations despite a failed gate and records a precheck_overridden audit event. 409 if the request is not held by the gate.

Response data

{ "status": "pending", "released": 2 }

GET /v1/feedback-groups

Auth: tenant · MCP feedback (list)

Query: domain (default default), optional status=active|muted|archived.

Response data

{ "domain": "landing-pages", "groups": [ /* FeedbackGroup */ ] }

GET /v1/feedback-groups/{groupId}

Auth: tenant · MCP feedback (get)

Query: domain.

Response data

{
  "group": { /* FeedbackGroup */ },
  "members": [{
    "commentId": "comment_…",
    "requestId": "rr_…",
    "itemId": "item_…",
    "excerpt": "Price says 19 but the ad said 9",
    "snapshotUrl": "presigned png url",
    "createdAt": "…"
  }]
}

PATCH /v1/feedback-groups/{groupId}

Auth: tenant · MCP feedback (update)

Request

{
  "status": "active" | "muted" | "archived",
  "rule": "rewritten check the precheck should enforce",
  "severity": "critical" | "major" | "minor",
  "title": "…"
}

All fields optional. Muted and archived groups are excluded from the precheck prompt.

Response data

{ /* FeedbackGroup */ }

GET /v1/precheck-config

Auth: tenant

Query: domain.

Response data

{
  "config": { /* PrecheckConfig */ },
  "domains": [{
    "domain": "landing-pages",
    "requestCount": 12,
    "activeGroupCount": 4,
    "gateEnabledByDefault": true,
    "configured": true
  }]
}

domains covers every domain in use, including those that only ever appeared on a review request. configured is false for those.

PUT /v1/precheck-config

Auth: tenant · MCP precheck (configure)

Request

{
  "domain": "landing-pages",
  "instructions": "Walk the full page and submit the signup form.",
  "blockOn": "major",
  "enabledByDefault": true,
  "learnFromFeedback": true,
  "maxGroups": 60
}

All fields optional; domain may also be given as a query parameter.

Response data

{ /* PrecheckConfig */ }

Standalone checks

The gate without a review request: it reuses the domain's learned rules and configuration, assigns no reviewers and sends no email. Ask it before you involve a human.

POST /v1/checks

Auth: tenant · MCP checks (create)

Request

{
  "title": "Pre-flight check",
  "description": "optional context for the agent",
  "items": [{ "title": "Landing page", "document": { "type": "url", "url": "https://…" } }],
  "domain": "landing-pages",
  "instructions": "added on top of the domain instructions",
  "model": "grok-4.5",
  "blockOn": "major",
  "metadata": { }
}

Only items is required; everything else falls back to the domain configuration.

Response data

{
  "check": { /* Check */ },
  "next_actions": ["wait_for_check", "fetch_check"]
}

GET /v1/checks/{checkId}

Auth: tenant · MCP checks (get)

Response data

{ "check": { /* Check, with presigned evidence urls */ } }

Poll until status leaves queued/running. A blocked run is failed with findings; error means the check itself could not complete.

Users, API keys & webhooks

Admin role / admin API key required.

GET /v1/users

Auth: admin · MCP users (list)

Response data

[ /* AppUser */ ]

POST /v1/users

Auth: admin · MCP users (create)

Request

{
  "email": "dev@example.com",
  "name": "Dev",
  "role": "consumer"
}

role defaults to consumer.

Response data

{
  "user": { /* AppUser */ },
  "temporaryPassword": "…"
}

PATCH /v1/users/{userId}

Auth: admin

Request

{ "role": "admin" | "consumer" }

Response data

{ /* AppUser */ }

400 if demoting the last admin.

DELETE /v1/users/{userId}

Auth: admin

Response data

{ "userId": "user_…", "deleted": true }

400 if deleting yourself or the last admin.

GET /v1/api-keys

Auth: admin · MCP api_keys (list)

Response data

[ /* ApiKeyRecord without keyHash; active only */ ]

POST /v1/api-keys

Auth: admin · MCP api_keys (create)

Request

{ "label": "CI consumer", "scope": "consumer" }

scope defaults to consumer.

Response data

{
  "apiKey": "ak_…",
  "apiKeyRecord": { /* ApiKeyRecord without keyHash */ }
}

POST /v1/api-keys/{keyId}/revoke

Auth: admin

Response data

{ "keyId": "key_…", "status": "revoked" }

POST /v1/api-keys/{keyId}/rotate

Auth: admin

Revokes the old key and creates a new admin key labeled Rotated API key.

Response data

{
  "apiKey": "ak_…",
  "apiKeyRecord": { /* ApiKeyRecord without keyHash */ }
}

GET /v1/webhooks

Auth: admin · MCP webhooks (list)

Response data

{
  "webhooks": [ /* WebhookEndpoint, secret replaced by secretHint */ ],
  "events": [ /* every event type you can subscribe to */ ]
}

POST /v1/webhooks

Auth: admin · MCP webhooks (create)

Request

{
  "url": "https://agent.example.com/hooks/agentfabric",
  "description": "Content agent",
  "events": ["review_request.decided", "review_comment.created"]
}

Omit events to receive all of them. The url must be public https — loopback, private ranges and the instance metadata address are rejected with 400.

Response data

{
  "webhook": { /* WebhookEndpoint */ },
  "secret": "whsec_…"
}

The secret is returned once; later reads only show a hint. See Webhooks for the signature to verify with it.

DELETE /v1/webhooks/{endpointId}

Auth: admin · MCP webhooks (delete)

Response data

{ "deleted": true }

404 if the tenant has no such endpoint.

Webhooks

Register an endpoint and AgentFabric posts each event to it, so an agent can finish its turn and be woken up instead of holding a poll loop open.

Event types

review_request.decided          a reviewer decided; data has status + resolved
review_request.precheck_passed  the gate cleared a request, reviewers were emailed
review_request.precheck_failed  the gate held a request back; data has the precheck
review_comment.created          a reviewer commented; data has the comment
check.finished                  a standalone check reached a verdict; data has the check

Delivery

POST /your/endpoint
content-type: application/json
agentfabric-event: review_request.decided
agentfabric-delivery: evt_…
agentfabric-signature: t=1800000000,v1=<hex hmac-sha256>

{ "data": { "eventId": "evt_…", "type": "…", "tenantId": "…", "createdAt": "…", "data": { … } } }

Verifying a delivery

HMAC-SHA256 over "{t}.{raw body}" with the endpoint secret, compared in constant time. Sign the raw body, not a re-serialised copy, and reject a t older than five minutes.

import { createHmac, timingSafeEqual } from "node:crypto";

const verify = (secret, header, rawBody) => {
  const parts = new Map(header.split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
  const t = Number(parts.get("t"));
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = parts.get("v1") ?? "";
  return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given));
};

Retries

Respond 2xx to accept. A 5xx, a 429 or a timeout (10s) is retried; any other 4xx counts as a permanent rejection and is dropped. Deliveries are at-least-once and not ordered — treat eventId as an idempotency key, and re-read the request when you need current state rather than reconstructing it from events.

Reviewer API

Used by the review web app. Agents rarely call these — reviewers use the emailed link.

POST /v1/review/exchange

Auth: public

Request

{ "authCode": "rrc_…" }

Response data

{
  "token": "rrc_…",
  "requestId": "rr_…",
  "reviewerId": "reviewer_…"
}

GET /v1/review/{requestId}

Auth: reviewer (Bearer auth code or ?authCode=)

Response data

{
  "request": { /* ReviewRequest */ },
  "items": [ /* ReviewItem */ ],
  "reviewer": { /* Reviewer (this reviewer) */ },
  "itemDecisions": [ /* this reviewer's item decisions */ ],
  "comments": [ /* ReviewComment */ ],
  "deleted": false,
  "evaluationScope": "request" | "item",
  "reviewerAggregate": "pending" | "approved" | "rejected" | "changes_required"
}

POST /v1/review/{requestId}/opened

Auth: reviewer

Records a link_opened audit event.

GET /v1/review/{requestId}/comments

Auth: reviewer

Query: optional itemId, status.

Response data

[ /* ReviewComment */ ]

POST /v1/review/{requestId}/comments

Auth: reviewer

Request

{
  "body": "Please fix the hero CTA.",
  "itemId": "item_…",
  "parentCommentId": "comment_…",
  "metadata": { }
}

Pass parentCommentId to answer a reply from the team. 410 if cancelled.

Response data

{ /* ReviewComment */ }

POST /v1/review/{requestId}/notify

Auth: reviewer

No body. The "I'm done" signal: emails the tenant's subscribers a digest of this reviewer's comments and verdicts now, instead of waiting out the 30-minute quiet period.

Response data

{ "outcome": "sent" | "idle" | "claimed_by_other", "recipients": ["team@example.com"] }

POST /v1/review/{requestId}/decision

Auth: reviewer

Request

{
  "decision": "approved" | "rejected" | "changes_required",
  "itemId": "item_…",
  "comment": "optional note (creates a comment)"
}

Response data

{
  "status": "pending" | "approved" | "rejected" | "changes_required" | …,
  "itemDecision": { /* ReviewItemDecision, if itemId set */ },
  "reviewerAggregate": "pending" | "approved" | "rejected" | "changes_required"
}