Skip to main content
Use the REST API when you’re building your own backend, dashboard, or custom workflow — a lending application that calls /decide on every form submission, a compliance portal that displays current rule explanations, or a custom authoring pipeline. Base URL: https://api.aethis.ai/api/v1/public/ Evaluation and read endpoints on public rulesets accept anonymous requests — no key.
Authoring endpoints require an x-api-key header, are invite-only, and must be called from your server.
Only an enumerated set of routes is reachable from a browser on an arbitrary origin — see Browser and mobile access before you call this API from client-side code.
Successful decision responses identify the serving build in engine_version. Read that field from your response rather than relying on a version copied into a tutorial. What the deployed engine serves.
Examples below marked (illustrative) use placeholder digests and identifiers; the field names and shapes are exact.

Authentication

Get an API key: sign up Public ruleset decisions accept anonymous requests. When supplied, the key identifies the caller and its access; private rulesets and rulebook decisions require appropriate access.

Scopes

API keys carry a set of scopes. Each authoring endpoint requires a specific scope; a key without that scope receives 403 Forbidden. For the CLI-side view — what aethis login mints, how to check with aethis whoami, and how to mint a rulebook-capable key — see Authentication & API keys. Decision endpoints (/decide, /schema, /explain on public rulesets) require no scope and accept anonymous requests. GET /rulebooks/ also accepts anonymous requests: without a key it returns the cross-tenant public catalogue (rulebooks with visibility=public and status=active); with a key it returns your tenant’s rulebooks as before.
Rulebook lookups and decisions require an API key. Anonymous /decide resolves a ruleset_id or ruleset slug against public rulesets only. The separate rulebook_id field (for composed multi-section rulebooks like aethis/uk-fsm) is always scope-gated — anonymous callers get a 401. Anonymous access to rulebooks is limited to the GET /rulebooks/ catalogue listing; to evaluate one, hit each section by slug instead, or pass an x-api-key header with a key that has the decide scope. See Nomenclature for the full distinction.

Browser and mobile access (CORS)

Cross-origin access is scoped per route and method, not granted to “decision endpoints” as a class. Two regimes: Open surface — any origin, no credentials. Exactly these (method, path) pairs answer a cross-origin request from any website: On this surface the engine allows GET, POST and OPTIONS, accepts the Content-Type and X-API-Key request headers, and never returns Access-Control-Allow-Credentials — cookies and other ambient credentials are not carried, by design. Restricted surface — everything else. Every other route, including all authoring and project routes, grants CORS only to first-party Aethis origins. A browser request from your own origin to an authoring route receives no CORS grant and the browser blocks it. Preflights are classified by the method the browser asks for, so an OPTIONS preflight for a non-open method on an open path is handled by the restricted policy.
Two consequences worth designing around.
  1. GET /rulebooks/ accepts anonymous requests server-side, but it is not on the open browser surface — call it from your backend.
  2. Never ship an authoring key to a browser. Even setting CORS aside, an x-api-key in client-side code is a published credential.
Client-side use is intended for the evaluation surface: decide against a public ruleset, read its schema, explain it, graph it. Anything that writes belongs on your server.

Rate limits

Every authenticated request is metered against one of six operation classes. A class is both the rate-limit bucket and the usage metric, so what you’re throttled on is exactly what you can measure: Limits apply per rolling 24-hour window (hourly buckets that slide continuously, not a calendar-day reset). Budgets are generous everywhere except generate, which carries the only meaningful ceiling — so ordinary authoring and decisions no longer compete with generation for a shared quota:
generate limits currently run in report-only mode: an over-limit request is recorded but not rejected while the ceilings are tuned against real usage. Every other class is enforced. Tier is set at key creation — contact eng@aethis.ai to upgrade.

Forward-visibility headers

Every metered response carries your current budget, so a 429 is never the first signal:
  • X-RateLimit-Class — the operation class this request was metered against.
  • X-RateLimit-Limit — the limit for that class on your tier.
  • X-RateLimit-Remaining — requests left in the current rolling window.
  • X-RateLimit-Reset — epoch seconds of the next hourly boundary, when the oldest counted hour ages out and budget is freed.

Check your usage

GET /usage returns your budget and usage across every operation class. It is scoped to the calling key and is never metered — poll it as often as you like without consuming quota:
classes[] gives each class’s rolling-24h budget and usage; rolling adds 7-day and 30-day per-class totals. See the API reference for the full schema.

Anonymous budgets

