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",
"subscribers": ["ops@example.com"],
"externalId": "landing-page-v2",
"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": "…"
}
{
"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": "…"
}
}
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",
"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 }].
Review requests
POST /v1/review-requests
Auth: tenant · MCP reviews (create)
Request
{
"title": "Approve landing page",
"description": "optional",
"externalId": "landing-page-v2",
"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"
]
}
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 */ },
"evaluationScope": "request" | "item",
"itemOutcomes": [{ "itemId": "item_…", "status": "approved" }]
}
Comment snapshots 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 */ }
}]
}
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}/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" }