The API

Tenali runs on your machine first. This API is the same cards, queued on tenali.dev instead, for when your agent runs somewhere your laptop isn't, or the reviewer isn't you. Pass, fail, or defer against a versioned rubric, and nothing else.

Tenali runs on your machine first; that's the product. This API is the same cards, queued on tenali.dev instead, for when your agent runs somewhere your laptop isn't, or the reviewer isn't you. Pass, fail, or defer against a versioned rubric, and nothing else.

Tenali runs on your machine first, and the local tool is the whole product. This API exists for the day your agent runs on a server and the reviewer is someone other than you. Same cards, same three keys, same refusal to accept anything vaguer than pass, fail, or defer.

# start here, in any project
tenali setup          # the store, the MCP server, and the coach skill for your agent
tenali ui             # 127.0.0.1:7847, keyboard only
tenali stats          # the hallmark for each criterion
Who sees a batch posted here: by default, everyone on tenali.dev who has qualified for that capability — so post cards you'd be comfortable showing a qualified reviewer, and pack only the range they need to decide. Send "audience": {"mode": "me"} and it goes to nobody but you, signed in. Team and guest audiences aren't built yet (what's built, and what isn't).

1. Authenticate

Every call sends your API key. Name your client too: it shows up in our analytics as how you use the service, and helps when something breaks.

Authorization: Bearer gpk_…
Tenali-Client: my-agent/0.4.1

Your wallet starts with a proof-of-concept grant of 250,000 GOLD. GET /api/v1/wallet shows the balance and what's in escrow.

2. Post a batch

The reward is per seat. Posting moves gold × seats into the batch's escrow wallet. Send the same Idempotency-Key again and you get the same batch back, never a duplicate.

curl -X POST https://tenali.dev/api/v1/batches \
  -H "Authorization: Bearer $TENALI_KEY" -H "Tenali-Client: my-agent/0.4.1" \
  -H "Idempotency-Key: run-2291-batch-1" -H "Content-Type: application/json" -d '{
  "capability": "transcript",
  "title": "Support calls, week 37",
  "gold": 400, "median_seconds": 360, "seats": 2, "closes_in_hours": 48,
  "external_ref": "run-2291", "metadata": {"model": "asr-v7"},
  "tasks": [{
    "case_key": "call-0192", "instructions": "Does the transcript say what the caller says?",
    "model_verdict": "pass", "confidence": 0.71, "stratum": "gray",
    "metadata": {"row": 192},
    "artifacts": [
      {"kind": "audio", "label": "Call audio", "data_base64": "SUQzBAAAAA…"},
      {"kind": "text", "label": "Transcript", "text": "I'd like to change my billing address."}
    ]
  }]
}'
201 Created
{"batch_id": 41, "replayed": false, "status": "open",
 "escrow": {"address": "gp1…", "balance": 800, "committed": 0},
 "wallet_balance": 249200, "url": "/api/v1/batches/41"}

3. Get notified

Pick either. A webhook gets a signed POST for every event, retried with backoff for about seven hours. The events feed works from anywhere, including a laptop behind a firewall: long-poll it with wait.

curl -X PUT https://tenali.dev/api/v1/webhook -H "Authorization: Bearer $TENALI_KEY" \
  -H "Content-Type: application/json" -d '{"url": "https://example.com/goldpan"}'
# → {"webhook": {"url": "…", "secret": "whsec_…"}}   the secret is shown once

curl "https://tenali.dev/api/v1/events?after=0&wait=25" -H "Authorization: Bearer $TENALI_KEY"
# → {"events": [{"id": 88, "type": "batch.completed", "batch_id": 41, "data": {…}}], "next_after": 88}

Check the signature before trusting a webhook:

import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))      # Tenali-Signature: t=…,v1=…
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    mac = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, parts["v1"])
EventWhen
batch.seat_acceptedA reviewer took a seat.
batch.seat_releasedA reviewer let a seat go, or their 24-hour hold ran out. The seat is back on the board.
batch.seat_finishedA seat decided every card.
batch.completedEvery seat finished. Carries the full results and any refund.
batch.cancelledYou cancelled it. Unused escrow is refunded; seats already working can finish.
batch.expiredIt closed before every seat finished. Unused escrow is refunded.

4. Read the results

