DocumentationBrowse
Recipes
Four patterns that come up in every integration. Each one is complete enough to paste and adapt, and each one handles the failure the naive version misses.
1. Stream to the user, still enforce the gate
The hard part: the text arrives before the verdict does.
Streaming gives you a fast first token and takes away the automatic repair. The workable compromise is to stream into a provisional state and only promote the answer once the final frame confirms it. The user sees text immediately; nobody copies an unverified citation into a brief.
- The first frame carries
sources,injectionanddegraded, so the banner can be drawn before any text. - The last frame carries
verification. That is the promotion signal. - A fabrication also arrives as an appended content delta, so a raw renderer shows it too.
type Phase = "provisional" | "verified" | "held";
export async function streamAnswer(
question: string,
ui: {
banner(text: string): void;
append(delta: string): void;
setPhase(p: Phase, reasons?: string[]): void;
},
) {
const res = await fetch(`${BASE}/chat/completions`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
model: "deepseek/deepseek-v4-flash",
stream: true,
max_tokens: 1200,
messages: [{ role: "user", content: question }],
}),
});
const requestId = res.headers.get("x-docketrouter-request-id");
if (!res.ok) throw new Error(`${res.status} ${requestId}`);
ui.setPhase("provisional"); // nothing is confirmed until the last frame
const reader = res.body!.getReader();
const dec = new TextDecoder();
let buf = "", meta: any = null, verification: any = null, failed = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop() ?? "";
for (const f of frames) {
if (!f.startsWith("data: ")) continue;
const payload = f.slice(6);
if (payload === "[DONE]") continue;
const c = JSON.parse(payload);
// First frame: draw the caveats before a single token lands.
if (c.docketrouter?.sources !== undefined) {
meta = c.docketrouter;
if (meta.degraded) ui.banner(meta.degraded.reason);
if (meta.injection?.verdict === "hostile") {
ui.banner(`Embedded instructions found in ${meta.injection.findings.map((f: any) => f.document).join(", ")}`);
}
}
// Last frame: the verdict.
if (c.docketrouter?.verification) verification = c.docketrouter.verification;
const choice = c.choices?.[0];
if (choice?.delta?.content) ui.append(choice.delta.content);
if (choice?.finish_reason === "error") failed = true;
}
}
if (failed) throw new Error(`upstream failed mid-stream: ${requestId}`);
if (verification?.fabricated.length) {
// Streaming never repairs. Hold the answer and offer a non-streaming re-run,
// which does run the revise pass.
ui.setPhase("held", verification.fabricated.map((c: string) => `fabricated citation: ${c}`));
return { requestId, repairable: true };
}
ui.setPhase("verified", verification?.unverified ?? []);
return { requestId, repairable: false };
}When you hold a streamed answer, offer the user a "fix this" action that re-sends the identical request without stream. That path runs the revise pass, which rewrites the answer without the fabricated citations and verifies it again. It costs one extra model call and it is the difference between reporting a problem and solving it.
2. Batch process a set of case files
Rate limits, injection policy, and knowing which documents were actually read.
A discovery run is the case where nobody is watching the output, so the settings change: block instead of flag, no streaming, bounded concurrency, and a hard check that file grounding actually happened.
| Decision | Batch setting | Why |
|---|---|---|
injection_policy | "block" | No human is reading before the result is acted on, so a hostile document should stop the item rather than be analysed into it. |
stream | false | Batch work gains nothing from streaming and loses the automatic repair of a fabricated citation. |
| Concurrency | 8 or fewer | The sustained key limit is 120 per minute. A grounded request takes seconds, so 8 in flight sits comfortably under it. |
injection.blocks | Assert it is greater than 0 | Nothing tells you a file exists but did not match. A zero here means the answer is not file-grounded, whatever it says. |
type Item = { id: string; question: string };
type Outcome =
| { id: string; ok: true; text: string; requestId: string; costUsd: number; blocks: number }
| { id: string; ok: false; reason: string; requestId: string | null };
async function one(item: Item): Promise<Outcome> {
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${BASE}/chat/completions`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
model: "deepseek/deepseek-v4-flash",
max_tokens: 1500,
messages: [{ role: "user", content: item.question }],
docketrouter: { case_file: true, injection_policy: "block" },
}),
});
const requestId = res.headers.get("x-docketrouter-request-id");
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 1) * 1000));
continue;
}
if (res.status === 502) { await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); continue; }
if (res.status === 422) {
// A hostile document. The message names it, so quarantine it and move on.
const err = await res.json();
return { id: item.id, ok: false, reason: err.error.message, requestId };
}
if (!res.ok) return { id: item.id, ok: false, reason: `${res.status} ${await res.text()}`, requestId };
const d = await res.json();
const dr = d.docketrouter;
if (!dr.injection?.blocks) {
return { id: item.id, ok: false, reason: "no case-file chunk matched; answer is not file-grounded", requestId };
}
if (dr.verification?.fabricated.length) {
return { id: item.id, ok: false, reason: `fabricated: ${dr.verification.fabricated.join(", ")}`, requestId };
}
return {
id: item.id, ok: true,
text: d.choices[0].message.content,
requestId: dr.request_id,
costUsd: d.usage.cost,
blocks: dr.injection.blocks,
};
}
return { id: item.id, ok: false, reason: "exhausted retries", requestId: null };
}
/** Fixed-size worker pool. Simple, bounded, and it degrades gracefully under 429. */
export async function runBatch(items: Item[], concurrency = 8) {
const queue = [...items];
const out: Outcome[] = [];
await Promise.all(
Array.from({ length: concurrency }, async () => {
for (let item = queue.shift(); item; item = queue.shift()) {
out.push(await one(item));
}
}),
);
return out;
}Every outcome carries a request id, including the failures, so a batch is fully reconstructable afterwards from the usage log without re-running anything.
3. Compare raw against grounded
The evaluation you should run before you commit to either.
Send the identical prompt twice, once with juice: false and once with defaults, and compare the citations. The raw arm gets no retrieved authority and no verification report, so run its answer through the verifier by asking the same question again in verified mode, or simply compare what each arm cited and how much it cost.
Q='Summarize the pleading standard in Ashcroft v. Iqbal, 556 U.S. 662 (2009).'
# raw: the model alone, no retrieval, no citation check
curl -s https://docketrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer dr-…" -H "content-type: application/json" \
-d "{\"model\":\"deepseek/deepseek-v4-flash\",\"max_tokens\":1200,
\"messages\":[{\"role\":\"user\",\"content\":\"$Q\"}],
\"docketrouter\":{\"juice\":false}}" \
| jq '{cost: .usage.cost, prompt_tokens: .usage.prompt_tokens,
sources: .docketrouter.sources, verification: .docketrouter.verification}'
# { "cost": …, "prompt_tokens": 34, "sources": null, "verification": null }
# grounded: retrieval in, verification out
curl -s https://docketrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer dr-…" -H "content-type: application/json" \
-d "{\"model\":\"deepseek/deepseek-v4-flash\",\"max_tokens\":1200,
\"messages\":[{\"role\":\"user\",\"content\":\"$Q\"}]}" \
| jq '{cost: .usage.cost, prompt_tokens: .usage.prompt_tokens,
rules: (.docketrouter.sources.rules | length),
cases: (.docketrouter.sources.cases | length),
checked: [.docketrouter.verification.checked[].input],
fabricated: .docketrouter.verification.fabricated}'
# { "cost": …, "prompt_tokens": 2921, "rules": 6, "cases": 3,
# "checked": ["556 U.S. 662", "550 U.S. 544"], "fabricated": [] }The grounded arm typically sends around 2,900 prompt tokens for a one-sentence question against roughly 30 for the raw arm. That is the whole cost of grounding, and it lands on input tokens, which price far below output tokens. Comparing total cost per answer is the fair comparison, and it is usually much closer than the token counts suggest.
4. Export usage to CSV for client billing
Tag with session_id, reconcile, export, group.
session_id is echoed back on the response and is the natural place to put a matter number. It is not a filter on GET /usage, so the pattern is: keep your own map of request_id to matter as you make calls, then join it against the exported rows. Everything you need for the join is in both places.
The three steps
| Step | Call | Why |
|---|---|---|
| 1. Tag | Send docketrouter.session_id: "matter-4471" and record the returned request_id. | The request id is the join key that appears in the export. |
| 2. Reconcile | POST /usage/sync?limit=200 | Replaces the estimated cost with the provider's per-generation truth. Run it before you invoice, not after. |
| 3. Export | GET /usage?format=csv&from=…&to=… | Flat file, stable columns, no prompt or answer content in it. |
# 2. reconcile everything unsynced, repeatedly until remaining is 0
curl -s -X POST "https://docketrouter.ai/api/v1/usage/sync?limit=200" -H "Authorization: Bearer dr-…"
# {"synced":83,"unresolved":0,"remaining":0}
# 3. export the period
curl -s "https://docketrouter.ai/api/v1/usage?format=csv&from=2026-08-01&to=2026-09-01" \
-H "Authorization: Bearer dr-…" -o august.csvimport csv, io, json, requests
from collections import defaultdict
# Your own map, written as you make each call.
# matter_by_request["req_…"] = "matter-4471"
matter_by_request: dict[str, str] = json.load(open("matters.json"))
# 1. reconcile until nothing is left unsynced
while True:
s = requests.post(f"{BASE}/usage/sync", headers=H, params={"limit": 200}, timeout=300).json()
if s["remaining"] == 0 and s["synced"] == 0:
break
# 2. export the period
csv_text = requests.get(f"{BASE}/usage", headers=H, timeout=300,
params={"format": "csv", "from": "2026-08-01", "to": "2026-09-01"}).text
# 3. group by matter
totals = defaultdict(lambda: {"requests": 0, "cost_usd": 0.0, "tokens": 0, "grounded": 0})
unattributed = 0
for row in csv.DictReader(io.StringIO(csv_text)):
matter = matter_by_request.get(row["request_id"])
if matter is None:
unattributed += 1
continue
t = totals[matter]
t["requests"] += 1
t["cost_usd"] += float(row["cost_usd"] or 0)
t["tokens"] += int(row["prompt_tokens"] or 0) + int(row["completion_tokens"] or 0)
t["grounded"] += int(row["juiced"] or 0)
for matter, t in sorted(totals.items()):
print(f"{matter:16s} {t['requests']:5d} requests "
f"${t['cost_usd']:.4f} {t['tokens']:8d} tokens {t['grounded']} grounded")
print("unattributed rows:", unattributed) # investigate rather than absorb- Bill from
cost_usd, not from token counts times a rate card. It already carries the markup and it already matches what you were charged. upstream_cost_usdis in the export beside it, so a client who asks what the markup is can be shown rather than told.- The CSV deliberately excludes
verificationand any logged content, which makes it safe to hand to a finance system. Pull quality metrics from the JSON API instead. - The export caps at 5,000 rows and ignores
limitandoffset. Narrow the date range rather than paging it.
Something here wrong or missing? Mail hello@docketrouter.ai with the request_id and we will fix the docs or the API, whichever is broken.