Traces — User Feedback API
All endpoints verified working via curl. Document updated only after curl confirmation.
User/human feedback attached to a trace, or to one span within it. Feedback is
free-form: a caller supplies at least one of rating, label, comment —
none are individually required. Auth: requireAnyAuth (session cookie or
personal/team API key) for both posting and reading; any team role. Team-scoped
throughout — a trace outside the caller's team behaves as if it doesn't exist
(404), same as GET /api/v1/traces/:id.
spanId in every request/response is the caller-supplied OTel span reference
(e.g. "s1", the same value used in POST /api/v1/traces and returned by
GET /api/v1/traces/:id) — never the internal span UUID. Trace-level
feedback (no span) has spanId: null.
Feedback can be edited after posting (PATCH /api/v1/traces/:id/feedback/:feedbackId),
but only by the row's original author (createdBy) — see that section below.
Deleting feedback is not supported. updatedAt equals createdAt until the row
has been edited.
POST /api/v1/traces/:id/feedback
Attaches feedback to a trace. Body accepts rating (integer -1..5), label
(string, <=200 chars), comment (string, <=5000 chars), spanId (OTel ref,
optional — omit for whole-trace feedback), and source (one of user,
developer, end_user, api; defaults to user). At least one of
rating/label/comment is required. createdBy is the caller's user id;
verified non-null here for a personal API key (per the controller, it is
req.user?.id ?? null — a team-scoped key with no associated user would
yield null, though that path was not separately exercised).
- curl
- TypeScript
- Python
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rating": -1, "label": "wrong_answer", "comment": "The tool call missed relevant docs.", "source": "end_user"}'
const feedback = await hub.traces.submitFeedback({
traceId: 'fbb5f16e-521b-4839-b9ab-78ace8c1f0a4',
rating: -1,
label: 'wrong_answer',
comment: 'The tool call missed relevant docs.',
source: 'end_user',
});
feedback = await hub.traces.submit_feedback(
'fbb5f16e-521b-4839-b9ab-78ace8c1f0a4',
rating=-1,
label='wrong_answer',
comment='The tool call missed relevant docs.',
source='end_user',
)
Response (status 201):
{
"id": "d71ba68f-a7bf-424d-a424-a0ed80ec1592",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "end_user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:11.976Z",
"updatedAt": "2026-07-05T02:33:11.976Z"
}
Span-level feedback (spanId)
Supplying spanId attaches the feedback to one span instead of the whole
trace. The value is the OTel ref from the original POST /api/v1/traces call
(here "s1", the LLM span) — the response echoes that same ref back, not the
span's internal UUID.
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rating": 5, "spanId": "s1"}'
Response (status 201):
{
"id": "bd505193-ea39-4e23-a9b5-c356d38b5064",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s1",
"rating": 5,
"label": null,
"comment": null,
"source": "user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.590Z",
"updatedAt": "2026-07-05T02:33:24.590Z"
}
A second span-level example — label only, no rating, source "developer", against span "s2" (a tool span, child of "s1"):
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "good_tool_use", "spanId": "s2", "source": "developer"}'
Response (status 201):
{
"id": "f848d26d-5f1f-4d18-8d47-2dc76675fb21",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s2",
"rating": null,
"label": "good_tool_use",
"comment": null,
"source": "developer",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.608Z",
"updatedAt": "2026-07-05T02:33:24.608Z"
}
PATCH /api/v1/traces/:id/feedback/:feedbackId
Edits an existing feedback row's rating/label/comment in place (author-only —
only the user who originally posted the row, matched via createdBy, may
edit it; rows posted via a team API key have createdBy: null and so cannot
be edited by anyone). Fields omitted from the body keep their existing value;
null explicitly clears a field. Deleting feedback is not supported.
- curl
- TypeScript
- Python
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": 1}'
const updated = await hub.traces.updateFeedback({
traceId: 'a856684b-79cf-4a83-ac8f-f50294ec07dc',
feedbackId: '7133430d-9e2c-4bb3-90b9-f56e95bde619',
rating: 1,
});
updated = await hub.traces.update_feedback(
'a856684b-79cf-4a83-ac8f-f50294ec07dc',
'7133430d-9e2c-4bb3-90b9-f56e95bde619',
rating=1,
)
Response (status 201):
{
"id": "7133430d-9e2c-4bb3-90b9-f56e95bde619",
"traceId": "a856684b-79cf-4a83-ac8f-f50294ec07dc",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "user",
"createdBy": "e0016d77-79da-4094-8bc9-e5ba5ff84527",
"createdAt": "2026-07-05T18:14:13.237Z",
"updatedAt": "2026-07-05T18:14:13.237Z"
}
The author corrects the rating; label/comment are omitted, so they keep their existing values:
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": 1}'
Response (status 200) — rating flipped, label/comment unchanged, updatedAt advanced:
{
"id": "7133430d-9e2c-4bb3-90b9-f56e95bde619",
"traceId": "a856684b-79cf-4a83-ac8f-f50294ec07dc",
"spanId": null,
"rating": 1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "user",
"createdBy": "e0016d77-79da-4094-8bc9-e5ba5ff84527",
"createdAt": "2026-07-05T18:14:13.237Z",
"updatedAt": "2026-07-05T18:14:22.536Z"
}
Error responses
Clearing rating/label/comment down to nothing (status 400, same message as the create-side validation — the merged result, not just the patch body, is checked):
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": null, "label": null, "comment": null}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Provide at least one of rating, label, or comment." } }
A teammate who is not the row's author (status 403) — the row is left unchanged:
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": -1}'
{ "error": { "code": "FORBIDDEN", "message": "Only the original author can edit this feedback." } }
Unknown feedback id, or one belonging to another team's trace (status 404 — never distinguishes the two, same as the trace-lookup 404s above):
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/00000000-0000-0000-0000-000000000000 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "NOT_FOUND", "message": "Feedback not found." } }
No Authorization header (status 401):
curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }
GET /api/v1/traces/:id/feedback
Lists all feedback on a trace (trace-level and span-level, mixed), newest first. Continuing the example above, after the three POSTs:
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback"
Response (status 200):
{
"data": [
{
"id": "f848d26d-5f1f-4d18-8d47-2dc76675fb21",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s2",
"rating": null,
"label": "good_tool_use",
"comment": null,
"source": "developer",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.608Z",
"updatedAt": "2026-07-05T02:33:24.608Z"
},
{
"id": "bd505193-ea39-4e23-a9b5-c356d38b5064",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s1",
"rating": 5,
"label": null,
"comment": null,
"source": "user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.590Z",
"updatedAt": "2026-07-05T02:33:24.590Z"
},
{
"id": "d71ba68f-a7bf-424d-a424-a0ed80ec1592",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "end_user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:11.976Z",
"updatedAt": "2026-07-05T02:33:11.976Z"
}
]
}
The same three feedback rows also appear, unchanged, in the additive
feedback field of GET /api/v1/traces/:id (see docs/api/traces/traces.mdx) —
verified byte-for-byte identical to the data array above.
GET /api/v1/traces/feedback/summary
Average rating + counts, grouped by model or prompt_version (query param
group_by, default prompt_version). Optional from/to (ISO 8601)
restrict the window; default is the last 30 days. downCount is the number
of rows with rating < 0 (thumbs-down) in that bucket; avgRating is null
for a bucket with no rated feedback.
Every bucket carries three identity fields:
key— the raw grouping value: a model name, or a prompt version UUID.label— the readable name to display: the model name, or"<prompt name> v<version number>". Falls back to the raw id if the version row no longer exists.promptId— the prompt owning that version, so a client can link the bucket to its prompt. Alwaysnullwhen grouping bymodel.
Buckets are ordered by label.
Attribution is per-TRACE, not per-span: every feedback row on a trace is
counted against every distinct model/prompt_version used by that trace's
llm spans, regardless of which span (or none) the feedback itself is
attached to (see aggregate() in feedback.repository.ts).
The two calls below were run against a trace with one llm span
(model gpt-4o-mini, stamped with version 1 of a prompt named
support-reply) carrying two trace-level feedback rows, ratings -1 and 1.
from narrows the window to just that run.
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback/summary?from=2026-09-06T11:57:29Z"
Response (status 200) — count 2; avgRating 0 (mean of -1 and 1); downCount 1:
{
"groupBy": "prompt_version",
"buckets": [
{
"key": "3219b466-b070-4a0d-a666-a38c372419b9",
"label": "support-reply v1",
"promptId": "2b4708b9-9845-4a73-9a04-edd13c8364c0",
"count": 2,
"avgRating": 0,
"downCount": 1
}
]
}
The same window grouped by model — label repeats the model name and
promptId is null:
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback/summary?group_by=model&from=2026-09-06T11:57:29Z"
Response (status 200):
{
"groupBy": "model",
"buckets": [
{
"key": "gpt-4o-mini",
"label": "gpt-4o-mini",
"promptId": null,
"count": 2,
"avgRating": 0,
"downCount": 1
}
]
}
A window containing no feedback returns an empty bucket list:
{ "groupBy": "prompt_version", "buckets": [] }
Error responses
Empty body — none of rating/label/comment supplied (status 400):
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Provide at least one of rating, label, or comment." } }
rating outside -1..5 (status 400):
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 9}'
{ "error": { "code": "VALIDATION_ERROR", "message": "rating must be between -1 and 5." } }
spanId does not belong to this trace (status 400, distinct error code):
curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1, "spanId": "does-not-exist"}'
{ "error": { "code": "INVALID_SPAN", "message": "spanId does not belong to this trace." } }
Unknown traceId (status 404):
curl -X POST $ACRUXCORE_BASE_URL/traces/00000000-0000-0000-0000-000000000000/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "NOT_FOUND", "message": "Trace not found." } }
Trace exists but belongs to another team (status 404) — same message as unknown, so the endpoint never confirms whether a foreign trace exists:
{ "error": { "code": "NOT_FOUND", "message": "Trace not found." } }
No Authorization header, on either POST or the summary GET (status 401):
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }
GET /api/v1/traces/feedback
T10: the team-wide raw feed backing the feedback visualization page —
distinct from GET /:id/feedback (one trace) and /feedback/summary (aggregated).
Newest-first, paginated (page/limit, limit capped at 100 like other list
endpoints). Same row shape as the other feedback endpoints; spanId is the
OTel ref (null for whole-trace feedback).
Filters. Every filter on GET /api/v1/traces applies here
and describes the feedback row's trace — prompt_id, tags, metadata[key],
q, q_in, model, status, session_id, and the score bounds. Four
filters are specific to the feedback row itself:
rating—up(rating above 0),down(below 0), ornone(no rating).source—user,developer,end_user, orapi.label— exact match.has_comment—truefor rows carrying a written critique,falsefor rows without one.
One exception to the shared vocabulary: from/to window the feedback
row's timestamp, not its trace's, because a critique is often written days
after the run it grades. There is no default window here.
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback?limit=2"
Response (status 200) — 3 feedback rows exist across the team's traces; this page shows the 2 newest:
{
"data": [
{
"id": "c80dfe36-cf93-4a0a-9a74-2a32ae2e33d2",
"traceId": "b6f37c03-30f6-4b8a-9082-24c9cbc4f8f4",
"spanId": "s2",
"rating": 1,
"label": null,
"comment": null,
"source": "developer",
"createdBy": "c7f4e80d-7873-445a-92a2-eaadfeb88896",
"createdAt": "2026-07-05T17:11:43.283Z",
"updatedAt": "2026-07-05T17:11:43.283Z"
},
{
"id": "ded66aec-e08f-48f9-93da-fcb45bbb6a87",
"traceId": "b6f37c03-30f6-4b8a-9082-24c9cbc4f8f4",
"spanId": "s2",
"rating": -1,
"label": null,
"comment": "Flight summary was thin",
"source": "developer",
"createdBy": "c7f4e80d-7873-445a-92a2-eaadfeb88896",
"createdAt": "2026-07-05T17:08:20.923Z",
"updatedAt": "2026-07-05T17:08:20.923Z"
}
],
"total": 3,
"page": 1,
"limit": 2
}
Filtering the feed
Thumbs-down rows that carry a written comment — the shape worth turning into evaluation data, since the comment becomes the example's judge criteria:
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback?rating=down&has_comment=true&limit=1"
Response (status 200) — total counts the filtered set, not the whole feed:
{
"data": [
{
"id": "5c9baf1d-3a51-4e38-8dab-76de04ebf8c7",
"traceId": "faa16a20-9a25-48b1-9396-e9b4720b0666",
"spanId": null,
"rating": -1,
"label": "test",
"comment": "test",
"source": "developer",
"createdBy": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"author": {
"id": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"name": "Demo",
"email": "demo@acruxcore.com"
},
"createdAt": "2026-09-07T19:24:52.773Z",
"updatedAt": "2026-09-07T19:25:04.214Z"
}
],
"total": 4,
"page": 1,
"limit": 1
}
No Authorization header (status 401):
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }