Consultant Council API
Send one question to two frontier models independently, then make each review the other's answer — and its own. What you get back is the disagreement.
What it's for
Two frontier models answering independently catch each other's factual errors. That is the entire value proposition; everything else is plumbing.
A single model returns a fluent, plausible, well-formatted answer whether or not it is correct. Nothing in the output distinguishes the two cases. You find out downstream, after you have built on it.
First position
Proposed calibrating a research system by rediscovering “the exact 3×3 matrix that disproved the Bessis–Moussa–Villani conjecture in 2013.”
Fluent. Specific. Dated. Wrong.
Correcting entry
BMV was proved true by Herbert Stahl. There is no counterexample to rediscover.
Caught on the next call. Cost of catching it: one extra round. Cost of missing it: an automated search hunting a counterexample that cannot exist, running until someone noticed.
Use this service when being wrong is expensive and a second independent opinion costs less than the mistake. For general-purpose generation, one model is cheaper and faster — call the vendor directly.
Quickstart
# Consultations are asynchronous. POST returns a job id immediately.
export COUNCIL_URL=https://council.smartledger.app
export COUNCIL_TOKEN=<your token>
curl -s -X POST "$COUNCIL_URL/v1/consult" \
-H "Authorization: Bearer $COUNCIL_TOKEN" \
-H 'content-type: application/json' \
-d '{"prompt":"Is the Collatz conjecture proved? One sentence."}'
# → {"job_id":"04961d7b-…","state":"queued",
# "poll":"/v1/jobs/04961d7b-…","stream":"/v1/jobs/04961d7b-…/stream"}
curl -s "$COUNCIL_URL/v1/jobs/04961d7b-…" -H "Authorization: Bearer $COUNCIL_TOKEN"
A high-effort consultation runs 2–8 minutes, far longer than any reverse proxy holds a connection. Poll the job, or subscribe over WebSocket and have the result pushed to you.
Authentication
Every /v1/* route requires a bearer token. x-council-token is
accepted as an alternative header. /health is deliberately open so monitoring
needs no credential.
Authorization: Bearer $COUNCIL_TOKEN
WebSocket handshakes also accept ?token=, because browsers cannot set headers
on an upgrade. Over wss:// that query string sits inside the TLS session, but it
can still reach reverse-proxy access logs — use the header from server-side
clients and treat the query parameter as the browser affordance it is.
No token is configured and anyone who can reach the URL can spend our OpenAI and Google
credits. That is an incident, not a warning. Set COUNCIL_TOKEN and redeploy.
Projects & isolation
One deployment serves many projects. Each token maps to a project, and every read, delete, and subscription is scoped to it.
Consultations were never at risk of colliding — they are UUID-keyed, one file each, written atomically. But a single shared token meant no isolation: any caller could list, read, and delete any other caller's records, the firehose broadcast all of them, and a busy project could evict a quiet one under global retention. Tokens now carry identity.
Configuration
# preferred: JSON map of project -> token
COUNCIL_PROJECTS='{"hdlab":"tok_a…","acme":"tok_b…"}'
# compact equivalent
COUNCIL_PROJECTS='hdlab:tok_a…,acme:tok_b…'
# sees every project — for operators, not for applications
COUNCIL_ADMIN_TOKEN='tok_admin…'
# legacy single token still works; its records land in project "default"
COUNCIL_TOKEN='tok…'
What isolation guarantees
| Action | Own project | Another project | Admin |
|---|---|---|---|
GET /v1/jobs/:id | 200 | 404 | 200 |
DELETE /v1/jobs/:id | 200 | 404 | 200 |
GET /v1/jobs | own only | never listed | all projects |
WS /v1/stream | own events | never emitted | all events |
WS /v1/jobs/:id/stream | subscribes | refused | subscribes |
Another project's job returns 404, not 403. A 403 would confirm the id exists, which is itself a leak across the boundary.
Fair share
Each project has its own concurrency budget, so one caller firing fifty consultations
cannot occupy every upstream slot or push the shared vendor rate limits into 429
for everyone else.
MAX_CONCURRENT_PER_PROJECT— default 4MAX_CONCURRENT_TOTAL— default 12MAX_JOBS_PER_PROJECT— retention budget per project, default 5000
Retention is applied per project. A global cap would let a busy project silently evict a quiet one's records, which for a service whose product is the record is data loss, not a capacity policy.
Upgrading an existing deployment
Jobs written before projects existed carry no stamp and are treated as belonging to
default. Keep COUNCIL_TOKEN and nothing changes; add
COUNCIL_PROJECTS and new work is scoped while the old records stay reachable
through the legacy token.
An admin token reads every project's prompts and results. Do not hand one to an application, and do not use it as a convenience token during integration.
Choosing a mode
The one decision that matters per request. Pick by how expensive it would be to be wrong.
| Mode | Upstream calls | Typical | Use when |
|---|---|---|---|
fanout | 2 | 1–4 min | You want two independent answers and will read both. The default. |
synthesis | 3 | 2–6 min | You want one answer, with conflicts ruled on and anything believed false flagged. |
debate | 4 | 4–10 min | The answer matters and will be built on. Each model audits the other — and itself. |
verify | 2 | 1–5 min | You have a LIST of candidate claims and need the false positives found. |
What debate returns
Each model is shown both answers, labelled by vendor, and asked to adjudicate in five parts:
- A · self-correction
- Where its own prior answer was wrong or overconfident.
- B · cross-examination
- Where the other answer is wrong, with corrections.
- C · genuine disagreements
- Points where they actually differ, with a ruling on each.
- D · merged recommendation
- One answer, not two.
- E · what both missed
- The highest-value point absent from either answer.
Section A is what earns the extra calls: models retract and revise when shown an alternative, and the revised confidence is better calibrated than the first pass. Section E reliably surfaces what neither volunteered unprompted.
In debate, result.round2[] holds the critiques and aligns with
result.round1[] by vendor. The corrections are the point; round 1
is the raw material.
Verify mode
The other three modes generate judgement about one question. Triaging a findings list is a different shape: N independent claims, most of them false positives, where the useful output is the set the two models disagree about.
{
"mode": "verify",
"claims": [
{"id":"f1", "claim":"BMV was disproved in 2013 by a 3x3 counterexample."},
{"id":"t1", "claim":"BMV was proved true by Herbert Stahl.",
"evidence":"optional supporting text"}
],
"prompt": "optional shared context for all claims"
}
Both models see the same list and are told to refute, defaulting to "refuted" when they cannot substantiate a claim. For triage that asymmetry is right: a false negative costs a second look, a false positive costs someone acting on nonsense.
{
"primary": "verdicts",
"summary": { "total":6, "upheld":2, "refuted":4, "disputed":0 },
"upheld": ["t1","t2"], "refuted": ["f1","f2","f3","c1"], "disputed": [],
"verdicts": [
{ "id":"f1", "claim":"…", "agreed":true, "consensus":"refuted",
"verdicts":[{"vendor":"openai","verdict":"refuted","confidence":0.95,"reason":"…"}] }
]
}
Claims both models agree on need no human. disputed is the short list that
does. Limit is 200 claims per job.
Start a consultation
{
"prompt": "required, non-empty string",
"system": "optional — matters more than you think, see Writing the prompt",
"mode": "fanout", // fanout | synthesis | debate
"vendors": ["openai", "gemini"], // default: both
"models": { "openai": "gpt-5.4" }, // per-vendor override
"effort": "high", // OpenAI reasoning: low | medium | high
"temperature": 0.9, // Gemini only
"maxTokens": 120000, // per call — reasoning counts against this
"synthesizer": "openai", // synthesis mode: who merges
"wait": false, // true = block for the result
"name": "the sharding debate", // human label, shown in listings
"tags": ["arch", "q3"], // filter with ?tag=
"store_prompt": true, // false = do not retain the prompt
"claims": ["…"], // verify mode only
"schema": { "…" }, // JSON Schema — see Structured output
"schema_name": "claim" // [A-Za-z0-9_-]{1,64}
}
Send an Idempotency-Key header. A repeat of the same key returns the original
job with idempotent_replay: true instead of spending a second set of model calls
— which at four calls per debate is real money.
The 202 response echoes the effective parameters actually used, after defaulting, plus a pre-flight cost estimate:
{
"job_id": "04961d7b-…", "state": "queued",
"effective": {
"mode":"debate", "vendors":["openai","gemini"], "effort":"high",
"maxTokens":120000, "models":{"…"}, "system_provided":true },
"estimated_cost": { "usd_low":0.06, "usd_high":0.51, "calls":4,
"rate_card":"2026-08-29" },
"poll": "/v1/jobs/…", "stream": "/v1/jobs/…/stream"
}
Without the echo you cannot tell from a stored record whether effort: high was
applied, ignored, or clamped — which makes a result non-reproducible, and reproducibility is
half of what an audit record is for.
| Status | Meaning |
|---|---|
| 202 | Accepted. Returns job_id, poll, stream. |
| 400 | Missing or empty prompt, unknown mode — the response lists valid modes — or a malformed schema. |
| 401 | Missing or wrong token. |
| 502 | wait:true only — an upstream call failed. |
A high-effort consultation will exceed the proxy timeout. You get a gateway error while the upstream calls keep running and keep costing money. If a request might take more than about thirty seconds, use the async path.
One vendor erroring does not fail the job — a one-model answer is still worth having. But
such a job reports state: "degraded", never done, so
state alone is trustworthy and a caller does not have to go
looking for trouble. done means everything succeeded.
Structured output
Pass a JSON Schema as schema and the answer is constrained at decode
time rather than requested in prose. This is not “please reply in JSON”:
the sampler will not produce a token sequence the schema forbids.
{
"mode": "fanout", "vendors": ["openai"],
"prompt": "Extract the hypotheses of this theorem.",
"schema_name": "claim",
"schema": {
"type": "object",
"properties": { "hypotheses": { "type": "array", "items": { "type": "string" } } },
"required": ["hypotheses"],
"additionalProperties": false
}
}
A required field in a schema is enforced. A required field in a prompt is a suggestion. Measuring an extraction pipeline against a 26-paper corpus, the largest single failure mode for cheap models was not a wrong answer but a missing field — the model did the hard part and dropped the bookkeeping. The prompt had asked for it in capital letters. The schema made the omission unrepresentable.
What the schema must satisfy
| Rule | Why |
|---|---|
Every object sets additionalProperties: false |
Strict mode rejects open objects. |
Every property appears in required |
Optionality is a nullable type — "type": ["string","null"] — never an omission. |
Recursion uses $defs + $ref |
Unrolling a recursive shape by hand produced a 148 KB schema. $ref made it 4 KB. |
| 100 KB maximum | Rejected with 400 above that. |
A schema with no escape hatch converts a correct refusal into a fabrication.
If the input might be unanswerable — truncated, contradictory, out of scope — include a
nullable field such as "unrepresentable": {"type": ["string","null"]}, and test it
for truthiness, not presence. Under strict mode every field is always present,
so if ("unrepresentable" in obj) reads every successful response as a refusal.
Which calls it applies to
A schema describes an answer to your prompt, so it is applied only where the model is answering you — never to a leg the council writes in its own words.
| Mode | Schema applied to |
|---|---|
fanout | Every answer. |
synthesis | The fan-out answers. Not the merge — it writes a document ending in “Open questions”. |
debate | Round 1. Not round 2 — it writes a critique with sections A–E. |
verify | Not applied. The prompt is the council’s, not yours. |
Gemini’s dialect is a restricted OpenAPI subset with no $ref. Rather than
send it a schema it may silently reinterpret, the service asks it for JSON only. If you need the
guarantee, set "vendors": ["openai"]. If you fan out to both, validate Gemini’s
answer yourself — the job record’s structured field records that a schema was
applied, not that both vendors honoured it.
A model may still decline. That returns as an error on that vendor’s entry
reading model refused: …, rather than as text.
Read a consultation
{
"id": "04961d7b-…", "state": "done", "mode": "synthesis",
"created_at": "2026-08-29T15:19:36.538Z",
"started_at": "2026-08-29T15:19:36.545Z",
"finished_at": "2026-08-29T15:19:42.370Z",
"duration_ms": 5825, "error": null,
"prompt": "…the exact submitted prompt…",
"system": "…the system prompt, if one was given…",
"prompt_stored": true,
"name": "the sharding debate", "tags": ["arch"],
"effective": { /* the parameters actually used */ },
"usable_answers": 2, "failed_vendors": [],
"cost": { "usd":0.0015, "calls":2, "rate_card":"2026-08-29" },
"result": {
"answers": [
{ "vendor": "openai", "model": "gpt-5.4", "text": "…", "ms": 1962,
"tokens": { "input":61, "output":8, "reasoning":10, "total":79, "cached":0 },
"usage": { /* raw vendor block, unnormalised */ } },
{ "vendor": "gemini", "…": "…" }
],
"synthesis": { "vendor": "openai", "role": "synthesis", "text": "…" },
"tokens": { "input":112, "output":10, "reasoning":253, "total":375, "calls":2 }
}
}
Job states
| State | Meaning |
|---|---|
queued | Accepted, not started. |
running | Upstream calls in flight. |
done | Everything succeeded. result is populated. |
degraded | Completed, but at least one vendor failed. Usable output is present; see usable_answers and failed_vendors. |
error | error is populated, result is null. |
interrupted | The service restarted mid-flight. Re-submit — that work was lost. |
interrupted exists so a poller is never stranded on a job that died with the
process. Treat it as retryable.
Result shape by mode
| Mode | Fields on result |
|---|---|
fanout | answers[], tokens |
synthesis | answers[], synthesis, tokens |
debate | round1[], round2[], tokens |
verify | verdicts[], summary, upheld/refuted/disputed |
result.primary names the field holding the primary output, and
result.answers is populated for every mode — in
debate it mirrors round2. Client code that checked
answers.length once reported a fully successful debate as zero usable answers;
a consistent shape is cheaper for every client than documentation about the inconsistency.
Listing & status
Health is the first thing to check when something looks wrong:
{
"ok": true, "auth": "bearer", "websocket": true,
"storage": { "durable": true, "dir": "/data", "stored_jobs": 4 },
"boot": { "writer": true, "instance": "990d270d", "interrupted": 0 }
}
auth: "open"— no token configured. Incident.storage.writable: false— the volume is not mounted; running memory-only.storage.volume.persisted— see below. Writable is not persistent.boot.writer: false— another instance holds the writer lock, or a stale lock is present.
Usage & cost
Token spend per project, so cost is attributable rather than a single shared bill. Admin sees every project; a project sees only itself.
{
"project": "hdlab",
"usage": {
"hdlab": { "consultations":1, "calls":2, "input":113,
"output":12, "reasoning":270, "total":395, "ms":4571 }
}
}
Counts come from the normalised tokens block, so output means
visible output across both vendors and reasoning is separate. Reasoning
usually dominates.
Export
Every visible job as newline-delimited JSON, full records including prompt, result and cost. Retention is 30 days or 5,000 jobs per project — for anyone treating consultations as business documentation that is a deletion deadline, and polling every job by id to beat it is absurd.
curl "$COUNCIL_URL/v1/export?since=2026-08-01" \
-H "Authorization: Bearer $COUNCIL_TOKEN" > council-archive.ndjson
WebSocket
Sends a snapshot of current state immediately, pushes updates, then closes
when terminal. Subscribing after a job finished still returns a snapshot containing
the full result, so a late client is never stranded.
const ws = new WebSocket(
`wss://council.smartledger.app/v1/jobs/${id}/stream?token=${TOKEN}`
);
ws.onmessage = (e) => {
const { event, job, result, error } = JSON.parse(e.data);
if (event === 'snapshot') console.log('state:', job.state);
if (event === 'done') console.log(result.answers);
if (event === 'error') console.error(error);
};
Events: snapshot, started, done, error.
Opens with a snapshot of the 25 most recent jobs, then emits service-wide events. Built for a dashboard; not the right tool for waiting on one job.
The server pings every 30 seconds so idle sockets survive proxy idle timeouts.
Token budget
maxTokens caps output tokens, and reasoning counts
against it. A high-effort model can spend its whole budget thinking and emit no
visible text at all.
A 32,000-token budget at high effort once returned a completion with zero characters. The service now detects this and fails loudly rather than handing back an empty answer:
empty completion (status=incomplete, reason=max_output_tokens,
output_tokens=32000, reasoning_tokens=32000). Raise maxTokens or lower effort.The default is 120,000, which is ample. You only need to think about this if you lower it.
Reading the counts
The tokens block is normalised across vendors; the raw usage
block is not. OpenAI folds reasoning into output_tokens; Gemini reports
it separately and excludes it from candidatesTokenCount. Summing the obvious
field across both would undercount one and double-count the other.
In tokens, output always means visible output and
reasoning is always separate. Use result.tokens.total for the cost
of the whole consultation — it counts every upstream call, which matters because
debate makes four. Reasoning usually dominates.
Writing the prompt
The system prompt does more work than the user prompt
Without a system you get a competent generalist. A domain-specific system
prompt changes answer quality more than any other parameter. State the persona, the standard
of evidence, and what to refuse:
You are a senior consultant on distributed systems. You are blunt and specific.
Name actual technologies, failure modes, and numbers. If a proposal will not work
at the stated scale, say so plainly and give the version that will. Call out
approaches that are already commoditised. No motivational preamble.
Three habits that pay
- Ask for structure
- Numbered sections come back cleanly and make
debatefar more useful, because round two can address each section. Vague prompts produce vague disagreement. - Give the constraints
- Hardware, budget, headcount, deadline. Otherwise both models propose things that assume a cluster.
- Ask what is missing
- “What is the most important point missing from this analysis?” reliably surfaces what neither model raised unprompted.
Cost discipline
Every call spends real credits, and debate spends four times what a single
model would.
- Default to
fanout. Escalate when the decision warrants it. - Develop at
effort: "low". Iterate on prompt shape cheaply, then raise effort for the real run. - Do not poll faster than every 10 seconds. Polling costs no API credits but it does hammer the service. Use WebSocket if you need to know immediately.
- Restrict vendors with
vendors: ["openai"]when you do not need the cross-check — though at that point, consider calling the vendor directly.
Storage & the record
Consultations are written to a persistent volume and survive restarts and redeploys. Writes are atomic — temp file then rename — so a crash mid-write cannot corrupt a job. Retention defaults to 30 days or 5,000 jobs, whichever comes first.
Writable is not persistent
storage.writable only tells you the path can be written to. Whether it
survives a deploy is a different question, and the two were conflated: three
consecutive production deploys silently lost every stored consultation while health
reported the storage healthy, because the orchestrator handed the container a fresh volume
each time.
Each boot now stamps a marker and reads back what the previous boot left:
"storage": {
"writable": true,
"volume": { "persisted": true, "boots": 4,
"first_seen": "2026-08-29T18:00:00.000Z" }
}
If boots is 1 again after a redeploy, the volume is not
persisting and every prior consultation is gone. persisted: null on a first
boot is honest — a brand-new volume genuinely cannot prove anything yet; only a second boot
can distinguish "new" from "not persisting".
On CapRover this means App Configs → Persistent Directories with path
/data and a label, not a bare path. The image deliberately declares no
VOLUME, because a bare VOLUME directive gives Docker licence to create a fresh
anonymous volume on every container recreation.
If a result matters, persist it on your side. Prompts and results are stored in full and are also transmitted to OpenAI and Google. Do not send credentials, customer data, or anything you would not want retained on that volume.
Single-writer model
The service is single-writer by design: flat files plus an in-memory index, with a boot
sweep that rewrites running jobs to interrupted. Only one instance
may run that sweep, so instances coordinate through a heartbeat lock on /data.
- An instance serves traffic immediately on boot and acquires the lock in the background. The destructive sweep runs only once it is provably the sole writer.
- During a rolling deploy the new container waits for the old to exit, and does
not reconcile meanwhile — so it cannot mark a still-running job as
interruptedout from under the draining instance. - A container killed with
SIGKILLleaves the lock held; the next instance takes over automatically once the heartbeat goes stale, in about twenty seconds.
The flat-file store cannot support concurrent writers. Two instances will serve divergent views of the same job, and which answer a client gets depends on which one the proxy routes to.
Upstream resilience
- Every vendor call has an abort timeout, default fifteen minutes. Without it a hung
provider would keep a job
runningand its prompt resident in memory indefinitely. 429,408and5xxare retried with exponential backoff and jitter, honouringRetry-After. Rate limits are transient — fanning one prompt to two vendors crosses tokens-per-minute limits easily under load.- Other
4xxresponses fail immediately; they will not succeed on retry.
Runbook
| Symptom | Check | Fix |
|---|---|---|
| 401 on everything | /health → auth | Token wrong or not set on the deployment. |
auth: "open" | — | Incident. Set COUNCIL_TOKEN, redeploy. |
| Results vanish after deploy | storage.durable | Persistent directory not mapped to /data. |
Jobs stuck running | boot.interrupted | Service restarted; re-submit. Should otherwise be impossible — check the upstream timeout. |
| Answer text empty | vendor error field | Reasoning ate the budget. Raise maxTokens or lower effort. |
boot.writer false | replica count | Another instance, or a stale lock. Ensure one replica; stale locks clear in ~20s. |
| Sporadic vendor errors under load | logs for 429, retry | Expected and retried. Raise provider rate limits if persistent. |
| WebSocket 404 on upgrade | path | Must be /v1/stream or /v1/jobs/:id/stream. |
| WebSocket closes near 60s | proxy config | WebSocket support disabled at the proxy. |
| One vendor always errors | /v1/models → configured | That vendor's API key is missing. |
state is degraded | failed_vendors | One vendor failed; usable output is still present. Not an error. |
| Duplicate jobs after a retry | — | Send an Idempotency-Key header. |
prompt is null on a record | prompt_stored | Submitted with store_prompt: false. |
| 404 on a job you just created | token → project | Reading with a different project's token. Jobs are scoped. |
| Consultations queue but do not start | /health → concurrency | At the project cap. Raise MAX_CONCURRENT_PER_PROJECT. |
| Records disappearing early | MAX_JOBS_PER_PROJECT | That project hit its retention budget. |