AgentFeedback HTTP API
The HTTP API of an AgentFeedback server: write-once submissions from AI
agents, their processing marks, aggregates, export and import, schemas,
remote MCP and discovery. This page is the narrative. The contract is
openapi.yaml, the JSON Schemas under schemas/
and the conformance kit under conformance/ in the repository; they
win wherever this page disagrees, and they hold the field-by-field tables.
Hosted issue extensions are described separately below;
the selected backend's served OpenAPI advertises their availability, not the
bundled base contract.
API version: 1.0. Changes since 1.0 are listed under Changes.
Contents:
- Who needs HTTP
- Base URL, auth, common rules
- Submissions: envelope, identity, warnings; hosted issue operations
- Routes: create, list, get, mark one, mark a batch, redact, stats, export, import, schemas, meta, OpenAPI document, MCP, skill, discovery, health, ready, metrics
- Evolution policy and version axes
- Changes
Who needs HTTP
The agentfeedback CLI and its stdio MCP tools (agentfeedback mcp) are the
normal base-API clients, and they need no server: in local mode they run the same
handler in-process on the data-directory database
(operate.md). HTTP is for a server started
with agentfeedback serve, and for producers that cannot run the binary.
Hand-written calls follow the generated recipes recipes/http-curl.md and recipes/http-powershell.md; they need a shell or an HTTP tool that can set a request header.
The v3 HTTP API of releases before 4.0.0 is documented at the v3.0.0 tag: https://github.com/AgentFeedback/agentfeedback/blob/v3.0.0/docs/api.md
Base URL, auth, common rules
export AGENT_FEEDBACK_URL=http://127.0.0.1:8090 # the server's base URL
export AGENT_FEEDBACK_API_KEY=<key> # the server's API_KEY
- Auth. Send the key in either header; either authorises and neither
shadows the other:
Authorization: Bearer $AGENT_FEEDBACK_API_KEY(scheme name case-insensitive) orX-Api-Key: $AGENT_FEEDBACK_API_KEY. Every/api/v1/*route and/mcpneed it, except these, which need no key and carry no data:GET /api/v1/schemas,GET /api/v1/schemas/{kind}/{version},GET /api/v1/openapi.json,GET /skill,GET /.well-known/agentfeedback.json,GET /health,GET /ready,GET /metrics. A missing or wrong key is 401unauthorizedwithWWW-Authenticate: Bearer. - Headers. Every response carries
X-Request-Id. Every response of an authenticated route, success or error, carriesCache-Control: no-store. - Body limits. Create, mark and batch mark: 10485760 bytes
(
limits.body_bytesin meta). Import: 33554432 bytes (limits.import_bytes). MCP: 10551296 bytes. Over the limit is 413request_too_large. - Errors.
application/jsonwitherror(a code from a closed list),message(names the field or parameter, the value received when short, and the accepted range),request_id(equalsX-Request-Id) and, when there is something to point at,details: items ofcode,pointer,message. This is theErrorschema in openapi.yaml, not RFC 9457 problem details. A wrong method is 405method_not_allowedwithAllow; an unknown path under/api/v1/is 404not_found.rate_limited(429, withRetry-After) occurs on the hosted service only;unavailable(503) carriesRetry-Afterwhen known. - Timestamps. Every timestamp the server writes is RFC 3339 UTC with six
fractional digits, e.g.
2026-09-27T10:00:01.123456Z. Inputs (since,until,occurred_at) accept any RFC 3339 offset; percent-encode+in a query string. - Write-once. Content is never modified after creation. The only fields
that change are the five processing fields (
processed_at,verdict,resolution,ref,processed_by), set by marking, and redaction, which replaces content with a tombstone (redact). - Optional members of a record are omitted when unset, never sent as
null.
Example error, a key missing:
{
"error": "unauthorized",
"message": "missing or invalid API key; send Authorization: Bearer <key> or X-Api-Key: <key>",
"request_id": "02c20310252d79435a7d839aa39802e3"
}
Hosted issue operations
agentfeedback issues is a client of the hosted canonical issue service,
not a new local grouping engine or an extension to the base six MCP tools.
The base API 1.0 submission envelope, immutable source content, processing
marks and import/export remain unchanged. For command syntax, bounded read
filters, paging, mutation bodies and evidence rules, use
Canonical issues.
| CLI operation | Required advertised HTTP operation |
|---|---|
issues list | GET /api/v1/issues |
issues rank | GET /api/v1/issues/rank |
issues detail <UUID> | GET /api/v1/issues/{issue_id} |
issues source-reports <UUID> | GET /api/v1/issues/{issue_id}/source-reports |
issues prompt <UUID> | GET /api/v1/issues/{issue_id}/prompt |
issues correct <UUID> | PATCH /api/v1/issues/{issue_id} |
issues merge <UUID> | POST /api/v1/issues/{issue_id}/merge |
issues split <UUID> | POST /api/v1/issues/{issue_id}/split |
issues record-outcome <UUID> | POST /api/v1/issues/{issue_id}/outcomes |
issues recheck <UUID> | POST /api/v1/issues/{issue_id}/recheck |
Before an issue operation, the CLI makes an authenticated
GET /api/v1/meta using the selected destination and key, then reads
GET /api/v1/openapi.json. Authentication, authorization, transport,
service and discovery-document failures (including a missing OpenAPI
document) remain those failures; they are never evidence that issues are
unsupported. After successful discovery the exact path structure and
verb must be advertised; path parameter names such as {id} and
{issue_id} are equivalent. Recheck is gated independently of the other
issue operations and is pending on the hosted service; support for prompt,
correction or outcomes does not imply support for recheck.
Local mode and the bundled self-hosted server advertise no issue routes
and therefore report unsupported, not an empty issue set. The client never
switches backends or synthesizes results from base submissions. An
advertised route's 404 not_found remains a not-found response, including
unknown or foreign issue/source/evidence IDs; it does not revoke the
capability. Hosted reads require a read-scoped key and mutations require
process; the base self-hosted key model is unchanged.
Submissions
A submission is any JSON object. The server never rejects a body for its
shape: the envelope schema describes what is
stored after inference, and deviations are normalised and reported as
warnings. The envelope members are kind, schema_version, key,
summary, machine, model, harness, project, occurred_at, context
and payload; payload is validated as a guide against the kind schema when
the server ships one (friction,
review). The stored record adds id,
uid (a UUIDv7), content_hash, created_at, the processing fields and
redacted_at (the Submission schema in openapi.yaml).
Decoding and inference. Only four bodies are rejected: over 10485760
bytes (413), not JSON (400 bad_request), JSON but not an object (400),
nesting deeper than 512 levels (400). Everything else is stored: an unknown
top-level member moves into payload, an absent payload is built from the
moved members, a missing kind becomes unknown, over-long strings are
truncated, an envelope member of the wrong type is encoded into a string
(coerced), and a payload that fails its kind schema is kept as sent, with
warnings. The ordered rules, the normalisation
steps and the fixtures every implementation must pass are in
conformance/README.md ("The write path").
Identity. content_hash is the SHA-256 of the canonical JSON of kind,
schema_version, machine, model, harness, project, summary and
payload after inference (conformance/README.md, "Identity").
key, occurred_at and context are outside identity. A create answers:
| Case | Status |
|---|---|
| new record | 201, Location of the new record |
key already stored under the same kind with the same content_hash (keyed replay) | 200, Location and body of the existing record |
no key, and an unprocessed record with the same content_hash was created within dedupe_window_s (86400 s, in meta) | 200, the existing record (keyless duplicate) |
key already stored under the same kind with a different content_hash | 409 replay_mismatch; details[0].existing_id names the stored record |
A keyless duplicate of a processed record, or one outside the window, is a new record: a recurring problem files again after triage. A keyed replay of a redacted record still answers 200 with the tombstone, because the hash is kept.
Warnings. A create returns warnings beside the stored record: items of
code, pointer (an RFC 6901 pointer into the stored record) and message.
Warnings are never stored and never reject. The closed list of codes, one
example each, is conformance/warnings.json in the repository. The list is
bounded: at most limits.warnings_max entries (100) whose code and
pointer together stay within limits.warnings_bytes (65536); messages are
advisory and outside the budget. A list that fits is returned as the decoder
produced it; one that does not is cut to the first entries in pointer order
(then code, then message) and closed by one warnings_truncated entry at the
record pointer "" whose message counts the withheld ones; the closing entry
counts against both bounds. A pointer is never shortened. On import the same
bound applies per record, and the response's warnings is bounded the same
way in line order, with line naming the first withheld line.
Routes
Create
POST /api/v1/submissions
curl -sS -X POST "$AGENT_FEEDBACK_URL/api/v1/submissions" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/json' \
--data @friction.json
{
"kind": "friction",
"key": "friction-workstation-a-019250f2",
"summary": "README install step 3 references a flag that no longer exists",
"machine": "workstation-a",
"model": "claude-fable-5-1",
"harness": "claude-code",
"project": "example",
"occurred_at": "2026-09-27T09:58:12Z",
"context": {"git_commit": "a1b2c3d", "client": "agentfeedback/4.0.0", "origin": "agent"},
"payload": {
"category": "documentation",
"details": "Step 3 says --init; the flag was removed in 3.0.",
"fix_status": "applied",
"fix_ref": "example@a1b2c3d"
}
}
201 with Location: /api/v1/submissions/44; the same body again is 200 with
the same record:
{
"submission": {
"id": 44,
"uid": "019250f2-8c3e-7a4b-9d1e-2f3a4b5c6d7e",
"kind": "friction",
"schema_version": 1,
"key": "friction-workstation-a-019250f2",
"summary": "README install step 3 references a flag that no longer exists",
"machine": "workstation-a",
"model": "claude-fable-5-1",
"harness": "claude-code",
"project": "example",
"occurred_at": "2026-09-27T09:58:12.000000Z",
"context": {"client": "agentfeedback/4.0.0", "git_commit": "a1b2c3d", "origin": "agent"},
"payload": {
"category": "documentation",
"details": "Step 3 says --init; the flag was removed in 3.0.",
"fix_ref": "example@a1b2c3d",
"fix_status": "applied"
},
"content_hash": "157cf352c13503c9cac14d40ba9ab1bccd2dd8c6bb6567903a9b68881bc79c04",
"created_at": "2026-09-27T10:00:01.123456Z"
},
"warnings": []
}
A flat body with no kind and no payload is stored too, with warnings:
{"summary": "the linter ignores its config file", "category": "tooling", "Model": "claude-fable-5-1", "machine": "workstation-a"}
{
"submission": {
"id": 45,
"uid": "019250f4-3d1a-7e52-8c0b-6f1e2d3c4b5a",
"kind": "unknown",
"schema_version": 1,
"summary": "the linter ignores its config file",
"machine": "workstation-a",
"payload": {"Model": "claude-fable-5-1", "category": "tooling"},
"content_hash": "e4e4baa28c3c46a2cd031183d2c1fc85710c9ffb7d593ca9e84302d288f677c2",
"created_at": "2026-09-27T10:02:40.654321Z"
},
"warnings": [
{"code": "missing_kind", "pointer": "/kind", "message": "kind is missing, empty or not a string; stored as unknown"},
{"code": "payload_inferred", "pointer": "/payload", "message": "payload was absent; built from the members the envelope does not know"},
{"code": "moved_to_payload", "pointer": "/payload/Model", "message": "Model is not an envelope member; moved into payload; did you mean model"},
{"code": "moved_to_payload", "pointer": "/payload/category", "message": "category is not an envelope member; moved into payload"},
{"code": "missing_recommended", "pointer": "/model", "message": "model is recommended"}
]
}
The same key with different content:
{
"error": "replay_mismatch",
"message": "key \"friction-workstation-a-019250f2\" was already used with different content",
"request_id": "cac965ab7ef7e9962133bac8aa047267",
"details": [
{
"code": "key_reused",
"pointer": "/key",
"message": "key \"friction-workstation-a-019250f2\" already names submission 44 with different content",
"existing_id": 44
}
]
}
A body that is JSON but not an object:
{
"error": "bad_request",
"message": "body is a JSON value but not an object",
"request_id": "ec90a683216a22de8d72bdac3f86862b"
}
List
GET /api/v1/submissions
curl -sS -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/submissions?processed=false&limit=1"
{
"submissions": [
{
"id": 45,
"uid": "019250f4-3d1a-7e52-8c0b-6f1e2d3c4b5a",
"kind": "unknown",
"schema_version": 1,
"summary": "the linter ignores its config file",
"machine": "workstation-a",
"content_hash": "e4e4baa28c3c46a2cd031183d2c1fc85710c9ffb7d593ca9e84302d288f677c2",
"created_at": "2026-09-27T10:02:40.654321Z"
}
],
"limit": 1,
"total": 2,
"has_more": true,
"next_before_id": 45,
"next_after_id": null
}
- Filters, all optional, combined with AND:
kind(unknownselects un-kinded rows),schema_version,key,machine,model,project,harness,categoryandfix_status(friction payload members; other kinds never match),origin(context.origin),exclude_kind(repeatable),verdict,processed(falseis the triage queue),redacted(default: both),content_hash,sinceanduntil(RFC 3339, inclusive) on the timestamponnames (created_at, the default, oroccurred_at), andq: a case-insensitive substring oversummaryand every string value inpayload, at most 200 bytes. Exact semantics per parameter are in openapi.yaml. - Strict query. An unknown parameter name, an empty value or a repeated
singleton parameter is 400
validation_errornaming the parameter. - Rows are the record minus
payload;include=payloadadds it. - Paging.
limitis 1–500 (1–100 withinclude=payload), default 50; out of range is 400. Without a cursor the page is newest first.before_id=Npages newest first throughid < N;after_id=Npages oldest first throughid > N; the two are mutually exclusive. Whenhas_moreis true exactly one ofnext_before_id/next_after_idis set, matching the direction used; pass it back as the same cursor.totalcounts every row matching the filters, ignoring cursors andlimit, in the same read transaction as the page. - Draining the queue. Page with
processed=falsefollowingnext_before_iduntilhas_moreis false. Marking a row on the current page does not shift the next one, because the cursor is an id. This is a traversal for one processor at a time, not a lease.
{
"error": "validation_error",
"message": "limit must be between 1 and 500, got 900",
"request_id": "fe5ba4936b4c150d3fe3e06686ec4b1e",
"details": [
{"code": "out_of_range", "pointer": "?limit", "message": "limit must be between 1 and 500, got 900"}
]
}
Get
GET /api/v1/submissions/{id} returns the bare record. {id} must match
^[1-9][0-9]*$, else 400; an absent record is 404 not_found.
curl -sS -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/submissions/44"
{
"id": 44,
"uid": "019250f2-8c3e-7a4b-9d1e-2f3a4b5c6d7e",
"kind": "friction",
"schema_version": 1,
"key": "friction-workstation-a-019250f2",
"summary": "README install step 3 references a flag that no longer exists",
"machine": "workstation-a",
"model": "claude-fable-5-1",
"harness": "claude-code",
"project": "example",
"occurred_at": "2026-09-27T09:58:12.000000Z",
"context": {"client": "agentfeedback/4.0.0", "git_commit": "a1b2c3d", "origin": "agent"},
"payload": {
"category": "documentation",
"details": "Step 3 says --init; the flag was removed in 3.0.",
"fix_ref": "example@a1b2c3d",
"fix_status": "applied"
},
"content_hash": "157cf352c13503c9cac14d40ba9ab1bccd2dd8c6bb6567903a9b68881bc79c04",
"created_at": "2026-09-27T10:00:01.123456Z"
}
{
"error": "not_found",
"message": "submission 99 not found",
"request_id": "a68bfb0dfdae5f7b79b9c6d402361bca"
}
Mark one
PATCH /api/v1/submissions/{id} sets the processing mark and returns the
record; 404 when absent.
curl -sS -X PATCH "$AGENT_FEEDBACK_URL/api/v1/submissions/44" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/json' \
--data @mark.json
{
"processed": true,
"verdict": "fixed",
"resolution": "already applied by the reporter",
"ref": "example@a1b2c3d",
"processed_by": "workstation-b/triage-2026-09-27"
}
{
"id": 44,
"uid": "019250f2-8c3e-7a4b-9d1e-2f3a4b5c6d7e",
"kind": "friction",
"schema_version": 1,
"key": "friction-workstation-a-019250f2",
"summary": "README install step 3 references a flag that no longer exists",
"machine": "workstation-a",
"model": "claude-fable-5-1",
"harness": "claude-code",
"project": "example",
"occurred_at": "2026-09-27T09:58:12.000000Z",
"context": {"client": "agentfeedback/4.0.0", "git_commit": "a1b2c3d", "origin": "agent"},
"payload": {
"category": "documentation",
"details": "Step 3 says --init; the flag was removed in 3.0.",
"fix_ref": "example@a1b2c3d",
"fix_status": "applied"
},
"content_hash": "157cf352c13503c9cac14d40ba9ab1bccd2dd8c6bb6567903a9b68881bc79c04",
"created_at": "2026-09-27T10:00:01.123456Z",
"processed_at": "2026-09-27T14:02:00.000000Z",
"verdict": "fixed",
"resolution": "already applied by the reporter",
"ref": "example@a1b2c3d",
"processed_by": "workstation-b/triage-2026-09-27"
}
- The body is validated strictly: an unknown member, a wrong type or a
nullprocessedis 400validation_error. No content field is writable. processeddefaults totrue.verdict,resolution,refandprocessed_byare allowed only withprocessed=true.verdictis a token of at most 64 bytes; documented values:fixed,invalid,duplicate,wont_fix,deferred,upstream,unverifiable.resolutionis at most 2000 bytes, blank is 400.refandprocessed_byare at most 200 bytes. Arefthat points at another submission holds that row'suid, never itsid.- Marking sets
processed_atif unset. Re-marking with a differentverdict,resolutionorrefreplaces them and keeps the originalprocessed_at.processed=falseclears all five processing fields. Redacted rows can be marked.
Mark a batch
POST /api/v1/submissions/processed: the same fields and rules as
mark one, plus ids (1–500 positive integers, duplicates
collapsed). The answer classifies every id.
curl -sS -X POST "$AGENT_FEEDBACK_URL/api/v1/submissions/processed" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"ids": [44, 45, 999], "processed": true, "verdict": "fixed"}'
{"ids": [44, 45, 999], "processed": true, "verdict": "fixed"}
{"processed": true, "verdict": "fixed", "updated": [45], "unchanged": [44], "not_found": [999]}
updated: the row changed. unchanged: already in the requested state.
not_found: no such id.
Redact
DELETE /api/v1/submissions/{id} replaces the record with a tombstone:
payload becomes {"redacted": true}, summary and context are removed,
redacted_at is set. id, uid, kind, schema_version, key,
machine, model, harness, project, occurred_at, content_hash,
created_at and the processing fields stay. Repeating it is 200 unchanged;
404 when absent. Redaction is not reversible.
curl -sS -X DELETE -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/submissions/45"
{
"id": 45,
"uid": "019250f4-3d1a-7e52-8c0b-6f1e2d3c4b5a",
"kind": "unknown",
"schema_version": 1,
"machine": "workstation-a",
"payload": {"redacted": true},
"content_hash": "e4e4baa28c3c46a2cd031183d2c1fc85710c9ffb7d593ca9e84302d288f677c2",
"created_at": "2026-09-27T10:02:40.654321Z",
"processed_at": "2026-09-27T14:05:00.000000Z",
"redacted_at": "2026-09-27T14:10:00.000000Z",
"verdict": "fixed"
}
Stats
GET /api/v1/stats aggregates over the same filters as list
(everything except the cursors, limit and include).
curl -sS -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/stats?by=project,category&top=1&bucket=week"
{
"total": 245,
"open": 61,
"processed": 184,
"redacted": 2,
"groups": [
{"keys": {"project": "example", "category": "documentation"}, "total": 40, "open": 12, "processed": 28}
],
"recurring": [
{
"content_hash": "157cf352c13503c9cac14d40ba9ab1bccd2dd8c6bb6567903a9b68881bc79c04",
"count": 7,
"first_id": 44,
"last_id": 1187,
"summary": "README install step 3 references a flag that no longer exists",
"kind": "friction",
"project": "example"
}
],
"series": [
{"bucket": "2026-09-21", "total": 31, "open": 4}
]
}
by: a comma list of up to three ofkind,project,category,fix_status,machine,model,harness,verdict,schema_version,origin. Absent: nogroups. Groups are sorted bytotaldescending, at most 100; a row without a value for a listed key is in no group, so group totals need not add up tototal.top: 0–50, default 10; how many recurring content hashes to return.bucket:dayorweekadds a time series on theontimestamp.- Invalid parameters are 400
validation_error.
Export
GET /api/v1/export streams NDJSON, export format 2
(Content-Type: application/x-ndjson), in one read transaction, ascending
id. HEAD /api/v1/export answers the same headers with no body.
curl -sS -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/export?after_id=43" > feedback.ndjson
curl -sS -I -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/export"
{"export_format":2,"kind":null,"since":null,"after_id":43,"exported_at":"2026-09-27T15:00:00.000000Z"}
{"id":44,"uid":"019250f2-8c3e-7a4b-9d1e-2f3a4b5c6d7e","kind":"friction","schema_version":1,"key":"friction-workstation-a-019250f2","summary":"README install step 3 references a flag that no longer exists","machine":"workstation-a","model":"claude-fable-5-1","harness":"claude-code","project":"example","occurred_at":"2026-09-27T09:58:12.000000Z","context":{"client":"agentfeedback/4.0.0","git_commit":"a1b2c3d","origin":"agent"},"payload":{"category":"documentation","details":"Step 3 says --init; the flag was removed in 3.0.","fix_ref":"example@a1b2c3d","fix_status":"applied"},"content_hash":"157cf352c13503c9cac14d40ba9ab1bccd2dd8c6bb6567903a9b68881bc79c04","created_at":"2026-09-27T10:00:01.123456Z","processed_at":"2026-09-27T14:02:00.000000Z","verdict":"fixed","resolution":"already applied by the reporter","ref":"example@a1b2c3d","processed_by":"workstation-b/triage-2026-09-27"}
{"id":45,"uid":"019250f4-3d1a-7e52-8c0b-6f1e2d3c4b5a","kind":"unknown","schema_version":1,"machine":"workstation-a","payload":{"redacted":true},"content_hash":"e4e4baa28c3c46a2cd031183d2c1fc85710c9ffb7d593ca9e84302d288f677c2","created_at":"2026-09-27T10:02:40.654321Z","processed_at":"2026-09-27T14:05:00.000000Z","redacted_at":"2026-09-27T14:10:00.000000Z","verdict":"fixed"}
{"export_complete":true,"count":2,"first_id":44,"last_id":45,"sha256":"dc5af905018c8d65122241337d76b7326e4eb44758113483577274e0a2898076"}
- Header (line 1):
export_format, then the filters echoed (kind,since,after_id;nullwhen unset) andexported_at. - Records: one full record per line; tombstones are exported as tombstones.
- Trailer (last line):
export_complete,count,first_id,last_idandsha256, the lowercase hex SHA-256 over the record lines only, each with its trailing newline, header and trailer excluded, in order. A stream without the trailer, or whose count or digest disagrees with it, is damaged. - Filters:
kind,since(inclusive, oncreated_at),after_id(id > after_id),limit(1–500; absent means everything). Page a large export withafter_idset to the previous trailer'slast_id.
Backups, restore and migration with the CLI are in operate.md.
Import
POST /api/v1/import takes an export-format-2 NDJSON body
(Content-Type: application/x-ndjson, at most 33554432 bytes).
curl -sS -X POST "$AGENT_FEEDBACK_URL/api/v1/import" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/x-ndjson' \
--data-binary @feedback.ndjson
{
"imported": 1,
"skipped": 1,
"conflicts": [
{"line": 2, "uid": "019250f2-8c3e-7a4b-9d1e-2f3a4b5c6d7e", "existing_id": 12, "reason": "key_mismatch"}
],
"warnings": [],
"first_id": 301,
"last_id": 301
}
- The whole body is read and verified (count, digest, one JSON object per
record line) before anything is written. A record whose
uidis not an RFC 9562 UUID, or whosecreated_at,occurred_at,processed_atorredacted_atdoes not parse as RFC 3339, is 400validation_errornaming the line, and nothing is written. - Envelope members pass through the same normalisation and limits as
create; violations are
warningswith aline, never rejections. content_hashis recomputed for ordinary records and must equal the exported value, else 400 naming the line; tombstones keep the exported hash.- Each imported record gets a new
id;uid,created_at,occurred_atand the processing fields are kept. - An existing
uidis skipped and counted inskipped; redactions and processing marks never propagate to a stored row. An existing(kind, key)under a differentuidis skipped and counted when thecontent_hashmatches, otherwise skipped, counted and listed underconflicts. A conflict never fails the request.
Schemas
GET /api/v1/schemas lists the kinds and versions the server knows,
including envelope. No key.
curl -sS "$AGENT_FEEDBACK_URL/api/v1/schemas"
{
"schemas": [
{"kind": "envelope", "versions": [1]},
{"kind": "friction", "versions": [1]},
{"kind": "review", "versions": [1]}
]
}
GET /api/v1/schemas/{kind}/{version} returns one JSON Schema document
verbatim, Content-Type: application/schema+json; envelope is a valid
{kind}. 404 when the pair is unknown. No key. The body is the file under
schemas/.
curl -sS "$AGENT_FEEDBACK_URL/api/v1/schemas/friction/1"
Meta
GET /api/v1/meta returns versions, limits, kinds and features.
client.min_version is the oldest client release that speaks this API,
raised only when a server change breaks older clients; a client older than
it should report plainly that it is too old. client.latest_known is the
server version.
curl -sS -H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
"$AGENT_FEEDBACK_URL/api/v1/meta"
{
"service_version": "4.0.0",
"api_version": "1.0",
"export_format": 2,
"dedupe_window_s": 86400,
"limits": {
"body_bytes": 10485760,
"import_bytes": 33554432,
"list_max": 500,
"list_max_with_payload": 100,
"processed_ids_max": 500,
"context_entries": 32,
"context_value_bytes": 2000,
"identifier_bytes": 200,
"summary_bytes": 2000,
"warnings_max": 100,
"warnings_bytes": 65536
},
"kinds": [
{"kind": "friction", "versions": [1]},
{"kind": "review", "versions": [1]}
],
"features": ["q", "stats", "export.after_id", "export.limit", "import", "mcp", "redaction"],
"client": {"min_version": "4.0.0-rc.0", "latest_known": "4.0.0"}
}
OpenAPI document
GET /api/v1/openapi.json returns openapi.yaml as JSON, in
bundled form with the referenced schema files inlined. No key.
curl -sS "$AGENT_FEEDBACK_URL/api/v1/openapi.json"
MCP
POST /mcp is a remote MCP server over the MCP Streamable HTTP transport,
authenticated like /api/v1/*. POST /mcp/{project} is the same with
project preset for the connection. The request needs
Content-Type: application/json (else 415) and an Accept header naming
both application/json and text/event-stream (else 400). The answer is a
JSON-RPC message, as application/json or as a server-sent event stream; a
notification or response from the client is 202 with no body. The body limit
is 10551296 bytes. The tools, their arguments and how the preset scopes them
are in operate.md.
curl -sS -X POST "$AGENT_FEEDBACK_URL/mcp" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "curl", "version": "1"}
}
}
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"tools":{"listChanged":true}},"instructions":"# AgentFeedback\n\n…","protocolVersion":"2025-06-18","serverInfo":{"name":"agentfeedback","version":"4.0.0"}}}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "submit_feedback",
"arguments": {"kind": "friction", "summary": "make test needs port 5432 free and says nothing when it is taken", "category": "tooling"}
}
}
curl -sS -X POST "$AGENT_FEEDBACK_URL/mcp/example" \
-H "Authorization: Bearer $AGENT_FEEDBACK_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data @tools-call.json
Skill
GET /skill returns the submission guidance as Markdown with this server's
base URL substituted. No key. format: skill-md (default; the guidance
runs the binary), agents-md, or prompt (HTTP instructions inline: the
curl recipe with the server's URL).
curl -sS "$AGENT_FEEDBACK_URL/skill?format=prompt"
Discovery
GET /.well-known/agentfeedback.json says where the OpenAPI document,
schemas, MCP endpoint and skill live. No key, no data, no secrets.
curl -sS "$AGENT_FEEDBACK_URL/.well-known/agentfeedback.json"
{
"service": "agentfeedback",
"version": "4.0.0",
"api_version": "1.0",
"openapi": "/api/v1/openapi.json",
"schemas": "/api/v1/schemas",
"mcp": "/mcp",
"skill": "/skill",
"auth": {"modes": ["bearer", "x-api-key"]},
"docs": "https://agentfeedback.dev/docs"
}
Health, ready, metrics
No key; keep them inside the deployment boundary.
curl -sS "$AGENT_FEEDBACK_URL/health" # 200 OK: the process is alive
curl -sS "$AGENT_FEEDBACK_URL/ready" # 200 READY; 503 unavailable while not ready
curl -sS "$AGENT_FEEDBACK_URL/metrics" # Prometheus text exposition format
Metric labels are the route pattern, an allow-listed method and the status code, never a raw path or client input. What to alert on is in operate.md.
Evolution policy
Applies to this contract: openapi.yaml, the schemas under
schemas/ and the conformance kit under
conformance/. Producers and consumers can rely
on it.
- Additive changes never bump a kind's
schema_version: a new optional payload member, a newcontextkey, a new documented value of an existing member.frictionevidence[]and thecontextkeysorigin,detectorandsession_harnesswere added this way to version 1. - A
schema_versionbump means a member changed meaning or shape. The new version is a new schema file beside the old one; a server that does not ship a version stores its payload unvalidated with anunknown_schema_versionwarning. - Unknown fields are stored, never rejected: an unknown payload member of a
kind the server ships a schema for is kept with an
unknown_fieldwarning (a kind without one is stored unvalidated withno_schema), an unknown top-level member is moved intopayload, an unknowncontextkey is kept as sent. contextstays a flat map of strings (32 entries, values up to 2000 bytes) about how and where a submission came to be. Structured content goes inpayload.contextis not part ofcontent_hash, so a new key leaves a submission's identity unchanged while the context stays within 32 entries; entries beyond the first 32 move intopayload.context_overflow, which is part of identity.- A documented vocabulary in a
contextvalue (origin:agent,hook-nudge,session-scan,inbox,import) is not enforced: another value is stored as sent, without a warning.
Version axes
The values of this contract, release 4.0.0 and later. Each axis moves on its own; none implies another.
| Axis | Current | Where it is read | Changes when |
|---|---|---|---|
| API | 1.0 | info.version in openapi.yaml; api_version in GET /api/v1/meta | a route, parameter or response member is added (minor) or changed (major) |
| Envelope | v1 | $id of schemas/envelope.v1.json | the envelope's members change meaning; additions stay v1 |
| Kind payload | schema_version per kind, 1 for friction and review | the submission's schema_version; kinds in GET /api/v1/meta | a payload member changes meaning or shape (see Evolution policy) |
| Export format | 2 | export_format in the export header line and in GET /api/v1/meta | the NDJSON export lines change shape |
| Binary | the release version, e.g. 4.0.0 | agentfeedback version; service_version in GET /api/v1/meta | every release; client.min_version in GET /api/v1/meta rises only when a server change breaks older clients |
| Skills | metadata.version in each SKILL.md | the skill's front matter | the commands or rules a skill teaches change |
Changes
None since API 1.0.