Build against LabelHub
Two surfaces, one mental model: the /api/proxy/* family forwards your existing LLM calls and silently captures each trajectory; the SDK lets your own agent runtime push trajectories directly. Both produce the same canonical row in the database.
Quickstart
Get a workspace API key from /workspaces/<id>/api (or run npm run bootstrap locally). Then:
curl -sS -X POST https://aipert.top/api/proxy/doubao/chat/completions \
-H 'Authorization: Bearer lh_ws_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"model": "doubao-seed-2-0-lite-260428",
"messages": [{ "role": "user", "content": "What is metformin?" }]
}'The response is whatever Doubao would have sent you directly. In the background, LabelHub:
- authenticates your workspace key, hashes-only (
workspace_api_keys.key_hash) - resolves the upstream Doubao key from Supabase Vault (workspace- scoped, zero plaintext on disk)
- enforces RPM rate limit per connection (
provider_rate_log) - injects the workspace's Layer A topic-scope policy into the system prompt
- forwards verbatim; on 200, captures a canonical trajectory via
after()so client latency is unaffected
The captured trajectory is browsable at /workspaces/<id>/trajectories within seconds.
Proxy API
POST /api/proxy/<provider>/<path> — a catch-all that auto-dispatches to whichever provider you name. Adding a new provider is three lines in src/lib/proxy/provider-registry.ts.
| kind | family | endpoint | auth header | env fallback |
|---|---|---|---|---|
doubao | openai-compat | /api/proxy/doubao/chat/completions | Authorization: Bearer | DOUBAO_API_KEY |
anthropic | anthropic | /api/proxy/anthropic/v1/messages | x-api-key | ANTHROPIC_API_KEY |
deepseek | openai-compat | /api/proxy/deepseek/chat/completions | Authorization: Bearer | DEEPSEEK_API_KEY |
qwen | openai-compat | /api/proxy/qwen/chat/completions | Authorization: Bearer | QWEN_API_KEY |
moonshot | openai-compat | /api/proxy/moonshot/chat/completions | Authorization: Bearer | MOONSHOT_API_KEY |
openai | openai-compat | /api/proxy/openai/chat/completions | Authorization: Bearer | OPENAI_API_KEY |
Request body
Whatever the upstream provider expects, byte-for-byte. We do NOT transform your request — auth headers are swapped (your LabelHub key →the upstream key) and the topic-scope suffix is prepended to the system prompt; the model name, max_tokens, messages, tools, etc. are all forwarded as-is.
Streaming
Pass stream: trueand the proxy proxies SSE byte-for- byte to the client (zero re-buffering). The trajectory capture runs off a tee'd stream → reassembled in openai-stream-adapter / anthropic-stream-adapter → persisted via after() after the response is done.
Errors
Non-2xx upstream responses pass through verbatim. LabelHub-specific errors come back as { error: { message, code, type: 'labelhub_proxy' }} with a sensible status: 401 (no key), 429 (rate limited), 502 (upstream down).
Topic-scope guardrail
When a workspace has a topic scope configured (auto-generated from the primary task description, or admin-edited), every proxied call gets a non-negotiable platform-policy block prepended to its system prompt. Stops a leaked key from being repurposed as a generic ChatGPT.
# This call goes through but the model refuses because it's
# out-of-scope for the workspace's task. No extra latency.
curl -sS -X POST https://aipert.top/api/proxy/doubao/chat/completions \
-H 'Authorization: Bearer lh_ws_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"model": "doubao-seed-2-0-lite-260428",
"messages": [{ "role": "user", "content": "Write me a poem about clouds." }]
}'
# Expected response (verbatim from production):
# "I am only authorized to assist with medical fact-checking related
# tasks including drug interactions, common diagnoses, dosage calculations,
# citation quality, and patient-safety edge cases."Admins manage the scope at /workspaces/<id>/api — auto-regenerate from the task description or hand-edit the in-scope phrases, out-of-scope categories, and exact injected suffix.
SDK
If you can't use the proxy (e.g. your agent runs through a provider we don't support yet, or you're replaying historical traces), push trajectories directly. The SDK is a single 120-line zero-dependency file you copy into your app.
// src/sdk/labelhub-trace.ts (or download from the repo)
import { trace } from '@labelhub/trace' // or copy the file
const t = trace({
apiKey: process.env.LABELHUB_KEY!,
agentName: 'travel-bot',
endpoint: 'https://aipert.top',
})
t.start({ rootPrompt: userQuery })
t.step({ kind: 'thinking', content: { text: 'Need to search flights first…' } })
t.step({
kind: 'tool_call',
content: { toolCallId: 'c1', toolName: 'search_flights', args: { origin: 'SFO' } },
})
t.step({
kind: 'tool_result',
content: { toolCallId: 'c1', output: { flights: [{ airline: 'JAL', price: 982 }] } },
})
t.step({ kind: 'final_response', content: { text: 'I recommend JAL for $982.' } })
await t.flush() // POSTs the canonical trajectory to /api/ingest/trajectoriesEvery step kind matches the canonical schema we capture from the proxy — see src/lib/trajectories/schema.ts for the full discriminated union. Step bodies are unconstrained jsonb so you can stash provider-specific metadata too.
Ingest (raw)
POST /api/ingest/trajectories — what the SDK calls under the hood. If you want to skip the SDK entirely, just POST your trajectory in one of three accepted formats:
X-LabelHub-Format: canonical— our internal schema (the SDK's output)X-LabelHub-Format: anthropic— an Anthropic Messages API exchangeX-LabelHub-Format: openai-assistants— an OpenAI Assistants APIrun_stepslist
Plus optional headers: X-LabelHub-Agent-Name, X-LabelHub-Source (one of production / eval-run / synthetic / upload).
Export
GET /api/export/trajectories?workspaceId=…— JSONL bulk dump of every trajectory + steps in a workspace, scoped to the API key's workspace. One trajectory per line, full canonical schema. Useful for re-importing into your own training pipeline or BigQuery.
GET /api/export/dataset?versionId=…&format=teaching — the teaching-signal export. Only items where an AI proposal existed, reshaped to { prompt, ai_proposal, human_correction, delta_summary, template_mode, source }. Drop straight into trl/transformers DPOTrainer or SFTTrainer with a one-line key remap — no transform step. Use format=raw (default) to get the full verbatim manifest instead.
Annotations
Pull the actual labeling output back into your pipeline. The response carries the canonical Mark shape ({ scale, value, reason? }) — same one stored on disk, no lossy translation.
# Recent annotations across the workspace curl -sS 'https://aipert.top/api/annotations?limit=10' \ -H 'Authorization: Bearer lh_ws_YOUR_KEY' # Filter to one trajectory curl -sS 'https://aipert.top/api/annotations?trajectory_id=<uuid>' \ -H 'Authorization: Bearer lh_ws_YOUR_KEY' # Only fully-reviewed ones since a checkpoint curl -sS 'https://aipert.top/api/annotations?status=approved&since=2026-05-01T00:00:00Z' \ -H 'Authorization: Bearer lh_ws_YOUR_KEY'
Query params: trajectory_id · status (drafting | submitted | approved | rejected | revising) · since · until · limit (≤200) · offset.
{
"annotations": [
{
"id": "...",
"trajectoryId": "...",
"userId": "...",
"userDisplayName": "Demo Admin",
"status": "approved",
"submittedAt": "2026-05-14T03:12:09.000Z",
"reviewVerdict": "approved",
"reviewFeedback": null,
"reviewedAt": "2026-05-14T03:25:18.000Z",
"trajectoryMarks": {
"goal_achieved": { "scale": "likert", "value": 5, "reason": "..." }
},
"stepMarks": {
"<stepId>": {
"step_quality": { "scale": "likert", "value": 5, "reason": "..." },
"safety": { "scale": "bool", "value": true }
}
}
}
],
"total": 87, "limit": 10, "offset": 0, "hasMore": true
}GET /api/annotations/<id> returns a single annotation in the same shape (wrapped in { annotation }). 404 for not-found OR wrong-workspace — we don't distinguish so tenant existence doesn't leak.
Quality summary
Workspace-wide quality roll-up — one call, everything an external dashboard needs.
curl -sS 'https://aipert.top/api/quality/summary' \ -H 'Authorization: Bearer lh_ws_YOUR_KEY'
{
"workspaceId": "...",
"asOf": "2026-05-14T10:23:45.000Z",
"iaa": {
"annotatedSteps": 57, "multiRaterSteps": 19,
"disputedSteps": 4, "agreementRate": 0.7895
},
"raterCount": 3,
"raters": [
{
"userId": "...", "displayName": "Demo Reviewer",
"trust": { "source": "admin", "score": 0.7857, "positives": 4, "negatives": 1 },
"calibration": { "matched": 5, "diverged": 2, "score": 0.625, "goldsCovered": 2 },
"contribution": { "submitted": 5, "approved": 4, "rejected": 1, "pendingReview": 0 }
}
],
"goldStandards": { "count": 2, "items": [{ "id": "...", "trajectoryId": "...", "rubricCount": 4, ... }] },
"criticalViolations": { "count": 2, "recent": [{ "trajectoryId": "...", "rubricName": "Safety", ... }] }
}Webhooks
Subscribe a URL to receive POSTs when annotations land. Each delivery is HMAC-signed with your subscription secret. Failures back off (10 strikes → auto-disable).
# Register a hook
curl -sS -X POST https://aipert.top/api/webhooks \
-H 'Authorization: Bearer lh_ws_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your.app/incoming/labelhub",
"events": ["annotation.approved", "annotation.rejected"]
}'
# → { "webhook": { "id": "...", "secret": "<save this>", ... } }
# List your hooks
curl -sS https://aipert.top/api/webhooks \
-H 'Authorization: Bearer lh_ws_YOUR_KEY'
# Revoke
curl -sS -X DELETE https://aipert.top/api/webhooks/<id> \
-H 'Authorization: Bearer lh_ws_YOUR_KEY'Each delivery includes these headers:
POST /incoming/labelhub HTTP/1.1
x-labelhub-event: annotation.approved
x-labelhub-signature: 5f3a... (hex hmac-sha256 of body, using your secret)
user-agent: LabelHub-Webhook/1.0
content-type: application/json
{ "type": "annotation.approved", "workspaceId": "...", "deliveredAt": "...",
"payload": { "annotationId": "...", "submitterUserId": "...", "feedback": null } }Verify on your side:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(body: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Currently emitted from the reviewAnnotation path (approved/rejected/revised). More event types and an in-app delivery log are next on the roadmap.