Reports and controls over the API

The dashboard at https://app.ergoda.com/ covers the everyday work: deciding on steps, switching autopilot on and off and setting its limits, keys, members, alerts and billing. This page is for scripts, CI jobs and reports. It lists every other report and control as an HTTP call to https://api.ergoda.com, with the key it needs and one example.

Calling the API

  • Send a key for your site as Authorization: Bearer $ERGODA_KEY. In the examples $ERGODA_SITE is your site id, which ergoda init printed. The key ergoda init printed is an agent (ingest) key and reaches none of the calls on this page except the ones the table below gives it; make a read or full key with ergoda login --scope full for the rest. The login's browser approval names the requested scope, and the key reaches its own site only.
  • Times are ISO-8601 in UTC, such as 2026-09-01T00:00:00Z. A period is a calendar month, such as 2026-09. A since, until or any *_since / *_until that is not an ISO-8601 date or timestamp is a 422.
  • A report without since covers all the history kept for your site. When a report starts later than you asked, the X-Ergoda-Window-Since response header says where it started.
  • Every write names a person in by (or approved_by, corrected_by), and that name goes on the record.
  • $STEP_ID in the examples is a step's step_cluster_id, as GET .../policy and GET .../steps show it. A step made with ergoda init --step resolve_ticket is SITE:resolve_ticket.
  • Errors come back as {"detail": "..."}: 401 for a missing or unknown key, 403 for another site or a scope the key lacks, 404 when nothing is published yet, 422 for a write that was refused, with the reason, 413 for a body that is too large and 429 for a site that is sending too fast (see Limits).
  • Every reply carries Cache-Control: private, no-store.

Each key has one scope. Make keys on the dashboard's API keys page, which shows a new key once.

Limits

These bound what one key can ask of the shared service. The SDKs and the proxy handle every one of them without an error reaching your application; they matter to you only if you post to POST /v1/events yourself.

Limit Value Answer
Request body4 MiB413, before the body is read
Events in one batch1,000413: split the batch and send both halves
Events per site300 a second sustained, 6,000 in a burst, per server process429 with Retry-After in seconds: keep the batch and send it again after that
A site on billing hold402: keep the batch; it lands when the hold is released

A batch id (batch_id) is idempotent per site: resending a batch you sent is a no-op that answers duplicate: true, and another site using the same id makes no difference to you.

Every string field of an event is checked against its shape: a hash field takes lowercase hex of at most 64 characters, a name field at most 128 characters of letters, digits and _ . : / @ + # = -, a list field at most 16 such names. Anything else in an optional field is dropped, not refused, and the rest of the event is stored; a malformed event_id, site_id, batch_id or timestamp is a 422.

Health check

GET https://api.ergoda.com/healthz answers {"status": "ok"} and touches no database: it says the process is up, and it is safe to call every minute. For a monitor that should also notice a database outage or a schema behind the code, call GET /healthz?deep=1. It runs one query bounded to two seconds and answers 200 {"status": "ok", "db": "ok", "schema": N}, or 503 with {"status": "unavailable", "db": "error"} (or "timeout"), or {"status": "degraded", "schema": N, "expected": M} when the database is behind the code. It carries no error text and is cached for five seconds per instance.

A recipe for an uptime monitor (Better Stack, UptimeRobot or any HTTP monitor): one check on /healthz every 3 minutes, which keeps a scale-to-zero database asleep, and one on /healthz?deep=1 every 10 to 15 minutes, which wakes it; alert on two consecutive failures, and expect a short 503 with "schema" while a deploy's migration is still running. Add a heartbeat per scheduled job (ERGODA_HEARTBEAT_<JOB>) so a job that stops is noticed as well.

Scope What it reaches Give it to
ingestPOST /v1/events and GET .../policy, nothing elsea serving agent
readevery GET on this page, and POST .../backtesta CI job or a report
fullevery site API call, including site key management; plan changes still require an adminan agent operating this site's lifecycle

ergoda login --scope full is a one-time delegation: the signed-in member checks the code and scope in the browser, then the agent can read evidence, publish or revert routes, run autopilot, tune sampling, manage alerts and catalogue operations through this API. The key remains site-scoped, is attributable to the member who granted it, and can be revoked under API keys. It can create and revoke keys for that site; child keys inherit the granting member's revocation linkage. It does not grant account ownership, membership changes, payment methods, or plan changes. An owner can refuse full-access keys for agents on the Organization page; while refused, approving the login for that organization is refused with the reason (and the login is denied when no organization the approver can write to grants it), and POST /v1/sites/{site}/keys with "scope": "full" from a key answers 403.

