LabelHub · docs
§ DOCS · v0

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.

§ 01

Quickstart

Get a workspace API key from /workspaces/<id>/api (or run npm run bootstrap locally). Then:

bash
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:

The captured trajectory is browsable at /workspaces/<id>/trajectories within seconds.

§ 02

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.

kindfamilyendpointauth headerenv fallback
doubaoopenai-compat/api/proxy/doubao/chat/completionsAuthorization: BearerDOUBAO_API_KEY
anthropicanthropic/api/proxy/anthropic/v1/messagesx-api-keyANTHROPIC_API_KEY
deepseekopenai-compat/api/proxy/deepseek/chat/completionsAuthorization: BearerDEEPSEEK_API_KEY
qwenopenai-compat/api/proxy/qwen/chat/completionsAuthorization: BearerQWEN_API_KEY
moonshotopenai-compat/api/proxy/moonshot/chat/completionsAuthorization: BearerMOONSHOT_API_KEY
openaiopenai-compat/api/proxy/openai/chat/completionsAuthorization: BearerOPENAI_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).

§ 03

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.

bash
# 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.

§ 04

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.

typescript
// 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/trajectories

Every 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.

§ 05

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:

Plus optional headers: X-LabelHub-Agent-Name, X-LabelHub-Source (one of production / eval-run / synthetic / upload).

§ 06

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.

§ 07

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.

bash
# 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.

json
{
  "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.

§ 08

Quality summary

Workspace-wide quality roll-up — one call, everything an external dashboard needs.

bash
curl -sS 'https://aipert.top/api/quality/summary' \
  -H 'Authorization: Bearer lh_ws_YOUR_KEY'
json
{
  "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", ... }] }
}
§ 09

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).

bash
# 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:

http
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:

ts
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.