Agent docs

Contract: the module surface

The HTTP surface every Foundry module exposes.

The HTTP surface every Foundry module exposes. This is the normative part: a module that exposes these routes with these shapes, and behaves as canon/03 and canon/04 describe behind them, is conforming. Routes marked required are the floor; standard routes are expected unless the manifest says the module does not do that; optional routes are named so that modules that offer them offer them the same way.

The shapes below are derived from versable-runner's surface (src/services-api/app/api.py, docs/runner-service.md), which is the most complete instance, with the tenant, caller, correlation, callback, and manifest additions the canon requires. Where a versable-runner route differs, the difference is noted, so upgrading it is a checklist.

Audience: anyone building a module or writing a client for one.

Conventions#

  • JSON in, JSON out, UTF-8. Errors are {error: {code, message, details?}}. A details entry is ALWAYS {path, message}, whatever the code. path names the field in the document being judged: a request field for VALIDATION (params.template), and a manifest field for UNAVAILABLE (capabilities, identity.verifier), since the manifest is the document a conformance gap judges. The fix belongs in message, which is where an env var name goes. Elsewhere details: [gaps] names what the entries are ABOUT and not their shape; one key carries one shape with an HTTP status that means what it says (400 bad request, 401 no or bad credential, 403 credential ok but not allowed, 404, 409 conflict, 422 validation, 429 over a ceiling, 5xx module fault). code is a closed set, one per status: BAD_REQUEST 400, UNAUTHENTICATED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, VALIDATION 422, CEILING 429, UNAVAILABLE 503, INTERNAL 500; a module never invents an envelope code (item-level codes live in the manifest's error_types, not here).
  • Every request except health and the manifest carries Authorization: Bearer <credential> and, for job routes, a tenant (X-Tenant-Id header or tenant in the body; header wins). canon/02.
  • Every list is paginated: ?limit= (default and max stated in the manifest) plus ?offset= or ?cursor=, and the response carries the page, a total where cheap, and next_offset or next_cursor.
  • Ids are opaque strings. job_id is the module's; client_job_id and item_id are the caller's and are always echoed back.
  • Timestamps are RFC 3339 UTC.
  • Versioning: the manifest states the module version and the contract version it conforms to. Breaking changes to a route bump the path prefix (/v2/jobs); additive changes do not. canon/11.

Routes#

RouteTierScopeWhat it does
GET /healthrequirednoneliveness, 200 {status: "ok"}; on Cloud Run do not name it /healthz (edge-reserved)
GET /health/deeprequiredstats:readreadiness: round-trips storage and queue, echoes the effective non-secret config, reports the verifier in use
GET /build-inforequirednone{commit, built_at, branch?, name, version, contract_version}; snake_case, one shape for modules and apps (versable-builder's app templates emit it since ba70a11; speedway's camelCase is the older witness); unauthenticated on purpose so a deploy can be confirmed without credentials
GET /manifestrequirednonethe machine-readable capability manifest (contracts/manifest.md): capabilities, param and item schemas, outcome schemas, error and review codes, ceilings, supported settings; carries an ETag and honours If-None-Match with 304, so an app that caches it refreshes with a cheap poll. A 200 from this route means a conforming manifest: a module that cannot conform in its environment (no payload registered, a verifier it does not implement) answers 503 {error: {code: "UNAVAILABLE", message, details: [gaps]}}, each gap naming its own fix, rather than serving a promise an app would build against
GET /guideoptionalnonethe manifest rendered as plain text for a person or an LLM agent; versable-runner serves this at /usage, which is a lapse because /usage/events is the usage route; /usage may alias /guide for one release
POST /jobsrequiredjobs:submitcreate a job; O(1) in items; returns 202 {job_id, state: "accepted"}
GET /jobsrequiredjobs:readlist jobs for the tenant, newest first; filters: state, capability, created_after, created_before, updated_after, attribution.<key>=<value> (repeatable, ANDed), client_job_id; response {jobs: [status rows with counts and updated_at], count, total, offset, limit, next_offset, facets} where facets carries live counts per value for state, capability, and every attribution key the manifest marks facetable, computed under the applied filter. This route is a reconciliation and debugging surface: an app's jobs page lists the app's own jobs (its names, its stage across capabilities, its files, its owner) joined to module state per row; nobody builds a browsed list on GET /jobs (vb-fable's shape check, 2026-08-18)
GET /jobs/{job_id}requiredjobs:readstate + counts + envelope summary
GET /jobs/{job_id}/envelopestandardjobs:readthe job envelope as stored, verbatim, minus items; named envelope so manifest means only the module's capability manifest (versable-runner serves this at /jobs/{job_id}/manifest, a lapse)
GET /jobs/{job_id}/outcomesrequiredjobs:readpaginated outcomes; filters type=result, error, needs_review, or skipped, reason, error_type, item_id (repeatable) and item_id_prefix, so a review queue pages by reason and a person finds one item without loading the job; only the page is loaded
GET /jobs/{job_id}/outcomes/{item_id}requiredjobs:readone item's outcome
GET /jobs/{job_id}/outcomes/{item_id}/logrequiredjobs:readthe per-item log: every attempt's structured lines, paginated (canon/07)
GET /usage/eventsstandardstats:readpaginated usage events for the tenant, filters job_id, from, to, meter (contracts/usage-event.md)
GET /jobs/{job_id}/errorsstandardjobs:readthe error outcomes, paginated; convenience over outcomes?type=error
GET /jobs/{job_id}/reviewstandardjobs:readthe needs_review outcomes, paginated; ?group_by=reason returns per-reason counts with a sample outcome each (canon/10)
GET /jobs/{job_id}/outputsstandardjobs:readthe transforms this capability offers over the job's outcomes and any materializations, with high-water marks (canon/15)
GET /jobs/{job_id}/outputs/{name}standardjobs:readcompute or serve one transform: paged rows, or a signed artifact URL; ?materialize=true, ?format= per manifest; carries computed_at, settled, pending
GET /jobs/{job_id}/statsstandardstats:readtiming, throughput, ETA while running, error breakdown by code with samples, review breakdown by reason, usage rollup, cost
POST /jobs/{job_id}/cancelrequiredjobs:cancelrequest cancel; 202; idempotent
GET /statsstandardstats:readmodule-wide for the tenant: jobs by state, items, per-capability breakdown, running jobs; created_after/_before; ?usage=true for cost rollup
POST /internal/tasks/{unit}required if queuedplatform identity onlythe queue's callback routes; never reachable with a caller credential (canon/02)

Differences from versable-runner today: /results becomes /outcomes with a type filter and a per-item route; /review is new; /manifest and /build-info are new; /jobs/run-file (multi-step run files) is a capability of the content module, not a contract route; meta becomes attribution; every job route gains the tenant.

Shapes#

Create a job#

POST /jobs
Authorization: Bearer …
X-Tenant-Id: t_speedway
Content-Type: application/json
{
"capability": "content.generate",
"client_job_id": "wr_456/stage-7", optional; idempotent create per caller
"params": { … validated against manifest.capabilities[capability].params … },
"items": [
{ "item_id": "sku-1001", "input": { … } }, item_id optional; positional if absent
],
"parent_job_id": "job_…", "items_from_parent": ["sku-1001", "sku-1003"], optional: a child job over a subset of a
parent's items (resolution, re-run, debug); items
may be omitted when items_from_parent is set
"attribution": { "user": { "id": "u_123", "label": "A. Chopra" }, "workflow_run": "wr_456" }, optional, claimed; `user` has the reserved shape {id, label}
"variants": { "research": "self" }, optional, one option per declared dimension; absent means each dimension's default
"settings": { optional, manifest says which are allowed
"max_attempts": 3,
"item_timeout_s": 900,
"callback_url": "https://app.example/hooks/foundry",
"callback_events": ["job.state", "item.outcome"]
}
}
202
{ "job_id": "job_01H…", "state": "accepted", "client_job_id": "wr_456/stage-7", "created_at": "…" }
  • Unknown capability, invalid params, or invalid items fail fast with 422 and a details array naming each problem. Nothing is persisted.
  • A repeated client_job_id from the same caller returns 200 with the existing job.
  • Item count above the manifest's max_items is 422; a per-caller or per-tenant ceiling is 429 with Retry-After.

Job status#

GET /jobs/{job_id}
200
{
"job_id": "…", "client_job_id": "…", "capability": "…",
"state": "running",
"counts": { "total": 5000, "pending": 3210, "running": 20, "succeeded": 1700,
"errored": 40, "needs_review": 30, "cancelled": 0, "skipped": 0 },
"tenant": "t_speedway", "caller_id": "app_console_prod", "env": "prod",
"attribution": { … }, "parent_job_id": null,
"created_at": "…", "started_at": "…", "updated_at": "…", "finished_at": null
}

Outcomes#

GET /jobs/{job_id}/outcomes?type=needs_review&limit=100&offset=0
200
{
"outcomes": [
{ "item_id": "sku-1001", "type": "result",
"output": { … }, "confidence": 0.93, "judged_by": "self", "evidence": { … },
"variant": { "research": "extractor" }, which declared option ran
"reference_versions": [ { "name": "pcdb", "version": "2026-07" } ],
"attempts": 1, "duration_ms": 8120, "usage_ref": ["u_…", "u_…"], "finished_at": "…" },
{ "item_id": "sku-1002", "type": "error",
"error_type": "RESEARCH_QUALITY_LOW", "message": "…", "retryable": false,
"attempts": 3, "duration_ms": 25400, "usage_ref": ["u_…", "u_…", "u_…"], "finished_at": "…" },
{ "item_id": "sku-1003", "type": "needs_review",
"reason": "AMBIGUOUS_PART_TYPE", "confidence": 0.41, "judged_by": "judge.parttype", "evidence": { … },
"output": { … partial … }, "attempts": 1, … },
{ "item_id": "sku-1004", "type": "skipped", "reason": "cancelled", … }
],
"count": 30, "total": 30, "offset": 0, "limit": 100, "next_offset": null
}

error_type and reason are stable codes listed in the manifest, so an app can build UI and rules against them without parsing messages.

variant and reference_versions are conditional, not part of every outcome. A capability that declares variants in its manifest carries variant on each outcome, naming the option that actually ran for that item, because a job may fall back per item (on_missing: fallback:<dimension>:<option>) and a reader comparing two outcomes otherwise cannot tell which path produced which. A capability that declares reference_data carries reference_versions, the snapshot it loaded, so an outcome stays explainable after the reference data moves. A capability declaring neither omits both fields entirely; declaring one and omitting its field is a conformance failure, not an optional extra.

Callback events#

POST {callback_url}
X-Foundry-Signature: sha256=… HMAC over the body with the caller's callback secret
X-Foundry-Event-Id: evt_… idempotency key for the receiver
Content-Type: application/json
{ "event": "job.state", "job_id": "…", "client_job_id": "…", "tenant": "…",
"state": "completed_with_review", "counts": { … }, "occurred_at": "…" }
{ "event": "item.outcome", "job_id": "…", "client_job_id": "…", "tenant": "…",
"outcomes": [ … up to N … ], "occurred_at": "…" }

At-least-once, retried with backoff, dead-lettered with a count when it cannot be delivered. canon/03, "Learning that something changed".

Errors#

422
{ "error": { "code": "VALIDATION", "message": "2 problems",
"details": [ { "path": "params.template", "message": "required" },
{ "path": "items[3].input.brand", "message": "must be a string" } ] } }
429
Retry-After: 30
{ "error": { "code": "CEILING", "message": "caller ceiling of 2 concurrent jobs reached" } }

Conformance checklist#

A module can score itself against this list; contracts/conformance.md will carry the smoke suite that exercises it.

  • every route above marked required exists and behaves as described
  • every job route rejects a missing or wrong tenant, and a credential from another environment
  • POST /jobs with 5,000 items returns in under a second and the job is accepted or expanding immediately after
  • a repeated client_job_id returns the existing job
  • every list is paginated and states its max page size in the manifest
  • every outcome carries item_id, type, attempts, duration_ms, usage_ref; every result and needs_review carries confidence and judged_by
  • error_type and reason values are all listed in the manifest
  • cancel on a running job ends in cancelled with in-flight items settled and unstarted items skipped
  • a callback is delivered at least once and carries a valid signature
  • /health/deep fails when storage or queue is unreachable
  • /build-info reports the running commit without a credential
  • the internal routes reject a caller credential
  • the module starts with zero env in the in-process mode and refuses to start with the local verifier on a public URL

Banned#

  • A shared credential across callers on any route.
  • A job route that works without a tenant.
  • An unpaginated list.
  • A running state before expansion.
  • A /results shape that requires downloading every outcome to read one.
  • A callback without a signature or an event id.
  • Secret values in /health/deep's config echo.
@versable-git/ui · reference, canon, and method, read in place