Every call the platform's own UI makes, in order, ending with a frozen adjudicator answering claims over HTTP from a container.
This is written from a run that was actually performed end to end, so the sequence, the request shapes, and the places that bite are all observed rather than inferred. Where a step has a non-obvious requirement it is called out as Gotcha.
http://127.0.0.1:8099/api (EARL_API_PREFIX defaults to
/api).Authorization: Bearer <token>.super_admin can do everything here; user can do everything from
step 3 on. auditor can do everything a user can — it is meant to drive the
platform end to end — and additionally reaches the read-only auditor surfaces.Scope of this guide. It documents the adjudicator builder as it is available today. Action authorization is demonstrated separately (the evaluation console); a capability-oriented developer API is planned. The vertical application guide lists what is available, exposed with limits and not exposed.
The interactive endpoint reference (Swagger UI) is at
/api/docs, ReDoc at/api/redoc, the schema at/api/openapi.json. On a deployed platform only/api/*reaches the backend, so/docsshows the app shell, not the API.
An EARL engine release must be installed before anything can train.
Install one through the admin API (or scripts/install_earl_engine_release.py):
curl -X POST "$API/earl-engines/install-jobs" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"release_tag": "v0.3.0"}'
curl "$API/earl-engines" -H "Authorization: Bearer $TOKEN" # confirm it registered
The installer picks the asset matching your host. Releases from v0.3.0 cover macOS (Apple Silicon and Intel), Linux (x86_64 and aarch64) and Windows.
Gotcha — a domain's engine is chosen when the domain is created and cannot be changed afterwards. To move onto a newer engine you create a new domain bound to it. Install the engine you intend to use first.
Gotcha — a newer engine can change verdicts. The bundled examples document verdicts measured on a specific release. After installing an engine, run its example check (
POST /earl-engines/{id}/example-checks, see Start from a working example) before building domains on it, so a divergence shows up as a check result rather than as your first domain contradicting the documentation.
POST /auth/token
{"username": "superadmin", "password": "superadmin"}
→ {"access_token": "...", "token_type": "bearer"}
Gotcha — the endpoint is
/auth/token, not/auth/login. The/auth/login/{provider}routes are the OAuth flow, a different thing.Bootstrap credentials default to
superadmin/superadmin,user/user,auditor/auditor. Change them viaEARL_BOOTSTRAP_*before any instance is reachable by anything but you.
The base vocabulary every domain grounds against. Package one from an installed engine's bundled KB:
python tools/package_kb.py <engine>/real_kb my-base-kb my-base-kb.zip \
--desc "Base EARL KB from engine v0.3.0"
POST /foundational-kb-packages (multipart: package_file=<zip>)
→ {"id": 1, "codename": "my-base-kb", "status": "uploaded"}
POST /foundational-kb-packages/1/approve
→ {"status": "approved"}
Packages land as uploaded and must be approved before a domain can bind
one — generated packages go through the identical gate, so generation
produces a candidate, never a pre-blessed artefact. reject and revoke both
require a written reason, because they are terminal and need an audit trail.
Gotcha — the fingerprint is content-derived, so re-uploading identical bytes returns 409. Reuse the existing package instead of re-uploading.
POST /domains
{"name": "Payments Routing Adjudicator",
"description": "Adjudicates routing claims against acquirer capability guidance",
"foundational_kb_package_id": 1,
"earl_engine_id": 1}
→ {"id": 1, ...}
foundational_kb_package_id and earl_engine_id may be omitted at creation
and the KB bound afterwards:
POST /domains/1/bind-foundational-kb
{"foundational_kb_package_id": 1}
Gotcha — domain names are unique; a duplicate returns 409.
POST /domains/1/documents (multipart: document_file=<file>)
Accepted: .md .markdown .txt .html .htm .pdf .docx .csv.
Anything that is not markdown is converted to markdown at upload (PDF text
layer, Word paragraphs and tables, HTML headings/lists/tables, CSV as one
table) and stored as <name>.md; the untouched original is kept and served at
GET /domains/1/documents/{id}/original. The response says which converter
ran (conversion) and what was uploaded (original_filename). A scanned PDF
has no text layer and is refused with 400. Other types are refused with 400
naming the supported list.
On corpus shape — one document per topic, short declarative sentences,
explicit names for the things claims will be about — see
platform/README.md in the repository, which covers it properly.
POST /domains/1/tuning-runs
{"source_document_ids": [1, 2, 3, 4, 5, 6]}
→ {"id": 1, "stage": "corpus_ready"}
A tuning run is the unit of work from corpus to trained checkpoint. GET
/tuning-runs/{id} returns its current stage plus every progress message,
log and error recorded so far — this is the endpoint to poll.
The stage ladder:
corpus_ready → vocab_generated → vocab_approved → lessons_generated
→ lessons_approved → school_queued → school_running → school_succeeded
→ school_accepted → training_queued → training_running → training_succeeded
with school_failed / training_failed as terminal alternatives.
The vocabulary decides which words become first-class concepts. Generate it from the corpus with an LLM:
POST /tuning-runs/1/generate-vocab
{"prompt_addendum": "optional operator guidance"}
…or write it yourself and skip the model entirely (no LLM credentials needed for the whole flow if you author both drafts). Check first whether generation is even available on the platform you are on:
GET /generation/status
→ {"configured": false, "missing": ["EARL_LLM_API_KEY", "EARL_LLM_MODEL"], "message": "..."}
The builder UI calls this and, when generation is not configured, says so on
Step 1 and Step 2 and offers Author manually (opens the editor with a
starter template) and Import draft (a .py vocabulary module, or a
.json curriculum) instead of failing on submit. Authored and imported drafts
go through the same validation and approval gates as generated ones.
PUT /tuning-runs/1/vocab-draft
{"module_text": "<python source>"}
→ {"validation_summary": {"syntax_ok": true, "contract_ok": true, "errors": [], "warnings": []}}
CLUSTERS = [
("schemes", "scheme", ["scheme", "visa", "mastercard", "amex"]),
("settlement", "settlement", ["settlement", "eur", "usd", "gbp"]),
]
# Optional. Surface forms the corpus uses for a concept your vocabulary
# names in short form. Without these, prose saying "American Express"
# cannot constrain a claim phrased "amex".
ALIASES = {"amex": ["american express"], "usd": ["united states dollars"]}
def anchor_chain():
return [("scheme", "settlement")]
Rules the validator enforces, reported on the draft rather than as a job failure later:
CLUSTERS — non-empty list of (cluster_name, anchor, [tokens]); the
anchor must appear in its own token listanchor_chain() — returns a list of 2-tuples linking anchorsALIASES (optional) — canonical must be a token in some cluster; a surface
is at most 4 words, must not duplicate a real token, and must not be
declared for two different canonicalsGotcha — every value must be a literal. The module is parsed as data (AST only) and never executed, so comprehensions, variables and computed returns cannot be resolved. Inline the lists and tuples. This is deliberate: the draft is LLM- or operator-authored text arriving over HTTP, and running it would be arbitrary server-side code execution.
Then approve and build:
POST /tuning-runs/1/vocab-draft/approve
POST /tuning-runs/1/build-vocab-kb → generates + registers a domain KB package
POST /tuning-runs/1/generate-lessons (LLM)
PUT /tuning-runs/1/lesson-draft {"curriculum_json": { ... }}
POST /tuning-runs/1/lesson-draft/approve
Curriculum schema (subject, lessons[] with id, title, grade,
requires, propositions, assessments, mastery) is documented in
the engine's CURRICULUM_JSON.md (shipped under earl/ in the package) and authoring a domain §3. A minimal lesson:
{
"id": "PR.1", "title": "Acquirers and Schemes", "grade": "Kindergarten",
"requires": [],
"propositions": ["an acquirer processes a transaction."],
"assessments": [{"a": "acquirer", "b": "transaction",
"prompt": "Does an acquirer process a transaction?"}],
"mastery": 0.95
}
Individual lessons can be regenerated with feedback
(POST /tuning-runs/1/lessons/regenerate) and approved one at a time
(POST /tuning-runs/1/lessons/{lesson_id}/approve).
POST /tuning-runs/1/run-school
Runs asynchronously. Poll GET /tuning-runs/1 until stage is
school_succeeded or school_failed; school_log, school_summary and
school_progress_message carry the detail. A failed run always carries an
error message — if the executor died before producing output, the message
says exactly that rather than leaving you a null.
POST /tuning-runs/1/accept-school
{"accepted": true}
Gotcha —
accept-schoolrequires that body. Sending none returns 422, which is easy to misread as a server problem.Contract — a school that left lessons unmastered (or produced no summary) is refused with 409 naming the gaps. Locked lessons never teach their concepts, so the trained brain would lack them and claims about them would hallucinate. Fix the vocabulary or corpus and run school again; to accept anyway, send
{"accepted": true, "accept_unmastered": true, "reason": "..."}— the reason is recorded on the run.
POST /tuning-runs/1/train-domain (no body)
Poll until training_succeeded / training_failed. On success the run
carries training_checkpoint_dir — the trained brain.
POST /tuning-runs/1/freeze-domain
{"codename": "payments-routing-v1",
"description": "Frozen from tuning run 1",
"runtime_port": 8410}
→ {"id": 1, "codename": "payments-routing-v1", "status": "ready", "artifact_path": "..."}
Produces a self-contained portable package (~98 MB): the grounded KB, the
trained checkpoint, an earl-verify binary matching the container's platform,
a FastAPI server, a Dockerfile, a docker-compose.yml, curl examples, a
manifest, the domain's policy (policy.json + the evaluator app/policy.py,
see §13) and its substrate identity (identity.json). It runs anywhere
Docker does, with no platform present.
The response carries substrate_identity: a sha256 over {engine release +
commit + the exact earl-verify binary, KB fingerprint, checkpoint
fingerprint (every file), policy hash}. The container answers with the same
value at GET /identity and in every /verify reply; evidence packages (§14)
carry it too. Two deployments with equal identities answer identically; a
package whose identity differs from the deployment's was not produced by it.
Freezing requires training_succeeded and a checkpoint that still exists on
disk. Freezing captures the policy version active at that moment; editing
the policy afterwards never changes a frozen package.
POST /deployments
{"name": "payments-routing-prod",
"frozen_domain_package_id": 1,
"runtime_port": 8410}
→ {"id": 1, "status": "creating", ...}
Creating a deployment builds and starts it. The backend extracts the
frozen package into a workspace and runs a real docker compose up --build -d
in the background. Poll GET /deployments/{id} (or the list GET /deployments)
until status is running or failed — a running deployment usually reports
within about 20 s of the create call; operation_log streams the full build and
compose output, and POST /deployments/{id}/tail-logs follows the container.
Frozen packages are listed by GET /frozen-domains (each row carries its id,
domain, substrate identity and policy hash); there is no per-id read and no
/frozen-domain-packages path. A tenant sees only its own rows.
POST /deployments/{id}/start is for a deployment that is stopped or
failed (after POST /deployments/{id}/stop, or to retry a failed build).
Calling it while the deployment is still creating returns 409.
Gotcha — an earlier version of this guide showed
startimmediately aftercreate. That ran a secondcompose upagainst a workspace the creating thread was still extracting, which failed witharchive/tar: write too longand a missingbin/, and then reportedfaileduntil the creation finished and overwrote it withrunning. The API now refuses that call.
Docker must be running on the host.
Against the deployed container:
curl -X POST http://127.0.0.1:8410/verify \
-H 'Content-Type: application/json' \
-d '{"candidate_text": "Atlas settles eur currency"}'
Gotcha — the field is
candidate_text, nottext.
From outside the host, call the deployment through the platform instead —
same request body, your normal bearer token, the container's reply returned
unchanged under result, plus a signed evidence package (§14) for the
exchange:
POST /deployments/{id}/verify {"candidate_text": "Atlas settles eur currency"}
→ {"result": {...container reply...}, "substrate_identity": "...",
"substrate_identity_mismatch": false,
"evidence_package_id": "...", "evidence_package": {...signed...}}
GET /deployments/{id}/health
GET /deployments/{id}/evidence-packages
409 if the deployment is not running; 502 if the container does not answer.
substrate_identity_mismatch: true means the container reports a different
identity from the one the platform recorded for that package — it is not
running what it says it is.
Or through the platform, against the working checkpoint of a domain (what the builder's "Domain testing" card calls):
POST /domains/1/adjudication-runs
{"candidate_text": "...", "mode": "verify"} # or "completion"
→ {"result": {...}, "evidence_package_id": "...", "evidence_package": {...},
"policy_version": 2, "policy_hash": "..."}
Batch test with expected outcomes. Add expectations, matched to claim
lines by text; either expected field may be omitted:
POST /domains/1/adjudication-runs (also POST /deployments/{id}/verify)
{"candidate_text": "Atlas settles eur currency\nNorthstar supports the amex scheme",
"expectations": [
{"claim": "Atlas settles eur currency", "expected_verdict": "VERIFIED", "expected_disposition": "ALLOW"},
{"claim": "Northstar supports the amex scheme", "expected_verdict": "UNCERTAIN", "expected_disposition": "DENY"}]}
→ {"expectations": {"total": 2, "verdict_matched": 2, "verdict_mismatched": 0,
"disposition_matched": 2, "disposition_mismatched": 0,
"unmatched_expectations": [], "mismatches": [], "all_matched": true}, ...}
Every claim row then carries expected_verdict, expected_disposition,
verdict_match and disposition_match (on the working checkpoint, inside
result.claim_results; through a deployment, in evaluated_claim_results,
because the container's own reply is never modified). The expectations and
the summary are part of the signed evidence package (request.expectations,
evaluation.expectations). In the UI, write claim | VERDICT | DISPOSITION
per line in the test dialog.
Every claim row keeps every engine field (verdict, endpoints,
endpoints_asserted, grounding, contradictions, demotion_reason,
prompt_hash) and, when the domain has a policy, a policy block with its
disposition and the rules that matched.
{"verdict": "CONTESTED", "grounding": 0.65,
"endpoints": ["atlas", "eur"], "endpoints_asserted": true,
"contradictions": 1, "relation_downgraded": false,
"demotion_reason": "negated_claim_grounds_only_affirmative_evidence"}
| Field | Means |
|---|---|
verdict |
VERIFIED · CONTESTED (conflicting evidence) · UNCERTAIN (cannot confirm) · HALLUCINATED (no grounding) |
grounding |
Strength of the path between the endpoints, 0–1 |
endpoints |
The entity pair the verdict is about |
endpoints_asserted |
true when that pair is the one the claim asserts, rather than the easiest-to-ground pair in the sentence |
contradictions |
Contradiction edges crossed — corpus evidence against the claim |
demotion_reason |
Why a verdict was lowered, when it was |
A claim and its exact negation cannot both verify. The demotions you will see most:
negated_claim_grounds_only_affirmative_evidence — the denial is contested
by the evidence it grounded throughaffirmative_claim_negated_in_substrate — the corpus explicitly denies itrelation_unrecognized — the relation kind could not be confirmed. This
is a lexicon miss, not an evidence judgement, which is why it lands on
UNCERTAIN rather than CONTESTEDWorth knowing before you build on a verdict:
closed_world rule on
northstar with permitted: ["visa", "mastercard"] turns the honest
UNCERTAIN into a DENY disposition, deterministically, outside the brain.GET /domains/1/policy active version (nulls when none)
PUT /domains/1/policy {"document": {...}} → new version
GET /domains/1/policy/versions history, newest first
A policy document (earl-domain-policy/1) maps each engine verdict to a
default disposition — ALLOW, REVIEW or DENY — and lists rules that
can raise it (never lower it):
{"format": "earl-domain-policy/1",
"name": "payments routing v1",
"dispositions": {"VERIFIED": "ALLOW", "CONTESTED": "DENY",
"UNCERTAIN": "REVIEW", "HALLUCINATED": "DENY"},
"rules": [
{"id": "asserted-pair", "type": "require_asserted_pair", "action": "review"},
{"id": "floor", "type": "min_grounding", "threshold": 0.5, "action": "review"},
{"id": "northstar", "type": "closed_world", "entity": "northstar",
"permitted": ["visa", "mastercard"], "action": "deny",
"reason": "Northstar supports Visa and Mastercard only"},
{"id": "clean", "type": "max_contradictions", "max": 0, "action": "deny"},
{"id": "lexicon", "type": "demotion_reasons",
"reasons": ["relation_unrecognized"], "action": "review"}]}
Rules see only what the verdict exposes. closed_world acts on the entity
pair the verdict is about (endpoints), never on words in the sentence, so
a bystander mention cannot trigger it. Invalid documents are refused with 400
naming the first defect; identical content is a no-op. Versions are
append-only. Test runs (§12) apply the newest version; a frozen package (§10)
embeds the version active at freeze time and the container applies it
itself — the same evaluator file, byte for byte. Full rule reference in
Evidence packages and policy.
Every POST /domains/{id}/adjudication-runs and every
POST /deployments/{id}/verify produces an Ed25519-signed package
binding the full result to what produced it (substrate identity, engine, KB
and checkpoint fingerprints, policy hash) and what was asked (claims, hashed).
It is returned inline and stored:
GET /domains/1/evidence-packages summaries, newest first
GET /deployments/1/evidence-packages
GET /evidence-packages/{package_id} the signed package, as issued
POST /evidence-packages/verify {"package": {...}} → {"valid": true, "checks": [...]}
GET /evidence/public-key (no auth) the platform's signing key + key_id
GET /evidence/verifier (no auth) verify_evidence_package.py
Offline, with nothing but the file:
python verify_evidence_package.py evidence-<id>.json --pin-key <key_id from /evidence/public-key>
Replay. Re-adjudicate a recorded package's claims on the same source (the domain's working checkpoint, or the deployment) and compare:
POST /evidence-packages/{package_id}/replay
→ {"original_package_id": "...", "replay_package_id": "...", "source": "deployment",
"comparison": {"identical": true, "claims_compared": 10, "differences": [],
"original_policy_hash": "...", "replay_policy_hash": "..."}}
differences names every claim whose verdict, disposition, pair,
endpoints_asserted or demotion reason changed. The replay is recorded as a
new signed package with replay_of set and the comparison under
evaluation.replay, so the determinism check is itself evidence.
Exit 0 when every check passes (format, key id, payload hash, signature, claim-text hash, identity record, pinned issuer), 1 otherwise. The format and what each check proves are in Evidence packages and policy.
GET /organizations POST /organizations {"name": "...", "description": "..."}
GET /users POST /users {"username", "password" (12+), "role", "organization_id"}
PATCH /users/{id} {"role"?, "organization_id"?, "clear_organization"?, "is_active"?, "password"?}
A user or auditor sees only the domains — and their runs, packages,
deployments, documents, policies and evidence — of their own organization;
other tenants' ids answer 404. A super_admin sees everything and may
create a domain in any organization (organization_id on POST /domains);
anyone else's domains join their own organization automatically. Engines and
foundational KB packages are platform-level and shared.
A Google sign-in the platform has never seen is created inactive with role
user; the login is refused until a super_admin activates it and places it.
| Operation | Poll | Terminal |
|---|---|---|
| Ingestion | GET /ingestion-jobs/{id} |
succeeded / failed |
| KB generation | GET /kb-generation-jobs/{id} |
succeeded / failed |
| School | GET /tuning-runs/{id} |
school_succeeded / school_failed |
| Training | GET /tuning-runs/{id} |
training_succeeded / training_failed |
| Deployment | GET /deployments |
running / failed |
Every failure carries a non-empty error_message, and school, training and
executor crashes also carry a log with the captured output or traceback.
GET /domains/1/kg/summary entity and belief counts
GET /domains/1/kg/entities resolved entities
GET /domains/1/kg/entity?name=x one entity and its neighbours
GET /domains/1/kg/path?a=x&b=y the grounding path between two entities
GET /domains/1/kg/graph the graph, for visualisation
kg/path is the one to reach for when a verdict surprises you: it shows the
route the grounding actually took.