For machine discovery, GET /v1/sites/{site}/commands returns every site-level command with its required fields, choices, and whether it writes. Run one with POST /v1/sites/{site}/commands/{slug} and a JSON body of {"values": {"field_name": "value"}}. The server supplies the key's identity for any by field and records the operation against that key. Admin-only fleet commands are not included. Nor are label and labels-import: a label is a person's verdict and can license a class toward the cheap model, so no key gives one; label in the dashboard, signed in as yourself.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/commands"
curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/commands/autopilot-apply" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"values": {}}'

Money

Usage for a month

The governed requests counted in a month, which is the number your bill is based on, with your plan and its prices.

Call: GET /v1/sites/{site}/billing/usage
Key: read or full
Query: period (required).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/billing/usage?period=2026-09"

Invoice preview

What a month's invoice comes to so far, line by line, and the last day to ask for a refund (refund_request_by). The amount charged so far inside the refund window (charged_in_window_usd) includes VAT. An enterprise account also gets its prepaid commit (commit: the amount, what has been drawn and what remains) and a line saying so.

Call: GET /v1/sites/{site}/billing/invoice-preview
Key: read or full
Query: period (required).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/billing/invoice-preview?period=2026-09"

Billing status

How your organization pays and whether it can: by invoice or by card, whether a card is on file, and whether usage is on hold and why. It also names the plan, the day of the month invoices are cut (invoice_day), any open problem with the billing account (account_problems) and, for an enterprise account, the commit and its drawdown.

Call: GET /v1/sites/{site}/billing/status
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/billing/status"

A fresh checkout link for adding a card, returned as card_url. Links expire, so ask for one when you need it. An ingest key cannot ask, and the deployment must have ERGODA_PUBLIC_URL set (503 otherwise): Checkout returns the browser there with the session id.

Call: POST /v1/sites/{site}/billing/card
Key: full
Body: none.

curl -X POST -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/billing/card"

Metering for any window

Governed requests in any window you choose, counted the way the bill counts them. Requests from our own test runs are counted apart and not billed. This report always covers the whole window you ask for. Every environment is billed; non_production lists the requests that came from outside production, when there are any.

Call: GET /v1/sites/{site}/metering
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/metering?since=2026-09-01T00:00:00Z"

Plan

Which plan your organization is on: the plan field of your settings, also on usage. Changing a plan (POST /v1/sites/{site}/plan) takes the operator's admin token, so a site key cannot do it.

Call: GET /v1/sites/{site}/settings
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/settings"

Cost per customer

What one successful task costs to serve, for each of your own customers, and what routing saved on each. A customer is the caller your agent declared on a request (see declaring a caller), and the report has only its digest (caller_sha256), never the id. ergoda customers puts your ids back on your machine.