Anonymous callers are metered separately, and on more than one axis. Plan for all four when you build on the open surface: Expensive options are costed. A plain anonymous /decide charges one unit. Each of no_cache, include_trace, include_explanation, include_graph_overlay and include_choice_context adds 5 further units, so a call with trace and explanation costs 11. Budget accordingly, or use a key. include_choice_context is useful only with an authenticated rulebook decision; it is a no-op on a leaf ruleset even though the anonymous cost still applies when the flag is sent. Client identity comes from the trusted proxy hop, not from a caller-supplied X-Forwarded-For. Rotating that header does not rotate your anonymous quota. Anonymous request bodies are capped at 2 MB on the evaluation routes and rejected with 413 before the body is parsed.
What an anonymous call stores. Anonymous preview decisions persist the input hash and aggregate metadata only — never your raw field_values and never a caller reference. inputs_hash is deliberately unsalted, so you can recompute it yourself from the same inputs and match a stored decision for replay.

Decision envelope

Every /decide response includes audit fields (decision_id, inputs_hash, engine_version) for reproducible replay without the server echoing your inputs. See Decision envelope → for the full contract.

Resolved identity

For a published leaf ruleset the response always resolves what you asked for into immutable identity, whether you passed a slug or an ID and whether the answer came from cache or cold: Pin replay and audit to content_digest, not to the version label. A republish of byte-identical content can advance the label while reusing the existing content cut; only the digest identifies the rules that produced a given decision. ruleset_version: "unknown" is not a possible value for a published leaf ruleset. Composed rulebook (rulebook_id) responses still report "unknown" for the composition until resolved member identity lands.

Evaluate eligibility

No API key required.
Response (illustrative — digests and IDs are placeholders; field names and shapes are exact):
Parameters:
  • ruleset_id — the published ruleset to evaluate against
  • field_values — map of field names to values (types must match the ruleset schema)
  • include_trace (optional) — when true, returns a trace object showing how each criterion was evaluated and the source clause it references
  • include_explanation (optional) — when true, returns a structured explanation object: gate-level checklist (groups[].criteria[].status), the supporting facts that proved each satisfied criterion (supporting_facts), the satisfied requirement name (decision_path), and any provided fields the ruleset never references (unused_facts — useful for catching field-name typos). See Debug a /decide for the full payload shape.
  • include_choice_context (optional) — when true on a rulebook_id decision, returns compact, answer-aware context for conversational route choices. It is false by default and a no-op on a leaf ruleset_id decision.
  • inquiry_context (optional) — supplies held question instructions and navigation preferences for this decision. It does not add facts to field_values or change the eligibility verdict.

Conversational choice context

Set include_choice_context: true when a conversational interface needs to offer authored alternatives without downloading or interpreting the full dependency graph:
The response’s choice_context is populated only for that opt-in rulebook call:
field_sections is the complete field-to-section membership map. choice_points contains authored OR-groups; each alternative is open, closed, or satisfied for the supplied answers. offerable is true only when no alternative is satisfied and at least two remain open. Use include_graph_overlay instead when you need the full graph for visualisation or debugging. Without the opt-in flag, and on leaf ruleset decisions, choice_context is null.

Inquiry navigation

Use inquiry_context when an intake flow needs to keep a question open without inventing an answer, or wants Core to apply a one-turn navigation preference. The context is request-local. It contains only exact field, section, and route IDs from the selected schema; it is not a substitute for field_values. The JSON fragments in this section show the contract shape. Use IDs returned by the schema for a live request.
held[].reason is either unknown or postponed. navigation.intent is continue, leave, or go_to; only go_to carries section_id. selected_route is optional. The API validates every supplied ID against the selected schema and rejects malformed, conflicting, duplicate, or non-applicant instructions. This public Free School Meals ruleset has two applicant fields. The following REST call holds both without asserting either as a fact:
An opt-in response can include inquiry_selection:
reason is engine_order, explicit_section, route_preference, section_continuity, or null. The satisfaction fields are true, false, or null when the corresponding preference was not supplied. They explain the question selection only; they do not change decision. When every currently askable applicant field is held, the response can include an inquiry_outcome instead of a next_question:
awaiting_revisit means held instructions mask the immediate question; it is not an eligibility outcome and does not add an undetermined_reason value. The existing more_to_ask reason remains correct because unheld selection would otherwise continue. Existing terminal, non-applicant, input-error, and review boundaries take precedence and omit inquiry_outcome. Omit inquiry_context to retain the existing request and response shape. The official Python SDK, CLI, and MCP server do not yet expose this opt-in; use the REST API until their linked releases ship. Decisions:
  • eligible — all criteria satisfied
  • not_eligible — one or more criteria failed
  • undetermined — the engine could not reach a decision (missing field, discretionary clause, blocking input error, or a case outside the compiled rules)

