dopp API
dopp is a drop-in proxy for any Jev-compatible decision API (POST /v1/systemone: TypeSafe's Jev, Kev, GLiNER, or your own endpoint). Send the same request you send to Jev; get the same response shape back. Each key belongs to a route. The route's setup decides who answers: by default Clef-flash (Cloudflare's open-weights model that speaks Jev's API), or Jev when your app sends its own TypeSafe key, until the route's own model, trained on its requests, meets the route's goal and takes over (autopilot). Every request is recorded and can become training data.
Base URL: https://dopp.sh
Set up from an agent
One call creates an account with a working key; no sign-up form. The person attaches it to themselves later by opening the claim link.
POST https://dopp.sh/agent/start
{"name": "my-app", "code": "<invite code, optional>"}
→ {"key": "us_…", "endpoint": "https://dopp.sh/v1/systemone", "claim_url": "https://dopp.sh/claim/…", "spendable_usd": 1, "expires_at": "…"}
Until it is claimed the account is a guest. A guest gets 10 free answers paid by dopp (per account, and per IP per day across guest accounts); after that /v1/systemone answers 402 with a claim_url until the person opens it. Requests that carry a TypeSafe key (as the bearer or in x-jev-key) are billed by TypeSafe and never count. The claim link works for 7 days and keeps everything already recorded. GET /agent/status with the key reports requests seen, spend left and whether it is claimed. Agent instructions: https://dopp.sh/skill.md
Authentication
Every request carries a bearer token: a dopp key (us_…, from the route's page or POST /agent/start), or the TypeSafe key your app already sends. A TypeSafe key is forwarded to TypeSafe, which keeps billing you; dopp stores only its hash, and the first request with it makes your account (sign in at dopp.sh and paste the same key once to see it). Requests through a key are recorded on that key's route and follow that route's setup.
Authorization: Bearer us_...
You do not need a TypeSafe key: a new route answers with Clef-flash, metered from your credit. A route can answer with Jev instead (pick it on the route). Then calls to Jev go through dopp's account and are metered from your credit, unless you add your key in Settings (stored encrypted, used only upstream) or send it per request as x-jev-key: <your key> (forwarded, never stored), and TypeSafe bills you directly. Jev's answers are kept on every request for your records but are never used as labels: training a model only on Jev's answers may go against TypeSafe's terms.
Endpoint
POST /v1/systemone
Request body (identical to TypeSafe):
{
"model": "jev-latest",
"state": "Board arrived with a cracked deck. I want a replacement, not a refund.",
"questions": {
"issue": { "type": "choice", "instructions": "What is the main issue?",
"criteria": { "damaged": "arrived broken", "wrong item": null, "late": null, "billing": null, "question": null } },
"wants": { "type": "choice", "instructions": "What does the customer want?",
"criteria": { "replacement": null, "refund": null, "repair": null, "information": null } },
"angry": { "type": "noul", "instructions": "Is the customer angry?" },
"urgency":{ "type": "score", "instructions": "How urgent is this?", "criteria": ["can wait", "this week", "today"] }
}
}
state: a string, object or array. Anything JSON.questions: an object of question id → question. Three types:
- choice: pick one of criteria (option name → description or null). - noul: yes/no. Returns a probability of yes. - score: an ordered scale; criteria is the list of levels, lowest first.
Response:
{
"model": "jev-1.13.0",
"answers": {
"issue": { "type": "choice", "choice": "damaged", "probabilities": { "damaged": 0.97, "wrong item": 0.01, "late": 0.01, "billing": 0.0, "question": 0.01 } },
"wants": { "type": "choice", "choice": "replacement", "probabilities": { "replacement": 0.95, "refund": 0.03, "repair": 0.02, "information": 0.0 } },
"angry": { "type": "noul", "noul": 0.81 },
"urgency": { "type": "score", "score": 1.4, "probabilities": { "0": 0.1, "1": 0.4, "2": 0.5 } }
},
"usage": { "input_tokens": 118, "output_tokens": 0 }
}
dopp adds one field to the response and sets model to whoever answered:
{
"model": "jev-1.13.0",
"understudy": {
"served": "jev",
"path": { "steps": [
{ "ask": "model", "ok": false, "reason": "skipped", "why": "there's no trained version yet" },
{ "ask": "jev", "ok": true, "ms": 218 }
] },
"fallback": { "from": "model", "reason": "there's no trained version yet" },
"recorded": true
}
}
model: who answered.jev-…for Jev,understudy/<route>-v<N>for the route's own model, or the upstream's id.understudy.served: who answered (jev,model, or an upstream id such ascf:clef-flash).path.steps: who was asked, in order, and why each one passed (skipped,unsurewith its confidence,slow,error).fallback: set when the first step didn't answer.recorded: whether the request was kept.
Choosing a target per request
Send x-understudy-target: jev (or model, or endpoint:<id>) to pick who answers one request. It is honoured only while the route's setup allows it (off by default); otherwise it is ignored.
Errors: 401 with {"error": "..."} for a missing, invalid or revoked key. Upstream errors from Jev are passed through with their status code.
Examples
curl:
curl -s https://dopp.sh/v1/systemone \
-H "Authorization: Bearer $DOPP_KEY" \
-H "content-type: application/json" \
-d '{"state":"Trucks are loose, can you send new ones?","questions":{"wants":{"type":"choice","instructions":"What does the customer want?","criteria":{"replacement":null,"refund":null,"repair":null,"information":null}}}}'
Python:
import os, requests
def decide(state, questions):
r = requests.post("https://dopp.sh/v1/systemone",
headers={"Authorization": "Bearer " + os.environ["DOPP_KEY"]},
json={"model": "jev-latest", "state": state, "questions": questions}, timeout=30)
r.raise_for_status()
return r.json()["answers"]
Node:
const answers = await fetch("https://dopp.sh/v1/systemone", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DOPP_KEY}`, "content-type": "application/json" },
body: JSON.stringify({ model: "jev-latest", state, questions }),
}).then(r => r.json()).then(r => r.answers);
TypeSafe's own SDK works unchanged: set its base URL to https://dopp.sh and keep your TypeSafe key, or use a dopp key.
Routes, requests, labels, autopilot
These are set on the dashboard (https://dopp.sh/routes). Autopilot does the routine parts by itself.
- Route: one per product or use case. It has keys, requests, a setup (who answers, who labels, what trains) and models named "<route> v<N>". The first request with a key dopp hasn't seen makes a route called Default.
- Requests: every request through a key is kept with every answer it got. You can add more: paste a file (text, CSV, JSON or JSONL, with or without labels), pull rows from a public Hugging Face dataset (with its own label column when it has one), or generate them.
- Labels: what a model learns from and is checked against. New routes label with Clef-flash (open weights); when Clef-flash isn't the one answering, it labels in the background. You can label by hand instead (fix or confirm answers on Requests), or pick an LLM or any upstream. Your own fixes always win.
- Held-out: one in ten requests you add (not traffic) is kept aside and never trained on, to measure each version.
- Autopilot (on for new routes): the route's upstream (Clef-flash, or Jev) answers until a version of the route's own model meets the goal. By default that's at most 2% of all requests answered wrong by it, measured on checked requests that came in after it was trained. Then the model answers what it's sure of and the upstream the rest. The upstream also answers while the model is waking up, after at most 2 s. The labeller keeps checking a share of requests (2% by default). If the model slips, the upstream answers everything again until a newer version meets the goal. The first version trains by itself once 100 requests have a label. Each run is charged to the account's credit, and the route's feed says what happened.
- Models: you can also train any base by hand (Models → Train), with the price shown before it starts. Each version is measured on the held-out requests and, separately, on live requests. Tiny (34 MB) runs downloaded, offline or in a browser. The other bases are hosted behind the route's URL.
Your model's files
GET /v1/models with the route's dopp key lists its ready versions, newest first, and where each one's files are, so your app can run the model itself:
GET https://dopp.sh/v1/models
Authorization: Bearer us_...
→ {"object": "list", "route": "home", "data": [{"id": "home v1", "object": "model", "version": 1, "base": "tiny", "holdout": {"agreement": 0.985, "answers": 204}, "files": "https://dopp.sh/api/p/…/web/v1/…/", "offline_zip": "https://dopp.sh/…"}]}
Keep questions and options stable. Changing an option's name makes a new question-set with no examples of its own.
For agents
If you are an AI agent integrating dopp on someone's behalf:
- Replace the TypeSafe base URL with the one above. Keep the TypeSafe key, or use a dopp key. Nothing else changes.
- Never log the key. Treat it like an API secret.
- Read
modelin each response to know who answered. Don't addx-understudy-targetunless the user asks for it. - Do not rename options once a route's model is trained on them; a changed question-set has no examples yet.
- Keys:
POST /agent/start. A trained model's files:GET /v1/models. Training and setup changes are on the dashboard; autopilot trains by itself. - If the task needs something dopp doesn't do, say so with
POST /feedback(below) instead of quietly working around it.
Feature requests and problems
POST /feedback takes a feature request, a wall you hit, or a bug, from a person or an agent. No account needed. With a bearer (your dopp key, or the key your app sends) or a signed-in session it is tied to that account; the bearer is only looked up, never stored.
POST https://dopp.sh/feedback
content-type: application/json
{"text": "What I needed, what I tried, what happened", "kind": "feature", "agent": "Claude Code", "page": "/v1/systemone", "contact": "you@example.com"}
→ 201 {"ok": true, "id": "3f9c0a1b2c4d", "status_url": "https://dopp.sh/feedback/3f9c0a1b2c4d", "message": "…", "github": "https://github.com/doppsh/dopp/issues/new"}
text(required, 8 to 4,000 characters).kind:feature(the default),bug,questionorother.agent: who wrote it, when an agent did.page: where it happened.contact: an email or handle, for an answer.- A plain-text body works too:
curl -d "what I needed" https://dopp.sh/feedback. GET /feedback/<id>returnsstatus(new,seen,planned,done,wont) andansweronce there is one.- At most 5 a minute and 20 a day from one address; past that the reply is 429 and names the GitHub issues page instead.
- Don't put keys or your users' request data in
text. People can use the form at https://dopp.sh/feedback/
Unknown paths under /v1/ and /agent/ answer 404 with {"error": "...", "feedback": "..."}, never a web page.
Limits
- At most 120 requests a minute per key; past that the reply is 429 with how long to wait.
- State plus questions must fit in about 8,000 tokens.
- Choice questions with more than about 50 options learn poorly; keep routing those to Jev.