Each row has the customer's requests, tasks (one conversation each), failed and unreported tasks, cost_usd, unpriced_requests (requests with no recorded cost and no known price: counted, and missing from cost_usd, so a row with any costs more than it shows), cost_per_success_usd (a task with no outcome signal counted as a success) and cost_per_reported_success_usd (over the tasks that reported), evidence_cost_usd (the comparison calls that checked the routing), saving_usd and frontier_cost_usd (what the same requests would have cost on your frontier model, priced as the dashboard prices its saving), routed (the share a route's cheaper model answered, with its interval) and escalation (how often a request went back to the frontier, over the requests a person's bar governed; escalation_excluded counts the ones under a bar autopilot moved).

callers come dearest cost per success first. A customer with fewer than min_tasks (20) tasks is listed after the rest and marked small_sample. Past limit customers, the rest are one row (others). Requests that named no customer are their own row (anonymous), never part of a customer's or of others. Only production traffic is counted.

Call: GET /v1/sites/{site}/customers Key: read or full Query: period, or since and until (not both); limit, 1 to 1,000 (default 50).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/customers?period=2026-10"

From a terminal, with your ids and, for the gross margin, what each customer paid you that month. Both files stay on your machine:

ergoda customers --period 2026-10 --ids customers.txt --revenue revenue.csv

customers.txt has one id per line; revenue.csv has id,revenue rows in US dollars. The margin is revenue minus cost minus the evidence cost. It leaves out Ergoda's own fee, which is capped against the whole site's saving for the month (billing) and so is not split by customer. If you set ERGODA_DIGEST_KEY for your agent, set it for this command too, or put it in the env file it reads (--env-file, default ergoda.env).

Evidence

Thresholds

Recompute every step's verdict from the evidence so far, to see why a step is or isn't ready. Changes no route. Returns 404 until a policy is published.

Call: GET /v1/sites/{site}/thresholds
Key: read or full
Query: since and until (default: the last 30 days; since= with no value reads all of it), min_n (default 100), floor (default 0.95), tolerance (default 0.01), persist (default false; true also records the run).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/thresholds"

Corroboration

How much of the measured agreement your outcome signals back up, contradict, or leave unchecked.

Call: GET /v1/sites/{site}/corroboration
Key: read or full
Query: since, until, min_n (default 100).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/corroboration?since=2026-09-01T00:00:00Z"

Commit gate

Approval, rejection, error and commit rates for each step and model in a window. With since, it also compares the window with everything before it and flags drift.

Call: GET /v1/sites/{site}/commit-gate
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/commit-gate?since=2026-09-20T00:00:00Z"

Drift

Steps whose agreement with your frontier model fell between a baseline window and a recent one. With fewer than min_n comparisons on either side, nothing is flagged.

Call: GET /v1/sites/{site}/drift
Key: read or full
Query: baseline_since, baseline_until and recent_since (required), recent_until, min_n (default 30), delta (the drop that counts, default 0.05).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/drift?baseline_since=2026-08-01T00:00:00Z&baseline_until=2026-09-01T00:00:00Z&recent_since=2026-09-01T00:00:00Z"

Escalation rate

How often steps went back to your frontier model, and why. Routes whose bar autopilot moved are reported apart and never added in.

Call: GET /v1/sites/{site}/escalation-rate
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/escalation-rate?since=2026-09-01T00:00:00Z"

False rejects

How much of your escalation was unnecessary, and which check caused it. It counts only the steps a check refused, never your traffic as a whole.

Call: GET /v1/sites/{site}/false-rejects
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/false-rejects?since=2026-09-01T00:00:00Z"

Canary

For one step, your outcome signals compared between the traffic each model served. Below min_n per group it gives no number.

Call: GET /v1/sites/{site}/canary/{step_cluster_id}
Key: read or full
Query: min_n (default 30), since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/canary/$STEP_ID"

Discovered steps

Steps nobody has routed yet, and how often the cheaper model agreed with yours on them. A reading list, not a proposal: routing one still takes a person's approval.

Call: GET /v1/sites/{site}/discovered
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/discovered"

Steps

For each step and model: volume, cost, latency and agreement with your frontier model. Production traffic only, unless environment names staging, development or eval: then the figures are that environment's alone, and every row carries "environment" and "evidence": false. Another word is a 422.

Call: GET /v1/sites/{site}/steps
Key: read or full
Query: since, until, environment.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/steps?since=2026-09-01T00:00:00Z"

Summary

The step figures above, plus how many events are stored for your site and the span they cover. The stored count is every environment's; the step figures follow environment as above.

Call: GET /v1/sites/{site}/summary
Key: read or full
Query: since, until, environment.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/summary"

Monthly review

The account review for one month, as JSON. The dashboard shows the same review.

Call: GET /v1/sites/{site}/review.json
Key: read or full
Query: period (required).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/review.json?period=2026-08"

Dashboard

Every number on the dashboard, as JSON. /dashboard without .json returns the same as one HTML page.

Call: GET /v1/sites/{site}/dashboard.json
Key: read or full
Query: since, until, canary_min_n (default 30).

step_savings is each step's saving over the window. pin_breaks lists pinned steps escalating on their pin over the 24 hours to the window's end: the step, which pin, first seen, the hashes, the saving at stake and a sentence with the fix.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/dashboard.json?since=2026-09-01T00:00:00Z"

Cost per successful task

For each routed step, what one successful task cost on the routed traffic against the tasks the cost holdout served on the frontier model only, from the outcomes you reported (task_outcome in Python, taskOutcome in TypeScript, POST /v1/task-outcomes on the proxy).

A task is one trajectory, keyed by a digest of its turns up to the first user message, so two runs that open with identical text are one task here. Its outcome is the latest report for it, whenever it arrived; conflicting_reports counts tasks reported with two different outcomes, which is a re-judgement or two runs sharing an opening. Only success and failure are known; unknown, pending (no report yet, last step within pending_hours) and missing (no report after that) are counted and shown but are in no rate and no cost, and a step's outcome flags are never read as a task's. coverage is the share of an arm's tasks with a known outcome. The cost per success has a 95% interval over tasks, so a long task counts once. comparison.status is insufficient, with reasons, when either arm has fewer than 20 priced tasks with a known outcome or no success, when under half of an arm's tasks are known, when the two arms' coverage differs by more than 15 points, or when over 5% of an arm's known tasks have an unpriced request. Production rows only. Nothing on this report licenses or loosens a route.

Call: GET /v1/sites/{site}/task-outcomes Key: read or full Query: period (YYYY-MM) or since, until; pending_hours (0 to 2160, default 72).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/task-outcomes?period=2026-10"

The dashboard's Customers page shows the same report under the per-customer table.

Model releases

Models released in the last days whose vendor serves one of the site's steps or which the site watches, each with the site's last 30 days of production token counts priced at the new model's price against what those requests cost. kind is price_arithmetic on the response and on every release: a different model writes different amounts and may need more steps, and none of that is in the figure. Requests on your own endpoint are counted in own_endpoint_requests and never compared. next_command is the local ergoda evaluate run that measures the model on your recorded work.

Call: GET /v1/sites/{site}/releases
Key: read or full
Query: days (1 to 90, default 7).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/releases?days=30"

Model retirement

What breaks when a provider retires a model, and which replacements already clear the bar on your own traffic before the shutdown date. Routes that fall back to the retiring model come first in routes, because every escalation on them fails after the date unless you publish a new escalate_to; a route whose cheap side is retiring can be disabled, which sends its step to the frontier. For each step served on the retiring model directly, steps lists every candidate already compared against it, with a state: it clears the bar, it does not, or it is still gathering comparisons. For one still gathering, the report says whether it will have enough in time at the current discovery rate, and the rate that would get there with 7 days to spare. A candidate clears the bar when the lower bound of its agreement with the retiring model clears 0.95 over at least 100 comparisons in the last 30 days. That is agreement with the model you run now, not a quality score. Production rows only. text is the same report as ergoda retirement prints it. The report moves nothing; replacing a model is a person's decision.

Call: GET /v1/sites/{site}/migration Key: read or full Query: retiring (required, the model as your policy names it, with or without its provider), shutdown (required, YYYY-MM-DD). A missing or malformed value is a 422.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/migration?retiring=openai/gpt-4o&shutdown=2027-02-16"

Or from a project ergoda login --read connected, with the read key it stored:

ergoda retirement openai/gpt-4o --shutdown 2027-02-16

Signed records

Attestation

The signed record of what was measured in a window, for an auditor or a customer who asks. It comes back as document and signature. If most of the window went unchecked, the document says so and makes no parity claim.

Call: GET /v1/sites/{site}/attestation
Key: read or full
Query: since (required), until, canary_min_n (default 30).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/attestation?since=2026-09-01T00:00:00Z" \
  > attestation.json

Decision record

One route's record at one policy version: the bar you registered for it and whether you registered it before the evidence, the baseline and candidate models, the window measured, the outcome rate in each arm with its interval against your margin, the coverage of each arm, what was left out and why, the agreement strata side by side, who approved the route, the machine moves on it, its review date, and a list of what the record cannot establish. A route nobody registered gets a record too, which says so and gives no verdict.

verdict is one of parity, worse, unresolved, interim (before your planned look plus the grace period you gave late outcomes), insufficient (with the reasons), withheld (most of the window ran under a bar autopilot loosened) or no_declared_margin.

When the bar is declared on task_failure, outcomes_by_cohort repeats the comparison for each cohort label on your task-outcome reports, with the trajectories no report labelled as their own row. A cohort with too little evidence is insufficient and is never merged into another. The cohorts you listed in the registration's cohorts are confirmatory, any other label is exploratory, and each group's intervals are Bonferroni-corrected. A cohort worse than your margin sets reading to keep_cohorts_on_current_model and is named in reading_cohorts, even when the pooled verdict is parity. The step-level outcome signals carry no cohort, so a bar declared on them has no per-cohort section. exposure gives the route's configured exposure (1 - baseline_share), the cap a registration declares on the step, and the versions in which the cap raised the share. The record is signed with the key GET /v1/policy-keys publishes, under its own tag, so it cannot pass as an attestation. The signature says Ergoda produced it; it is not a third-party certificate.

Call: GET /v1/sites/{site}/decision-record/{step} Key: read or full Query: version (default: the current policy version). {step} is a step_cluster_id or a route's label.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/decision-record/$STEP_ID" \
  > decision-record.json

Pre-register a model change

Declare the bar a model change is judged on before you look at its results: the steps, the baseline and candidate models, the outcome, the margin (the largest added rate of that outcome per trajectory you accept), the confidence, the coverage floor each arm must reach, the fewest trajectories per arm, the exposure cap, the window (analysis_since_utc) and the one planned look (look_at_utc), the review interval, and stop: alert sends an alert when the interval sits wholly above your margin. Nothing changes the route automatically; you revoke it.

outcome is one of two values. negative_outcome_signal (the default) counts a trajectory as negative when your code reported a retry, a human escalation, a correction or a tool error on any of its steps: a proxy, not task success. task_failure counts it as negative when the latest task outcome you reported for it (see Cost per successful task) is failure; unknown and no report are neither success nor failure, are counted apart, and lower the arm's coverage, and the rate is over the trajectories with a success or failure. The record then says how many reports arrived after the cut-off and how many never did. Either way the outcome is your own report, and Ergoda does not verify it.

Each call writes the next version of the series and never changes an earlier one. Changing a field the analysis uses after the window opened, or withdrawing a series and starting another for the same step, marks the record post-hoc. A registration does not approve anything: publishing a route still needs its approval.

The exposure cap is enforced, and only toward your baseline model. A policy version that raises a route on a registered step past the cap, or turns one on above it, is refused. Once an hour, a route already above the cap has its baseline_share raised to 1 - exposure_cap, which sends more of its trajectories to the baseline. You get an alert, and the decision record's window starts again at that version. Register before the window opens and this costs the window nothing.

Call: POST /v1/sites/{site}/preregistrations (and GET to list every version) Key: full to register, read or full to list Body: registration_id (lower-case letters, digits and hyphens), registered_by, and document with steps, baseline, candidate, margin (up to 0.10), coverage_floor, min_trajectories (at least 30), exposure_cap, analysis_since_utc, look_at_utc, review_every_days (1 to 90), and optionally outcome (negative_outcome_signal or task_failure, default the first), confidence (default 0.95), coverage_gap_max (default 0.05), outcome_grace_hours (default 72), stop (alert or none), cohorts (up to 20 cohort labels whose verdicts are confirmatory, read under task_failure), withdrawn, note, regression_runs (offline runs of your own regression cases, written by ergoda cases attach) and evaluations (evaluations of a candidate on your recorded states, written by ergoda evaluate attach); both are reported, never verified, and not analysis fields. ergoda qualify TRACE_FILE drafts this body offline from a trace export, with every decision left as FILL IN: ...; a body with a placeholder left is refused with 422, naming each one.

# register.json: {"registration_id": "ticket-triage", "registered_by": "Your Name",
#   "document": {"steps": ["$STEP_ID"], "baseline": "anthropic/claude-sonnet-5-5",
#   "candidate": "openai/gpt-6-luna", "margin": 0.02, "coverage_floor": 0.6,
#   "min_trajectories": 200, "exposure_cap": 0.5,
#   "analysis_since_utc": "2026-11-01T00:00:00Z", "look_at_utc": "2026-11-29T00:00:00Z",
#   "review_every_days": 30}}
curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/preregistrations" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d @register.json

Audit log

Who did what to your site: sign-ins to it, keys made and revoked, members, settings, autopilot, policy versions, account closing, recovery links, card links, and every request our operators make with the admin token. Each row names the actor (your colleague's verified address, key:<id>, admin, or system:<job>), the source network (the /24 or /48, never the full address) and a short description with no secrets in it. Oldest first, one page at a time; pass next_after back as after for the next page.

Call: GET /v1/sites/{site}/audit Key: read or full Query: since, until, after, limit (default 200, at most 1,000), action (for example key.revoke).

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/audit?since=2026-10-01"

One request's record

What we recorded about one answer, for the day someone asks which model wrote it and whether anything checked it. Pass the id you logged beside the answer: the SDK's step.request_id, or the proxy's X-Ergoda-Event header. You get the variant and model that served it, whether it escalated and why, the policy version and who published it, the route with the name of the person who approved it, every gate verdict, and every other row about the request. The answer itself never reaches us, so it is not in the record. found is false for an unknown id, another site's id, or a request older than events_kept_months; gate verdicts are kept for the life of the account.

Call: GET /v1/sites/{site}/requests/{request_id}
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/requests/$REQUEST_ID"

Or ergoda request $REQUEST_ID from a project where ergoda login ran.

Routes

Current policy

The routing policy your agents fetch right now, with its version, etag and signature. Send the etag back in If-None-Match to get 304 when nothing changed. Returns 404 until a policy is published. When we sign policies, every answer, the 304 and the 404 included, also carries an Ergoda-Policy-Fresh header: the time, the key id and an Ed25519 signature saying which etag was current then ("" for none). A pinned SDK counts an answer only when that signature is less than a day old, so a recorded reply played back later is refused.

Call: GET /v1/sites/{site}/policy
Key: ingest, read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy"

Policy history

Every version of the policy: who published it, and when.

Call: GET /v1/sites/{site}/policy/history
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy/history"

Publish a policy

Publish a policy you wrote yourself as the next version. Every route in it must name the person who approved it. A route carrying an autopilot loosening your site did not authorize, or accepting more risk on irreversible actions than your site's ceiling, is refused with 422.

Call: POST /v1/sites/{site}/policy
Key: full
Body: policy (the document, in the shape GET .../policy returns under policy) and approved_by (required), note.

# publish.json: {"policy": {...}, "approved_by": "Your Name", "note": "why"}
curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d @publish.json

Revoke a route

Send one step back to your frontier model now. Needs nobody's approval.

Call: POST /v1/sites/{site}/policy/revoke
Key: full
Body: step_cluster_id and by (required), reason.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy/revoke" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"step_cluster_id": "'"$STEP_ID"'", "by": "Your Name", "reason": "why"}'

Revert a route

Put one step back on the route it had before, as a new version.

Call: POST /v1/sites/{site}/policy/revert
Key: full
Body: step_cluster_id and by (required), note.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy/revert" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"step_cluster_id": "'"$STEP_ID"'", "by": "Your Name", "note": "why"}'

Lock a step to the frontier

Keep one step on your frontier model whoever publishes next. Its routes are disabled in the same version, and every later version that enables a route for it (by step id or by label) is refused with 422, including one published with an agent's full-scope key. Any full key may lock a step, because a lock only refuses. With two_members, unlocking takes two different people from your organization.

Only a person signed in to the dashboard can unlock a step, and no key can. Unlocking does not turn the routes back on; a route for the step needs a new publish with an approval, like any other.

Call: POST /v1/sites/{site}/policy/lock
Key: full
Body: step and by (required), note, two_members (default false).

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy/lock" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"step": "final_answer", "by": "Your Name", "two_members": true}'

Locked steps

Every locked step, who locked it and when, how many people unlocking it takes, and a first request to unlock that is waiting for a second person (it lapses after unlock_request_days).

Call: GET /v1/sites/{site}/policy/locks
Key: read or full

Retune sampling

Set how often each step is compared with your frontier model, from its traffic, and publish those rates. Changes how much is measured, never which model answers. A run that would change nothing returns "changed": false and publishes no version. It also runs weekly on its own.

Call: POST /v1/sites/{site}/policy/retune-sampling
Key: full
Body: by (required), since, until.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/policy/retune-sampling" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"by": "Your Name"}'

Writes

Write tools and what needs you

Every write tool on each routed step, with its state (validated, acknowledged, changed, needs_decision), who acknowledged it and when, its calls and newest call in the last 30 days, gone for a declared tool with no calls, its share of the step's bill (bill_share) and the share that would be freed if it were handed over (freed_share, null where the rows carry no cost). tool is the name where your route declares one, else "" with the digest the SDK derives from the name; ergoda writes fills in names from your own catalogue. todo counts the rows that need a person.

Call: GET /v1/sites/{site}/writes
Key: read or full

Acknowledge a write, or re-confirm

Acknowledges tool's writes on step with no validator, or re-signs the step's acknowledgement where one already covers it. A re-confirmation covers every tool the acknowledgement names. tool may be "*" for every tool on the step. It hands nothing over by itself. by is recorded with the key that asserted it, and a by containing "(asserted via" is refused. The reply's signed_tools lists every tool the signature now covers.

Call: POST /v1/sites/{site}/writes/acknowledge
Key: full
Body: step, tool and by (required), decision_class, note.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/writes/acknowledge" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"step": "support.cancel", "tool": "cancel_order", "by": "Your Name"}'

Mark a tool validated

Adds tool to the step's validated_tools. Until your process passes a validator for it, every call to it goes to your frontier.

Call: POST /v1/sites/{site}/writes/validated
Key: full
Body: step, tool and by (required), decision_class.

Withdraw an acknowledgement

Takes tool out of the step's acknowledgement. This moves toward your frontier, so no approval is needed. What it covered goes back to the frontier in the same version.

Call: POST /v1/sites/{site}/writes/withdraw
Key: full
Body: step, tool and by (required), reason.

Backtest

Backtest a set of routes

Score routes you have not run on the production traffic you already served: the policy in force with some routes changed, or a whole policy. It writes nothing and licenses nothing. The reply carries the candidate as a draft that publishing refuses until a person signs each route it changed.

Call: POST /v1/sites/{site}/backtest Key: read or full (the one POST a read key may make) Body: changes (a list of at most 50) or policy (a whole document of at most 500 routes, checked as publishing checks it), not both; since and until (the last 30 days by default, at most 90).

A change names a step_cluster_id and any of enabled; serve, one of the models the policy already names and its allowlist permits (a backtest never widens the allowlist, because a wider one moves every route serving what it admits); and commit_confidence_threshold, from 0 to 1, or null for none. A change for a step with no route adds one and needs enabled: true, serve and escalate_to. irreversible_alpha, max_irreversible_commits and graduated_classes are refused, in a change or in a whole policy, and so are irreversible_licence and graduation_licence in a whole policy: they are licensed from evidence whose supported rate Ergoda never quotes, and trying values against your own rows would quote it.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/backtest" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"changes": [{"step_cluster_id": "'"$STEP_ID"'", "enabled": false}]}'

The reply:

  • verdict: measured; unavailable, with a reason that says what to switch on (the policy's exploration while the traffic was served, and outcomes reported against the request); or insufficient_data below 100 randomised decisions with an outcome.
  • current and candidate: the estimated share of requests with no bad outcome (snips), its interval (ci), how many decisions it really rests on (effective_n), the largest weight, and trustworthy. Do not quote an estimate that is not trustworthy.
  • coverage: the requests in the window, how many were randomised, and how many of those had an outcome.
  • unanswerable: each step the estimate cannot speak for, with why. A step no route serves today, a model a step never ran on in the window, and a lower commit bar are never estimated: the commits a lower bar would have let through were redone by your frontier, so no outcome of them exists. A higher bar is counted (the commits it would have sent to your frontier), not estimated. A changed bar keeps the route's confidence weights, declared by whoever signs it, because a bar is compared against their sum; a whole policy that changes the weights is named here and not priced.
  • agreement: for a step no route serves, how often the model you named agreed with yours on discovery comparisons. Agreement, not quality.
  • restored: each route the candidate moves to another model or switches on or off while it holds something a machine moved on the model it serves now (a commit bar, an irreversible budget, graduated decision kinds), with a sentence saying so. That evidence does not describe another model, so the draft puts back what the route's owner last declared. A route that was never published with a declared bar cannot be moved here.
  • changes and cost: per changed route, what the window would have cost more (positive) or less, priced as the dashboard prices savings, and how; unpriced_steps names what could not be priced.
  • draft: the candidate document, with unsigned_routes naming the routes whose approval record is empty: every route the candidate changed, whatever approval a whole policy carried on it. Fill in a name and time on each, then publish it.

At most 30 backtests an hour per site, then 429 with Retry-After. A window holding more than 100,000 randomised decisions, or more than 100,000 commits below a raised bar, is a 422: narrow it. A backtest that runs past 20 seconds is a 503.

Autopilot

Autopilot plan

What autopilot would move toward the cheaper model now, and why the rest stays. Changes nothing. It also says whether autopilot is on for your site and under whose limits. Returns 404 until a policy is published.

Call: GET /v1/sites/{site}/autopilot
Key: read or full
Query: since, until.

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/autopilot"

Run autopilot now

Apply that plan now instead of at autopilot's next daily run. A move is published under the name that authorized autopilot (ergoda:default unless your site set limits of its own); by records who ran it.

Call: POST /v1/sites/{site}/autopilot/run
Key: full
Body: by (required), since, until, note.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/autopilot/run" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"by": "Your Name"}'

Switch autopilot off, or back on

The same switch as the dashboard's. Off means every move toward the cheaper model waits for a named person's approval; moves already made stay until someone reverts them. See autopilot.

Call: POST /v1/sites/{site}/autopilot
Key: full
Body: by (required) and one of: "enabled": false to switch off, "default": true to go back to the default limits, or alpha and delta (and optionally trajectory_alpha) to set limits of your own under your name. note is optional.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/autopilot" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"by": "Your Name", "enabled": false}'

Tool catalogue

Submit your tools

Send your agent's tool definitions, in any provider's format, and get back which tools Ergoda treats as recoverable, which it withholds, and which it could not tell. No model is called. This is the one call that sends content (your tool names and descriptions), so it needs a name on it, and withdrawing deletes it.

Call: POST /v1/sites/{site}/catalogue
Key: full
Body: tools and submitted_by (required), decision_tools (the tools your route already declares as decisions).

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/catalogue" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"submitted_by": "Your Name", "tools": [{"name": "lookup_order", "description": "Read one order by id", "input_schema": {"type": "object"}}]}'

Read the catalogue

The tools your agent declared, how Ergoda treats each one, and your corrections. Returns 404 if nothing was submitted.

Call: GET /v1/sites/{site}/catalogue
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/catalogue"

Correct a tool

Tell Ergoda what one of your tools really does, for example that its action can be undone. Marking a tool recoverable is the only way a withheld tool becomes routable, so a correction carries your name.

Call: POST /v1/sites/{site}/catalogue/corrections
Key: full
Body: tool, verdict (recoverable or irreversible) and corrected_by (required), note.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/catalogue/corrections" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"tool": "save_draft", "verdict": "recoverable", "corrected_by": "Your Name"}'

Withdraw the catalogue

Delete every catalogue your site submitted. Your corrections are kept, so the next submission does not ask the same question again.

Call: DELETE /v1/sites/{site}/catalogue
Key: full

curl -X DELETE -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/catalogue"

Shared evidence

Priors

How your steps compare with evidence pooled across organizations. You see your own position and the pooled figures, never another organization's.

Call: GET /v1/sites/{site}/priors
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/priors"

Reciprocity

What your organization adds to the shared evidence, and what it gets back.

Call: GET /v1/sites/{site}/reciprocity
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/reciprocity"

Settings

Read settings

Your plan, whether your evidence is in the shared pool, autopilot's state and limits, the most risk any route may accept on irreversible actions, and your alert channel. The alert signing secret is never returned; alerts_secret_set says whether one exists. A read key sees the webhook as its host only (https://hooks.slack.com/…), because a chat webhook's path is its secret; a full key sees the whole address.

Call: GET /v1/sites/{site}/settings
Key: read or full

curl -H "Authorization: Bearer $ERGODA_KEY" \
  "https://api.ergoda.com/v1/sites/$ERGODA_SITE/settings"

Opt out of shared evidence

Keep your evidence out of the shared pool, or put it back.

Call: POST /v1/sites/{site}/settings
Key: full
Body: priors_opt_out (required, true or false), note.

curl -X POST "https://api.ergoda.com/v1/sites/$ERGODA_SITE/settings" \
  -H "Authorization: Bearer $ERGODA_KEY" -H "Content-Type: application/json" \
  -d '{"priors_opt_out": true, "note": "why"}'

Keys

Make and revoke keys on the dashboard's API keys page or with a full-scope site key. GET /v1/sites/{site}/keys, POST /v1/sites/{site}/keys, and POST /v1/sites/{site}/keys/{key_id}/revoke are restricted to that site's full key or the operator's admin token. New key material is returned once. Keys created by a full key inherit the member who granted that key, so removing the member revokes the keys created under their grant.

What runs for you

Ergoda runs these for every site; there is nothing to call and nothing to schedule.

What When Where you see it
Routes whose evidence fell below their bar are revokedDailyThe route's state on the dashboard
Decision kinds whose audits now disagree are taken backHourlyThe route's state on the dashboard
Alert conditions are checked and sentHourlyYour alert webhook, or email to the owner without one
A registered stop condition is checkedHourlyAn alert; the route is not changed
Models whose behaviour shifted on steps nobody reconfiguredAs it happensAn alert
Pinned routes a deploy broke: the prompt or tool catalogue changed under the pinHourlyAn alert, and the step on Evidence
The monthly reviewOnce the month closesYour alert channel

Cost per end customer, backtesting a different set of routes and what breaks when a model is retired each have their own section above (Cost per customer, Backtest a set of routes, Model retirement).

A third party can check an attestation (GET /v1/sites/{site}/attestation, above) without trusting us. Its ed25519 block is an Ed25519 signature over the document, made with the key published at GET /v1/policy-keys and on Security and data. In Python, with ergoda[verify] installed:

from ergoda.signing import parse_public_keys, verify_attestation

keys, _ = parse_public_keys("<public key from the security page>")
assert verify_attestation(record, keys) is None  # else: why it does not verify

The signature field beside it is an HMAC under our own secret, so only the control plane can check it.