Reading the response

Blocking errors versus advisory signals

field_errors is the only blocking channel in a /decide response. Every entry means an input you sent could not be applied to the ruleset: an unknown field key, a value that does not convert to the field’s type, or a conversion failure at evaluation time. A non-empty field_errors always forces decision: "undetermined". The engine never returns eligible or not_eligible next to a blocking input error, because that verdict would have been computed from a partial input set you did not knowingly send. Read field_errors before you treat any decision as terminal.
Everything else is advisory and never gates the outcome:
  • missing_fields, next_question, optimal_path — progress guidance while the decision is undetermined. With inquiry_context, a null next_question and undetermined_reason: "more_to_ask" can instead mean inquiry_outcome.status: "awaiting_revisit"; read that opt-in member before treating the question flow as complete;
  • explanation.unused_facts — answers you sent that no satisfied criterion referenced (usually a field-name typo);
  • a malformed caller_ref — dropped with a server-side warning, never a rejection.
Per-criterion status is not a second decision. When field_errors forces undetermined, explanation.groups[].status and graph_overlay.nodes[].overlay.status still report what the engine established for each criterion from the valid subset of your inputs — so a criterion can legitimately read "satisfied" in the same response.That is deliberate: those fields answer “what is true of this criterion?”, while decision answers “what may I act on?”. Never recompute an outcome by aggregating group or node statuses. decision is the only field to treat as the result, and you read field_errors first.

The response truth table

A 500 returns an opaque envelope: no partial decision, and none of your submitted values echoed back. Unknown top-level request keys are rejected, not ignored: the request body is strict, and an undefined key returns 422 with detail[].type == "extra_forbidden". If you have code that sends a field this API does not document, it has never been doing what you thought.

Trace, explanation, and source references

Three distinct things, often conflated. Ask for the one you need — on the anonymous surface each costs extra quota. trace is not a citation. It shows the compiled logic that ran. The citation lives in source_references, which is validated at publish time rather than assembled at read time.

The SourceReference contract

GET /rulesets/{id}/explain and POST /decide with include_explanation: true return the identical SourceReference shape — on /explain it sits at criteria[].source_references[], on /decide at explanation.groups[].criteria[].source_references[].
What the contract guarantees, and what it does not:
  • quote.exact is verbatim, never a summary. Publish validation asserts the quote occurs in the fetched source after whitespace normalisation (HTML is tag-stripped, PDF is checked against extracted page text). No stemming, no case folding, no fuzzy matching.
  • References are resolved and verified at publish time, not at read time. An unresolvable, private, unlicensed or digest-mismatched reference fails the publish. You are reading a check that already passed, so /explain does not fetch anything while you wait.
  • content_digest fixes the bytes the quote was verified against, and verified_at says when. If the authority silently rewrites the page, your citation still names what was actually cited.
  • deep_link locates the quote in the source: a percent-encoded #:~:text= fragment for HTML and text, #page=N for PDF.
  • licence is mandatory. Reproducing text under no stated licence is not publishable.
  • schema_version grows additively. Pin schema_version >= 1 and treat new fields as optional.
  • Newly emitted v1 references carry an optional snapshot. It is the digest keying the engine-retained copy of the bytes it fetched (equal to content_digest), so the citation outlives the page it came from. Absent on references stored before snapshot-on-fetch.
Schema v2: artefact-backed references
A reference with "schema_version": 2 cites a file the author uploaded to their project rather than a public URL. It adds target_kind: "artefact", artefact_project_id and artefact_source_id, and its url is different in kind from a v1 url:
On a v2 reference, url is the relative path of an authenticated download route — /api/v1/public/projects/{project_id}/sources/{source_id}/raw, requiring a key with the projects:read scope. Resolve it against the engine base URL you are calling, and never render it as a public link. v1 url is an absolute, publicly fetchable HTTPS URL; the two are not interchangeable.
Artefact references are private-only. Any operation that would make an artefact-backed ruleset publicly resolvable — publishing it public, flipping ruleset or rulebook visibility, attaching or promoting it into a public rulebook — is rejected with artefact_reference_public_visibility_forbidden. So a publicly readable ruleset’s references are always v1 URL citations, and no anonymous response ever exposes a private project or source identifier. Full authoring semantics, including the failure reason codes, are on Provenance and citations.
source_references is present only on rulesets published under this contract. Older published rulesets may carry the legacy source_refs array of opaque authoring keys instead. Read source_references when it is present and fall back rather than assuming either.