GET /api/v1/batches/41 returns every seat's verdicts with rationale and timing, the consensus per card (pass, fail, split, or null), and each reviewer as a wallet address with a reputation tier. Verdicts from an account removed for abuse come back with "excluded": true and don't count toward consensus.

{"batch_id": 41, "status": "complete", "done": true, "external_ref": "run-2291",
 "seats": {"total": 2, "finished": 2, "live": 0},
 "cards": [{"position": 1, "case_key": "call-0192", "metadata": {"row": 192},
            "consensus": "fail", "agreement": true, "overturned_model": true,
            "verdicts": [{"seat": 1, "verdict": "fail", "rationale": "Says shipping, not billing",
                          "duration_ms": 21400, "reviewer": "gp1…", "excluded": false}, …]}]}

Endpoints

CallWhat it does
GET /api/v1/capabilitiesKinds of work, rubric versions, qualified reviewers, the token, limits, event types.
PUT /api/v1/criteria/{key}Define your own question, or version it. Private to your workspace.
GET /api/v1/criteria · /{key}Your criteria: every rubric version, and what's been asked against each.
POST /api/v1/batchesPost and escrow a batch. Honors Idempotency-Key.
GET /api/v1/batchesYour batches; filter with status, external_ref; page with after.
GET /api/v1/batches/{id}Status, seats, escrow, and full results.
POST /api/v1/batches/{id}/cancelStop new seats and refund unused escrow.
GET /api/v1/eventsThe events feed: after, limit, wait (up to 25 s).
GET · PUT · DELETE /api/v1/webhookRead, set (rotates the secret), or remove your webhook.
GET /api/v1/walletYour address, balance, GOLD in escrow, recent transactions.

Your own questions

A capability is a kind of work reviewers qualified for: few, curated by us, public. A criterion is your own question: unlimited, private to your workspace, and never on the board. Writing down what right means for your app is the whole point, so a criterion is where you do it.

curl -X PUT https://tenali.dev/api/v1/criteria/receipt-total \
  -H "Authorization: Bearer $TENALI_KEY" -H "Content-Type: application/json" -d '{
  "title": "Receipt total matches",
  "question": "The extracted total equals the final total on the receipt, including tax."
}'
# → {"key": "receipt-total", "rubric_version": 1, "spec_hash": "80674949…", "reviewable": true}

curl -X POST https://tenali.dev/api/v1/batches -H "Authorization: Bearer $TENALI_KEY" \
  -H "Content-Type: application/json" -d '{
  "criterion": "receipt-total", "title": "Receipts, week 37",
  "audience": {"mode": "me"}, "tasks": [ … ]
}'

The same question again is the same version; a changed one is the next, and no version is ever edited, so a batch you already posted keeps the question it actually asked. A batch names a criterion that must already exist and carries no rubric of its own — a typo would otherwise quietly split the answers you're accumulating into two half-built sets.

A criterion batch is always "me": your own question is yours to answer. Reaching reviewers means a capability they qualified for.

Audiences

A batch takes an audience saying who can see and take it. Leave it out and nothing changes: the batch is open work, exactly as before audiences existed.

{"capability": "transcript", "title": "Receipts, week 37",
 "audience": {"mode": "me"},
 "tasks": [ … ]}

"me" is a batch only you review, signed in. It never appears on the board, in the demand meters, or to another reviewer, and it's kept out of everyone's reputation and accuracy — including yours, since scoring you against your own answers would mean nothing. Nobody is paid for it, so gold, median_seconds and a second seat are refused rather than ignored, and no GOLD moves. Your key needs an owner: the account that reviews its private batches, which we set when we mint the key.

Not built yet, and refused with AUDIENCE_NOT_BUILT so you can tell: "team" for colleagues in your workspace, "invited" for one guest with a scoped link, and "targeted", which waits on an honest answer about pay.

Limits

Capabilities open now

KeyWhat reviewers judgeQualified
transcriptRubric v1Transcript matches the audioReal audiobook clips beside a speech-recognition transcript0
same_readerRubric v1Same reader?Two short clips from real audiobooks: is one person reading both0
entity_linkRubric v1Right entity?A real Wikipedia sentence and a proposed link for a name in it0

GOLD is a token with no cash value. Rewards, escrow, and refunds move GOLD, never money. See docs/API.md in the repository for every field.