agent-eval exposes its evaluation logic over a versioned wire protocol so non-TypeScript clients (Python, Rust, Go, …) can drive it without a parallel implementation. The TypeScript runtime is the single source of truth; clients in other languages are transport adapters, not ports.
your code (any language)
│
▼
thin transport client ──HTTP──▶ agent-eval serve ──┐
│ │
└─────subprocess────────▶ agent-eval rpc ──┤
▼
same TS handlers, same rubrics,
same scoring code
Both transports talk to identical handlers. If you need a sustained connection (live agent paths, high-frequency calls), use HTTP. If you need a one-shot (cron, CI, batch), use stdio RPC. The wire shape is the same.
This versioned /v1 protocol is separate from the source-only distributed Dispatch adapter.
The adapter's /dispatch endpoint is documented in docs/distributed-driver.md.
Its request body includes the required runAttemptId, which the worker must preserve unchanged when it builds its DispatchContext.
| HTTP | stdio RPC | |
|---|---|---|
| Start | agent-eval serve --port 5005 |
per-call: agent-eval rpc <method> |
| Latency | ~10 ms | ~500 ms (Node startup) |
| Best for | live calls, agent paths, dashboards | cron, CI, batch evaluation |
| Requires | running server | binary on PATH |
The current surface is the smallest useful slice. Adding a method is mechanical: see §Adding a method.
POST /v1/judge
{
"rubricName": "anti-slop",
"content": "We just shipped zero-copy IO between sandboxes",
"context": { "platform": "x", "author": "drew", "impressions": 1240 }
}{
"composite": 0.78,
"dimensions": { "buyer_quality": 0.85, "voice": 0.7, "signal": 0.8 },
"failureModes": [],
"wins": ["specific-component", "earned-detail"],
"rationale": "Specific architectural detail, no AI cadence, technical voice.",
"rubricVersion": "anti-slop@sha256-rfc8785:2307e4f3f0948d20275a1521515c36635b37ba848149d3f603b6dfde07f7ea1c",
"model": "claude-sonnet-4-6",
"durationMs": 1840
}Pass either rubricName (built-in) or rubric (inline definition). Not both. The handler:
- Resolves the rubric.
- Calls the judging LLM with a JSON-schema-constrained response.
- Computes
composite = Σ(weight_i × normalized_score_i) / Σ(weight_i). - Returns a typed
JudgeResult.
rubricVersion is the stable identity of the rubric used, as <name>@<scheme>:<hex>. Scores are only comparable across runs when it matches exactly.
The scheme is named inside the value: sha256-rfc8785 is the SHA-256 of the rubric's RFC 8785 canonical JSON, which orders keys by UTF-16 code unit and so does not depend on the server's locale.
A tag whose scheme differs from the one you hold says this package changed how it hashes, not that the rubric changed — re-read the rubric rather than treating the series as broken. Releases before 0.167.0 emitted an untagged 32-bit value (anti-slop@a4f2b8c1); those tags are not comparable with these.
GET /v1/rubrics{
"rubrics": [
{
"name": "anti-slop",
"description": "Voice and signal quality for technical-buyer content.",
"dimensions": [
{ "id": "buyer_quality", "description": "Would the target buyer care?", "weight": 0.5 },
{ "id": "voice", "description": "Builder voice, not AI/marketing?", "weight": 0.3 },
{ "id": "signal", "description": "Non-obvious detail or constraint?", "weight": 0.2 }
],
"failureModes": ["ai-cadence", "marketing-tone", "vague-claim", "no-hook", "engagement-bait", "off-icp", "stale-claim"],
"rubricVersion": "anti-slop@sha256-rfc8785:2307e4f3f0948d20275a1521515c36635b37ba848149d3f603b6dfde07f7ea1c"
}
]
}GET /v1/version{
"package": "@tangle-network/agent-eval",
"version": "0.150.1",
"wireVersion": "1.0.0",
"apiSurface": ["judge", "listRubrics", "version", "feedback.ingest", "traces.ingest"]
}version matches the package version. wireVersion bumps independently: only on breaking request/response schema changes. Package versions can differ across releases as long as wireVersion matches.
POST /v1/traces/ingest
{
"events": [
{
"eventId": "evt_01h...",
"runId": "run_01h...",
"spanId": "span_01h...",
"kind": "log",
"timestamp": 1732556400000,
"payload": { "message": "..." }
}
]
}{
"accepted": 1,
"rejected": 0,
"errors": []
}Also accepts content-type: application/x-ndjson — one TraceEvent object per line instead of a wrapping {"events": [...]} body. Max 10,000 events per call; chunk bigger streams.
Best-effort: each event is appended independently, so one bad event doesn't poison the batch — rejects land in errors[], keyed by eventId. The underlying store is append-only: retries of the same payload produce duplicate events, so consumers should de-duplicate by eventId downstream (production traces frequently land via at-least-once buses — Kafka, SQS — where dedup is unavoidable). Requires a traceStore passed to createApp; without one this returns 503 service_unavailable.
kind is one of log, error, budget_decrement, budget_breach, state_mutation, policy_violation, redaction_applied, custom, matching the TraceSchema v1 EventKind enum. payload is free-form — the runtime owns its shape.
POST /v1/feedback
{
"id": "traj_01h...",
"task": { "intent": "Summarize the incident report." },
"labels": [
{ "source": "user", "kind": "approve", "value": true, "createdAt": "2026-08-01T00:00:00Z" }
],
"createdAt": "2026-08-01T00:00:00Z"
}{
"id": "traj_01h...",
"persisted": true
}Idempotent on id: re-posting the same id replaces the prior record. Requires a feedbackStore passed to createApp; without one this returns 503 service_unavailable.
task.intent and createdAt are required; attempts and labels default to []; outcome, split (train / dev / test / holdout), and tags are optional. See FeedbackTrajectorySchema in src/wire/schemas.ts for the full shape, including per-attempt artifacts, proposed actions, and per-label severity.
For probing whether a server is up. Returns { "status": "ok", "uptimeSec": <number> }.
Auto-generated from the Zod schemas. This is what code generators consume to produce typed clients in other languages.
Every error response uses the same shape:
{
"error": {
"code": "rubric_not_found",
"message": "No built-in rubric named \"missing-name\".",
"details": null
}
}| HTTP | code | meaning |
|---|---|---|
| 400 | validation_error |
Request didn't match the schema. |
| 404 | rubric_not_found |
Unknown rubricName. |
| 500 | judge_error |
LLM returned malformed output. |
| 500 | internal_error |
Unexpected server error. |
| 503 | service_unavailable |
traces.ingest or feedback.ingest called without the matching store passed to createApp. |
stdio RPC uses the same shape inside an envelope: {"error": {...}} instead of {"result": {...}}. Exit code is non-zero on error.
agent-eval serve --port 5005 --host 127.0.0.1Defaults to 127.0.0.1:5005. Bind to 0.0.0.0 only if you trust the network.
# health
curl http://localhost:5005/healthz
# discover
curl http://localhost:5005/v1/rubrics | jq
# judge
curl -X POST http://localhost:5005/v1/judge \
-H 'content-type: application/json' \
-d '{"rubricName":"anti-slop","content":"We just shipped …"}'
# traces ingest
curl -X POST http://localhost:5005/v1/traces/ingest \
-H 'content-type: application/json' \
-d '{"events":[{"eventId":"evt_1","runId":"run_1","kind":"log","timestamp":1732556400000,"payload":{}}]}'
# feedback ingest
curl -X POST http://localhost:5005/v1/feedback \
-H 'content-type: application/json' \
-d '{"id":"traj_1","task":{"intent":"Summarize the incident report."},"createdAt":"2026-08-01T00:00:00Z"}'# version
echo '{}' | agent-eval rpc version
# listRubrics
echo '{}' | agent-eval rpc listRubrics
# judge (one-shot)
echo '{"rubricName":"anti-slop","content":"…"}' | agent-eval rpc judge
# JSONL batch: one request per line
cat requests.jsonl | agent-eval rpc-batch judge > results.jsonlEach invocation is one process: Node startup adds ~500 ms. For more than a few calls, stand up a server.
- Python: source lives in
clients/python. Auto-detects HTTP, falls back to subprocess. Version-locked to npm. - TypeScript: import directly from
@tangle-network/agent-eval(no wire round-trip needed in-process). - Rust / Go / Other: generate from
dist/openapi.json. PRs welcome to add an officially-maintained client.
- Schema: define
XRequestSchemaandXResponseSchemainsrc/wire/schemas.ts. Every field gets a.describe()so docs flow through to OpenAPI. - Handler: pure function in
src/wire/handlers.ts. ThrowsWireErrorfor caller-fixable issues. - Server route:
app.post('/v1/x', …)insrc/wire/server.ts. - RPC case: add
case 'x':indispatchRpcinsrc/wire/rpc.ts. - OpenAPI route: register in
src/wire/openapi.tsso it shows up in the spec. - Test: add to
tests/wire/. At minimum: schema validation, happy-path, error-path. - Python client: add a method on
Clientinclients/python/src/agent_eval_rpc/client.py, plus pydantic models inmodels.pymirroring the new schemas.
The pattern is mechanical. When the surface grows past ~10 methods, swap the hand-written Python models for datamodel-code-generator -i openapi.json -o models.py.
WIRE_VERSION (in src/wire/schemas.ts) is a separate semver from the npm/PyPI package version. It bumps on breaking changes to a request/response schema. Additive changes (new optional fields, new methods) don't require a bump.
When WIRE_VERSION bumps, every language client gets a new major version; the dual-publish CI (see .github/workflows/publish.yml) enforces this lock-step.