Inspect a ruleset

notes (each FieldNoteOut: { note_text, source, metadata }) is the structured guidance the ruleset author attached during /fields/discover. Conversational front-ends (e.g. a paralegal bot) typically render metadata.type='why' notes when the user asks “why are you asking this?” and draw on metadata.type='legal_background' notes for edge-case follow-ups. Older bundles return "notes": [] — treat the array as optional. weight is the question-ordering hint used by the constraint solver (higher = less preferred to ask). Used by /decide’s optimal_path to surface cheap questions before expensive ones when multiple ways to satisfy the ruleset exist. enum_labels is an optional {member slug: display label} map the ruleset author attached to an Enum field, for front-ends that store the slug but must show a human-readable label. canonical_field is the author’s pairing between this eligibility field and the consuming system’s own storage key for the same fact. Both are carried by the engine and never interpreted by it — nothing validates enum_labels against enum_values. Both are always present on the response and are null when the author declared neither; read them with an explicit null check, since {} means “no labels” and is a different statement from null.

Honest catalogue counts

GET /rulesets returns a bare JSON array. A stored record that fails validation on read is skipped rather than 500-ing the whole catalogue, which means a page can be shorter than the records the query matched. Every response carries a header saying how many were dropped:
Read it on every page. 0 is the normal case; a non-zero value tells you a short page is short because something was skipped, not because you reached the end of the catalogue. Never infer exhaustion from len(page) < limit. A direct read of a corrupt record is never silently skipped: it returns a structured 422 with reason_code: "ruleset_record_corrupt", so the failure is attributable to one record rather than surfacing as a page-wide 500. Same anonymous-public access applies to GET /rulesets/{id}/graph — the compiled ruleset as a node/edge graph plus a ready-to-render Mermaid string, for visualizing structure instead of reading a flat field list. See Ruleset & rulebook graphs.

Author rules

Authoring is invite-only private beta (request access). API key required; do all authoring server-side. The literal flow is create project → upload source → discover and review fields → store reviewed tests → start one generation job → poll that job → test → publish → decide. See Author your first ruleset for the complete executable sequence. The provider credential belongs in X-Anthropic-Key on discovery and default Sonnet generation requests. The DeepSeek generation option uses X-DeepSeek-Key, as described below. Read it from your server’s environment; never send it in JSON, a command argument, or a client-side application. The API uses it only for the request and does not store it.
The full asynchronous contract is a release candidate until the deployed capability check says otherwise. Do not treat this reference as proof that a production revision has already shipped it.

Structured acceptance contracts

The live API supports this structured acceptance request shape. The example illustrates a contract for a prepared project; its field names must match the project’s reviewed fields and source requirements.
Store the full versioned contract atomically with POST /projects/{project_id}/tests. The raw REST envelope requires replace: true:
The JSON sidecar accepted by aethis generate --acceptance-contract contains contract_version, test_cases, and optional expected_review_bindings, but does not contain replace; the CLI supplies the REST replacement envelope. See structured acceptance contracts for assertion semantics and clearing rules.

Generation model selection

Both POST /projects/{project_id}/generate and POST /projects/{project_id}/generate-and-test accept an optional model field alongside mode and seed_ruleset_id: Omit model (or send null) to preserve the server’s configured default: Sonnet 5 unless the server operator has overridden it. Other model selectors are rejected. The model applies to this request only. For example, the body for DeepSeek refinement is:
Supply X-API-Key for Aethis authentication and only the selected provider’s credential header. Never put provider keys in the JSON body. External callers must bring that provider’s key: the API does not substitute a platform credential or another model when it is missing. This option covers generation and refinement; discovery continues to use X-Anthropic-Key.

Recover an asynchronous generation

For a section that may exceed the synchronous endpoint’s request timeout, use POST /projects/{project_id}/generate, then poll GET /projects/{project_id}/status. Send your provider credential in the selected provider’s header on the generation request only; it is never stored. If a client timeout interrupts polling, check status before attempting another generation. A current response advertises generation_contract_version: 1 and provides top-level telemetry_availability, server-authoritative worker_lifecycle, and retry_readiness. Retry only when readiness is ready; blocked means a job still owns the project and cleanup_pending means server cleanup has not finished. Its job object provides progress and timestamps, safe failure diagnostics, and live convergence fields such as current_turn, best_passed, test_total, last_tool, and seconds_since_progress.
To abandon a run, an explicit caller may POST to /projects/{project_id}/generate/cancel?job_id={observed_job_id}. Copy the id from a successful status response; binding cancellation to that exact job means a delayed request cannot cancel a newer run on the project. This marks the job failed and releases its project ownership; it does not guarantee an already-running worker stops immediately. Inspect outcome (cancelled or idempotent already_cancelled), detail, and project_released before starting another run. Never issue cancellation automatically because a client timed out or progress looks stalled.
The Bash examples below read keys from securely populated environment variables and supply headers through file descriptors. Keep shell tracing off; do not paste raw credentials into command arguments.

Incremental refine (minimal edit)

Both generate and generate-and-test accept an optional mode body. mode: "refine" seeds generation from the section’s active ruleset and makes the minimal edit to fix failing tests, instead of re-authoring the section from scratch. Omitting the body (or mode: "fresh") is the default from-scratch behaviour. Supply the selected provider key in its header (X-Anthropic-Key for Sonnet), never in a JSON field. The optional model selection above also applies to refinement.
seed_ruleset_id may be supplied to refine from a specific ruleset; when omitted, the section’s active ruleset is used.

Step 3 — Publish

Now evaluate with /decide using the returned ruleset_id.

Promote a testing ruleset to live

When you publish into a rulebook, the candidate remains in testing until an explicit, atomic promotion. Call POST /rulebooks/{rulebook_id}/rulesets/{ruleset_name}/promote-to-live with a rulebooks:write key. For a two-segment slug, use /rulebooks/{namespace}/{name}/rulesets/{ruleset_name}/promote-to-live. force_unsafe defaults to false. A non-conservative presence operator — one whose unanswered default can be more favourable than answering the field — is refused with 422 non_conservative_presence_op. Only an internal Aethis key may set force_unsafe: true; the bypass is audit-logged, and an external key is still refused. Correct the rule instead of designing an integration around the internal override. presence_polarity_advisories is null after a clean promotion. It contains field, criterion, and message entries only when the promotion succeeded despite a polarity finding: the audited internal force_unsafe path, or the advisory path for an already-published first-party showcase member. A non-null list is evidence that promotion completed with a known semantic risk, not evidence that the gate blocked it.

Review a project (Authoring Coach)

Run the versioned authoring rubric over a project and get an objective quality report — a reproducible score, per-check evidence across grounding / process / lifecycle, strengths, and the single highest-leverage next improvement. Requires an API key with projects:read + rulesets:read. Advisory only — it never blocks generation, publishing, or a decision. The deterministic report needs no LLM key. Add coach: true (with your own X-Anthropic-Key) to get an LLM-synthesised coaching narrative on top; the deterministic layer ignores the key entirely.
Response fields:
  • score (int | null) — the weighted rubric score (0–100). null on a project too thin to score.
  • data_completeness — "ok", or "thin" when the project is missing a ruleset, tests, or sources (the report degrades gracefully, never a 500).
  • checks[] — one entry per rubric check: status (pass / warn / fail / na / info), the evidence that produced it, its weight, whether it’s scored, the why it matters, the actionable_via lever that changes it, and a docs_url linking to Review checks.
  • strengths[] — what the project already does well.
  • next_skill (object | null) — the single highest-leverage, author-actionable improvement to make next.
  • coaching (string | null) — the LLM narrative, present only when coach: true.
The full rubric — every check, its weight, and its pass/warn/fail thresholds — is documented on the Review checks page.
Ambient review hints. The generate, generate-and-test, and publish responses now carry an optional review_hint — the top author-actionable warning from the same rubric, so you get a nudge in-flow without an explicit review call. It’s the same shape as next_skill (check_id, message, actionable_via, docs_url) and is null when nothing needs attention.

Error responses

422 — a value that failed validation:
422 — a top-level request key the API does not define:
The request body is strict. An undefined key is rejected rather than ignored, so a client sending an unimplemented field finds out immediately instead of believing a feature exists.
A bad field value does not produce a 422 on /decide — it comes back as a 200 with the offending key in field_errors and decision: "undetermined". 422 is reserved for a malformed request. See the truth table.
429 with rate limit headers:
The forward-visibility headers ride ordinary metered responses too — check X-RateLimit-Remaining or poll /usage to stay ahead of a 429.
Date fields take an ISO "YYYY-MM-DD" string or an integer ordinal on ruleset evaluations (ISO accepted since engine 0.31.0). Rulebook evaluations (rulebook_id) still require ordinals. See Date field values.

Full endpoint reference

All endpoints with schemas, parameter details, and example responses: API Reference →
Help improve this pageIf something here is unclear or missing an example, use the feedback button at the bottom of the page.Found a bug? Open a GitHub issue. Evaluating Aethis for a regulated workflow? Contact us directly.