Skip to Content

API

Base path /api/v1; the OpenAPI document is served at /api/v1/openapi.json and the interactive browser at /api/v1/docs. Program routes live under /programs/{program_id}/... (constant PROGRAM_PREFIX in app/routers/_scope.py), organisation routes under /orgs/current/..., personal routes under /me/..., and three routes sit outside every scope: the inbound webhook gateway, the launch resolver and the agent tool server. 206 routes are pinned in api/scripts/designed-routes.json (206 rows; EXPECTED_ROUTE_COUNT = 206 in api/tests/route_matrix.py:1198, asserted against the file and the mounted app by api/tests/test_rbac_matrix.py; verified 2026-09-16). The console, Evidence, Plans and Review routes carry the status word preview since 2026-09-15 (contract 11.6); Progress and the R3 twin routes stay designed until the R3 gates pass. Host api.trovensa.com; local http://localhost:8100.

Phase 2 added 38 routes (117 -> 155): console threads, messages, feedback, stream, export and share; agent-run tool calls, interrupts, snapshot and interrupt resolution; artifacts and promote; documents and chunks; SOP versions and the requirement-set decision; progress agent runs; agent memory; audit export for a program and for the organisation; signatures; agent capabilities; connector objects; notifications and personal settings; the webhook gateway; the launch resolver; the tool server.

Phase 3 wave 1 adds the two public site routes (155 -> 157, contract section 11.3): the contact-form inquiry and the aggregate site-event counter, both unauthenticated, mounted as router public before legacy, with small body caps (8 KB and 2 KB) and a cross-origin allowance for the site origin only.

Phase 3 R3 adds 32 routes (157 -> 189, contract 11.4 and 11.7, Part 7 section 7.4): the persona inbox, the daily brief and delegations under /me; twins, their triggers and schedules, hand-offs, the program board and decision metrics under a program; and the twin activity export for the organisation. They are listed below in router order (inbox after me_settings; twins, handoffs, board, metrics after progress; the export with audit_export).

Phase 4 (R4) adds 17 routes (189 -> 206, an R4 target re-derived on release; contract 12.7 and ADR 0032): the workbench role catalogue, the role home aggregate and the lead’s assignment; personal pins; document assets, the signed blob and asset links, gather and the third-party flag; personal memory reads and forgets and the promote route; the skill catalogue and workbench-started skill runs. Every one is designed until the R4 gates pass. The six R4 routers (me_memory, document_assets, workbench, pins, skills, skill_runs) are mounted from ROUTERS in api/app/main.py and EXPECTED_ROUTE_COUNT reads 206 (verified 2026-09-16). Two existing routes gain a parameter without a new row: GET /programs/{program_id}/documents takes a repeated ids (at most 200), and PATCH /me/settings clears a workbench role with null; GET /me carries the person’s settings (see the rows below). The stage-gate routes reserved by 11.4 for R4 move to R5 (+7, with lens reviews +3, role memory +6 and propose-share +1 -> 223); R6 adds curated entry, connection grants and organisation twins (-> 228).

Conventions

  • Every list route returns Page[T] (items[], total, limit, offset) with limit 1 to 200 (default 50) and offset from 0.
  • Every *Out carries id, created_at, updated_at; scoped rows carry organization_id, program_id, stage_id; agent-capable rows (open_items, experiments, sources, deviations, checks, and the findings, hypotheses, gaps, protocols and reports of phase 1) carry origin, agent_run_id, illustrative.
  • Exactly one require_permission(...) per route or one documented access rule (the RBAC matrix test fails on any unlisted mounted route). Health routes are public; /me, /me/notifications, /me/settings, /permissions, /personas, /providers and /launch/{launch_id} need only a valid token; /hooks/... needs the provider’s signature; /agent-tools/mcp needs a run token.
  • Audit rule: every mutation writes an audit row in the same transaction, carrying actor_kind, reason where required, interrupt_id, proposal_hash and approved_hash for agent writes; entry_seq, row_hash and created_at are set by the database; the application role can only insert and read audit and signature rows. Low-value mutations are audited too: notification.read, message.feedback, user.settings_update, thread.redact, thread.share, launch.consume, document.ingest, document.purge.
  • Thread rule: for any agent run with a thread_id, events, tool calls, interrupts, snapshot and artifacts are returned only to a principal who may read the thread (owner, shared, or program.threads.read); program.agent_runs.read alone lists such runs as metadata (id, kind, status, timings, agent version). route_matrix.py carries the rule per row; a viewer-principal test pins it.
  • Bound-user rule (R3): for any interrupt on a run with twin_id, the twin is checked before the thread rule. write_approval and batch_approval are resolvable by the bound person or an active delegate who holds the item’s write permission; plan_approval by the bound person only; anyone else gets 403 bound_user_only. POST /me/inbox/{item_id}/resolve applies the same rule and is authorised by item ownership or an active delegation, never by thread access.
  • Party rule (R3): hand-off routes are scoped to the party they belong to: withdraw to the sender, accept and decline to the addressee or a current holder of the addressed persona, complete to the person who accepted. Each rule has a test.
  • Request bodies are capped by BodySizeLimitMiddleware; protocols, reports and SOPs get the larger document limit; the webhook gateway caps at 1 MB.
  • Success codes: 201 on create, 202 when work is queued (sync, agent-runs, checks, invocations, hooks), 204 on delete, otherwise 200. GET .../threads/{thread_id}/stream answers 200 text/event-stream and stays open.

Errors: problem+json

Errors are RFC 7807 documents with media type application/problem+json. type is https://console.trovensa.com/errors/<slug> (for example .../forbidden), and every document carries the request_id that the X-Request-ID response header also exposes. A 403 adds permission; a 422 adds errors[] with loc, msg, type.

{ "type": "https://console.trovensa.com/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "this action requires permission evidence.hypotheses.approve", "request_id": "6f1c0d0e-illustrative", "permission": "evidence.hypotheses.approve" }

Without a token every non-health route answers 401 with a WWW-Authenticate: Bearer challenge. Phase 2 adds these public codes (the code field beside type):

CodeStatusWhen
gate_expiredrun failurean approval interrupt passed its expires_at; Resume opens a new interrupt
policy_deniedrun failurea tool outside the role allowlist, or access lost during the run (live re-validation)
plan_violationrun failurea tool outside the approved plan
config_mismatchrun failurethe container’s model id, inference profile or region differ from the active agent version’s model configuration
runtime_updating409a release is quiescing the interactive lane; retry after the rollout
reauth_required401a decision route received no usable re-authentication token (fail closed)
record_signed409update attempted on a signed protocol, report or requirement set; create a new version instead
sop_external409POST .../review/sops/{sop_id}/approve on a connector SOP; the QMS is authoritative
rate_limited429 or run failurea rate budget is exhausted; the rail shows “budget exhausted for source X, retry after N s”
rate_limited_public429 with Retry-AfterPOST /public/inquiries over 5 per hour or 20 per day from one salted address hash, or 3 per day from one e-mail
consent_required422POST /public/inquiries without consent: true
honeypot422POST /public/inquiries with the website field filled
too_fast422POST /public/inquiries with started_at under 3 s old
stale_proposal409the linked record changed since the twin proposed (basis_hash mismatch); the response names the changed fields and the card reads “Changed since proposed”
bound_user_only403an interrupt on a twin run resolved by someone other than the bound person (or, for write approvals, an active delegate holding the write’s permission)
batch_item_forbiddenper item in InterruptResolveResulta batch item whose ToolSpec.permission the resolver lacks; the item is failed, the siblings proceed
twin_active_exists409a second active twin for the same person in the same program
twin_paused409an action that needs an active twin while it is paused
duty_unavailable422a duty template naming an action outside TRIGGERABLE_ACTIONS, or a duty whose permission the person lacks
handoff_exists409a hand-off of the same kind to the same persona on the same record is already sent or accepted
self_handoff409the sender would be the addressee
delegation_invalid422self-delegation, a delegate without program membership, more than 30 days, or a chained routing
baseline_missing409enabling a twin on a partner program before its baseline row exists in CLAIMS-REGISTER.md
workbench_role_not_held422R4: a workbench role whose personas do not intersect the caller’s, or a program outside the caller’s memberships (shown beside the picker)
kind_not_promotable422R4: “Keep across programs” on an episodic or lesson memory record (shown beside the Keep control)
third_party_content422R4: “Share to board” on a document flagged as third-party content inside an upload
unit_required422R4: a skill input without a unit (shown beside the unit field)
harness_mismatch401R4: a run token whose harness claim differs from agent_runs.harness_key
harness_deniedtool refusalR4: a tool outside the run’s harness toolset; audited agent_run.harness_denied and shown in the message feed as the existing question item
reason_required422R4: DELETE /programs/{program_id}/agent-memory/{record_id} of another person’s record without a reason (MemoryForget {reason?})
not_tier1409R4: “Keep across programs” on a record that is not a kept tier-1 record
kind_not_admitted422 and tool refusalR4: a memory kind the tier does not admit
injection_refusedtool refusalR4: a proposed memory note that reads like instructions
members_only403R4: a third-party-suspected document read by a non-member
licence_locator_only403 on the two signed-link routes; 422 on gatherR4: a blob or asset link, or a gather, for a licence code that allows neither (gather is accepted for cc0, cc_by, cc_by_sa only)
unit_unsupported, unit_mismatch, invalid_input422 and tool refusalR4: skill inputs; each carries field for the inline error beside the input
run_not_found401R4: tools/list for a run whose row is missing; fixed in contract 12.5, not in source on 2026-09-16 (the server still falls back to the token’s claim; api-foundation raises the refusal)

The one exception to problem+json is POST /agent-tools/mcp, which speaks JSON-RPC and returns JSON-RPC errors.

Identity, re-authentication and run tokens

Production tokens are RS256 from the identity provider with issuer and audience pinned by AUTH_ISSUER, AUTH_AUDIENCE and AUTH_JWKS_URL. Development and tests use HS256 tokens with header kid: dev signed with DEV_TOKEN_SECRET; they are accepted only when APP_ENV is dev or test, and validate_deployed_secrets() refuses to start a deployed environment that still carries the placeholder secret. Mint one with:

uv run python scripts/dev_token.py --user owner@example.com --org trovensa-demo --roles org_owner,program_owner

Three more token shapes exist in phase 2:

  • Re-authentication tokens (reauth_token in ApproveRequest and RequirementSetDecision) go through the same issuer, audience and JWKS path and must carry oid and tid equal to the session principal and an auth_time within 300 s; the uti hash is stored with a unique index so a token signs once. A token without auth_time fails closed with reauth_required. Development mode: scripts/dev_token.py --reauth mints a 300 s token with auth_time and a jti.
  • Run tokens (POST /agent-tools/mcp) are RS256 with kid = run-<n>, audience trovensa-agent-tools, sub = agent:<agent_key>@<org_id>, role, allowlist_hash, act = <user id>, run_id, program_id, exp <= 60 min; the worker mints six per run (console, evidence, plans, review, progress, reader) with a worker-only private key and the API verifies with the public key set (app-run-token-jwks). verify_token branches on the run- prefix. R4 (designed) adds a harness claim copied from agent_runs.harness_key at mint time: the server refuses 401 harness_mismatch when the claim differs from the row, and tools/list and check_allowed read the row, never the claim (harnesses). No allow-list travels in a token and the allow-list hash is unchanged.
  • Launch ids (GET /launch/{launch_id}) are one-time ids minted by the webhook gateway with a 10-minute expiry; the caller still authenticates as a person.

Console stream

GET /programs/{program_id}/threads/{thread_id}/stream is text/event-stream. Each event carries id: <seq> and one StreamEvent {seq, run_id, kind, ts, payload} with kind in state, text_delta, citation, tool_call_proposed, tool_call_result, interrupt, artifact_delta, message_complete, run_finished, keepalive. The API tails the durable agent_events table with LISTEN agent_events (one connection per replica, 500 ms poll fallback) and writes a keepalive every 15 s, under the load balancer’s 120 s idle timeout. Browsers cannot send Authorization with EventSource, so the web app uses fetch streaming and resumes from Last-Event-ID = seq after a disconnect. Event payloads never contain retrieved source text; citations reference document_chunks.id.

Routes

206 designed routes, grouped by router in mount order (ROUTERS in app/main.py, verified 2026-09-16: health, me, me_settings, me_memory (R4), inbox (R3), permissions, personas, providers, orgs, programs, signatures, audit_export, connections, agent_runs, threads, artifacts, documents, document_assets (R4), evidence, plans, review, progress, twins, handoffs, board, metrics (R3), workbench, pins, skills, skill_runs (R4), notifications, launch, hooks, agent_tools, public, legacy). PATCH /me/settings is listed with me, /connector-objects with connections, /agent-capabilities and /agent-memory with agent_runs, and the twin activity export with audit_export. The legacy router (410 responses for retired paths) is mounted last and is empty. A note after a row states the extra rule the permission alone does not express. Routes marked R3 are in designed-routes.json and mount with the twins release. Routes marked R4 are the 17 of contract 12.7; their routers me_memory, document_assets, workbench, pins, skills and skill_runs are mounted and answer designed until the R4 gates pass.

Router workbench-roles (1, R4)

GET/workbench-rolesany signed-in userWorkbenchRoleOut[] (15 at WORKBENCH_ROLE_VERSION = 1); the catalogue, not the caller’s choice

Router skills (2, R4)

GET/skillsany signed-in userSkillOut[] with references[{key, version, retrieved_on, status}], status and release per skill
GET/skills/{skill_key}any signed-in userSkillOut

Router me_memory (2, R4)

GET/me/memoryany signed-in user?program_id=; the caller’s own tier 1 in that program and tier 2; MemoryRecordOut[]
DELETE/me/memory/{record_id}204any signed-in userown record only; audited memory.forget; a tombstone by source_ref prevents re-derivation

Router health (2)

GET/healthpublic, no token
GET/health/readypublic, no token

Router me (2)

GET/meany signed-in userMeOut {user, organization, org_permissions, programs}; user.settings: UserSettings (R4, contract 12.8) carries workbench_role, the twin settings, the shortcuts and the illustrative flag so the web reads them from the identity call. Fact (verified in api/app/schemas/identity.py:70-74): MeOut.user carries settings: UserSettings, and other people’s UserOut rows never do | Consequence: built; the web reads the workbench role from the identity call
PATCH/me/settingsany signed-in userUserSettingsUpdate {console_run_mode?, shortcuts?, workbench_role?}; workbench_role (R4) is {<program_id>: <workbench_role_key> | null} validated against the caller’s memberships and personas (422 workbench_role_not_held), audited workbench_role.set; null removes the program’s entry (audited with new: null) and the role home returns to the persona home (contract 12.3, 2026-09-16). Fact (verified in api/app/schemas/console.py:269 and api/app/routers/me_settings.py:50-57): the schema types the map as dict[uuid.UUID, str | None] and the router pops the program’s entry for a null value, recording the change as cleared | Consequence: clearing is built; the five twin-era keys (quiet_hours, digest_time, timezone, inbox_shortcuts, illustrative) are written server-side and are not accepted here; audited user.settings_update

Router inbox (9, R3)

GET/me/inboxany signed-in user“Decisions waiting for you” across every program the person belongs to; Page[InboxItemOut] grouped by the web into signatures, approvals, questions, hand-offs, mentions, information
GET/me/inbox/{item_id}any signed-in userown items, or items routed to the caller under an active delegation
POST/me/inbox/{item_id}/resolveany signed-in useritem ownership or an active delegation, then the write’s own permission and the bound-user rule; InboxResolve is InterruptResolve (decision or items[]); returns InterruptResolveResult; the path the inbox, the brief e-mail link and the phone use
POST/me/inbox/{item_id}/snoozeany signed-in userInboxSnooze {until}; audited inbox.snooze
POST/me/inbox/{item_id}/feedbackany signed-in userInboxFeedback {feedback: helpful | not_useful, note?}; evaluation data for twin_evaluate; audited inbox.feedback
GET/me/briefany signed-in userPersonaBriefOut: counts and items per program, grouped signatures-or-not; the same content the daily digest e-mail links to
GET/me/delegationsany signed-in userdelegations from and to the caller
POST/me/delegations201any signed-in userDelegationCreate {delegate_user_id, program_id?, starts_at?, ends_at, reason}; at most 30 days, no self, one hop, delegate must hold membership; 422 delegation_invalid; audited delegation.create
DELETE/me/delegations/{delegation_id}204any signed-in userrevokes; audited delegation.revoke

Router permissions (1)

GET/permissionsany signed-in user

Router personas (1)

GET/personasany signed-in user

Router providers (1)

GET/providersany signed-in user

Router orgs (14)

GET/orgs/currentorg.settings.read
PATCH/orgs/currentorg.settings.update
GET/orgs/current/programsorg.programs.read
POST/orgs/current/programs201org.programs.create
PATCH/orgs/current/programs/{program_id}org.programs.manage
GET/orgs/current/usersorg.users.read
GET/orgs/current/groupsorg.groups.read
POST/orgs/current/groups201org.groups.manage
PATCH/orgs/current/groups/{group_id}org.groups.manage
DELETE/orgs/current/groups/{group_id}204org.groups.manage
GET/orgs/current/role-assignmentsorg.roles.read
POST/orgs/current/role-assignments201org.roles.manage
DELETE/orgs/current/role-assignments/{assignment_id}204org.roles.manage
GET/orgs/current/auditorg.audit.read

Router programs (7)

GET/programs/{program_id}program.settings.read
PATCH/programs/{program_id}program.settings.update
GET/programs/{program_id}/membersprogram.members.read
GET/programs/{program_id}/role-assignmentsprogram.roles.read
POST/programs/{program_id}/role-assignments201program.roles.manage
DELETE/programs/{program_id}/role-assignments/{assignment_id}204program.roles.manage
GET/programs/{program_id}/auditprogram.audit.read

Router signatures (1)

GET/programs/{program_id}/signatures/{signature_id}program.audit.readSignatureOut.valid recomputed from the current record hash

Router audit_export (2)

GET/programs/{program_id}/audit/exportprogram.audit.readAuditExportQuery {from?, to?, format}; CSV or JSON with entry_seq and row_hash
GET/orgs/current/audit/exportorg.audit.readsame shape, every program
GET/orgs/current/twins/exportorg.audit.readR3; TwinActivityExportQuery; every twin, its duties, triggers and runs, and per proposal the metadata columns only (tool key, target type and id, proposal_hash, approved_hash, decision, interrupt_id, batch_id), never tool_calls.arguments; the one place per-person decision rows appear, labelled “per-person throughput, org audit export”

Router connections (7)

GET/programs/{program_id}/connectionsprogram.connections.read
POST/programs/{program_id}/connections201program.connections.manage
PATCH/programs/{program_id}/connections/{connection_id}program.connections.manage
DELETE/programs/{program_id}/connections/{connection_id}204program.connections.manage
POST/programs/{program_id}/connections/{connection_id}/sync202program.connections.sync
GET/programs/{program_id}/connections/{connection_id}/runsprogram.connections.read
GET/programs/{program_id}/connector-objectsprogram.connections.read

Router agent_runs (11)

GET/programs/{program_id}/agent-runsprogram.agent_runs.read
GET/programs/{program_id}/agent-runs/{run_id}program.agent_runs.read
GET/programs/{program_id}/agent-runs/{run_id}/eventsprogram.agent_runs.read
POST/programs/{program_id}/agent-runs/{run_id}/cancelprogram.agent_runs.manage
GET/programs/{program_id}/agent-runs/{run_id}/tool-callsprogram.agent_runs.readand the thread rule when thread_id is set
GET/programs/{program_id}/agent-runs/{run_id}/interruptsprogram.agent_runs.readand the thread rule when thread_id is set
GET/programs/{program_id}/agent-runs/{run_id}/snapshotprogram.agent_runs.readand the thread rule; RunSnapshotOut.model_check
POST/programs/{program_id}/agent-runs/{run_id}/interrupts/{interrupt_id}/resolveprogram.threads.useplus the write’s own permission, recorded as permission_exercised, and the bound-user rule on twin runs; InterruptResolve {decision?, edits?, reason?, items?[]} (items[] required for batch_approval, refused otherwise); returns InterruptResolveResult for a batch
GET/programs/{program_id}/agent-memoryprogram.agent_memory.readdesigned; R4 gives it a surface: ?user_id= and ?tier= filters, another person’s tier 1 only under program.twins.read, audited memory.oversight_read and shown to the person (“Viewed by <Name> on <date>.”); tier 2 is never returned for another person
DELETE/programs/{program_id}/agent-memory/{record_id}204program.agent_memory.managean owner or lead with a reason; audited memory.forget in R4 (memory.delete before)
POST/programs/{program_id}/agent-memory/{record_id}/promoteprogram.agent_memory.manageR4; own record only; MemoryPromote {} -> MemoryPromoteOut {record, scrubbed_terms_count}; preference | vocabulary | rubric_adjustment only, else 422 kind_not_promotable; audited memory.promote
GET/programs/{program_id}/agent-capabilitiesprogram.agent_runs.readcontext of use, limitations, risk class

AgentRunOut gains harness_key?, harness_version?, rubric_version? and ToolCallOut gains check_logic_version?, inputs_hash?, result_artifact_id?, output_violations in R4.

Router workbench (2, R4)

GET/programs/{program_id}/workbenchprogram.settings.readWorkbenchOut: the role home aggregate (decisions with role record types first, other_decision_count, questions, pins, status_lines[{text, record_ref, as_of}], sources[{provider, status, verified_on?}], twin, memory_summary of at most 3, set_by?); RecordRef {record_type, record_id, href}
PUT/programs/{program_id}/workbench-roles/{user_id}program.members.manageWorkbenchRoleAssign {workbench_role_key, reason} -> WorkbenchRoleAssignmentOut; writes the member’s settings.workbench_role[program_id]; 422 workbench_role_not_held; audited workbench_role.assign (actor the lead, target the member)

Router pins (3, R4)

GET/programs/{program_id}/pinsprogram.settings.readthe caller’s own pins plus the program board; PinOut[]
POST/programs/{program_id}/pins201program.settings.readplus the own_pin rule; PinIn {target_type: chunk | asset | record, target_id, note? (<= 300), board: false} -> PinOut (record_type? and href? present for record pins so the board can open the record; contract 12.3, 2026-09-16. Fact (verified in api/app/schemas/workbench.py:92-93): both fields exist, None unless the pin is a record pin | Consequence: built); board: true (“Share to board”) needs program.documents.manage, copies the pin, re-checks the licence and the third-party flag and refuses 422 third_party_content; audited pin.create or pin.share
DELETE/programs/{program_id}/pins/{pin_id}204program.settings.readown pin (own_pin), or program.documents.manage for a board pin; audited pin.delete

Router threads (10)

GET/programs/{program_id}/threadsprogram.threads.read_ownown and shared threads; program.threads.read lists every thread
POST/programs/{program_id}/threads201program.threads.useprivate by default
GET/programs/{program_id}/threads/{thread_id}program.threads.read_ownthread rule
PATCH/programs/{program_id}/threads/{thread_id}program.threads.manageowner, or manage with a reason; ThreadUpdate {title?, status?}
POST/programs/{program_id}/threads/{thread_id}/shareprogram.threads.useowner only; ThreadShare.confirm_counts must name the messages and artifacts that become visible
GET/programs/{program_id}/threads/{thread_id}/messagesprogram.threads.read_ownthread rule
POST/programs/{program_id}/threads/{thread_id}/messages201program.threads.usecreates the console_turn run; the state event is written in the same transaction
POST/programs/{program_id}/threads/{thread_id}/messages/{message_id}/feedbackprogram.threads.read_ownthread rule; audited message.feedback
GET/programs/{program_id}/threads/{thread_id}/streamprogram.threads.read_ownthread rule; text/event-stream, id: <seq>, 15 s keepalive, resumes from Last-Event-ID
GET/programs/{program_id}/threads/{thread_id}/exportprogram.threads.manageowner, or manage with a reason; carries the provenance footer

Router artifacts (3)

GET/programs/{program_id}/artifactsprogram.threads.read_ownthread rule
GET/programs/{program_id}/artifacts/{artifact_id}program.threads.read_ownthread rule; versions share a lineage_id
POST/programs/{program_id}/artifacts/{artifact_id}/promoteprogram.threads.useplus the target record’s permission; yields a tool_calls proposal, never a direct write

Router documents (4)

GET/programs/{program_id}/documentsprogram.documents.readR4 adds the repeated query parameter ids (at most 200 document ids; no new row) so citation markers read integrity_status by id instead of paging the program’s documents (contract 12.7, 2026-09-16). Fact (verified in api/app/routers/documents.py:40-49): the router takes ids, repeated or comma-separated, and passes the parsed list to the service | Consequence: the filter is built
POST/programs/{program_id}/documents201program.documents.manageDocumentIngest {identifier_scheme, identifier_value}; no URL field
GET/programs/{program_id}/documents/{document_id}program.documents.read
GET/programs/{program_id}/documents/{document_id}/chunksprogram.documents.readchunk text is licence-gated; R4 adds asset_id? and row_index? to DocumentChunkOut

DocumentOut gains licence_statement?, licence_verified_on?, pmc_prefix?, third_party_suspected, third_party_ref?, integrity_status (ok | corrected | retracted), integrity_ref? and asset_count in R4.

Router document_assets (5, R4)

GET/programs/{program_id}/documents/{document_id}/assetsprogram.documents.readDocumentAssetOut[] {id, document_id, kind, ordinal, label, page?, row_count?, sha256? (only when mirrored), licence_code, chip_class, status, source_href?, integrity_status, created_at, updated_at}; no caption text (captions reach the model only through the reader tool documents.read_asset)
GET/programs/{program_id}/documents/{document_id}/assets/{asset_id}302program.documents.reada 5-minute signed link over the program’s own copy; members only when third_party_suspected; audited document.blob_read on every 302; figures are served only through this route, never as public objects
GET/programs/{program_id}/documents/{document_id}/blob302program.documents.readthe document’s own file, same rule and audit
POST/programs/{program_id}/documents/{document_id}/gather202program.documents.manageenqueues the deterministic documents_gather job (no agent run); answers the pending successor DocumentOut; audited document.gather. Accepted only for cc0, cc_by and cc_by_sa; every other code, customer_internal included, is refused 422 licence_locator_only, and a third-party-suspected document 422 third_party_content, checked first (contract 12.3, 2026-09-16). Fact (verified in api/app/services/documents_assets.py:441-446): request_gather refuses third_party_content first, then licence_locator_only for any code outside the redistributable set, then document_not_ready | Consequence: both refusals are built
PATCH/programs/{program_id}/documents/{document_id}/third-party-flagprogram.documents.manageThirdPartyFlagIn {third_party_suspected, reason} -> DocumentOut; audited document.third_party_flag

Router evidence (22)

GET/programs/{program_id}/evidence/questionsevidence.questions.read
POST/programs/{program_id}/evidence/questions201evidence.questions.manage
GET/programs/{program_id}/evidence/questions/{question_id}evidence.questions.read
PATCH/programs/{program_id}/evidence/questions/{question_id}evidence.questions.manage
DELETE/programs/{program_id}/evidence/questions/{question_id}204evidence.questions.manage
GET/programs/{program_id}/evidence/sourcesevidence.sources.read
POST/programs/{program_id}/evidence/sources201evidence.sources.manage
GET/programs/{program_id}/evidence/sources/{source_id}evidence.sources.read
PATCH/programs/{program_id}/evidence/sources/{source_id}evidence.sources.manage
POST/programs/{program_id}/evidence/sources/{source_id}/links201evidence.sources.link
GET/programs/{program_id}/evidence/findingsevidence.findings.read
POST/programs/{program_id}/evidence/findings201evidence.findings.manage
GET/programs/{program_id}/evidence/findings/{finding_id}evidence.findings.read
PATCH/programs/{program_id}/evidence/findings/{finding_id}evidence.findings.manage
GET/programs/{program_id}/evidence/hypothesesevidence.hypotheses.read
POST/programs/{program_id}/evidence/hypotheses201evidence.hypotheses.manage
GET/programs/{program_id}/evidence/hypotheses/{hypothesis_id}evidence.hypotheses.read
PATCH/programs/{program_id}/evidence/hypotheses/{hypothesis_id}evidence.hypotheses.manage
POST/programs/{program_id}/evidence/hypotheses/{hypothesis_id}/decisionevidence.hypotheses.approve
GET/programs/{program_id}/evidence/gapsevidence.gaps.read
PATCH/programs/{program_id}/evidence/gaps/{gap_id}evidence.gaps.manage
POST/programs/{program_id}/evidence/agent-runs202evidence.agents.use

Router plans (26)

GET/programs/{program_id}/plans/plansplans.plans.read
POST/programs/{program_id}/plans/plans201plans.plans.manage
GET/programs/{program_id}/plans/plans/{plan_id}plans.plans.read
PATCH/programs/{program_id}/plans/plans/{plan_id}plans.plans.manage
POST/programs/{program_id}/plans/plans/{plan_id}/transitionplans.plans.manage
POST/programs/{program_id}/plans/plans/{plan_id}/approveplans.plans.approve
GET/programs/{program_id}/plans/protocolsplans.protocols.read
POST/programs/{program_id}/plans/protocols201plans.protocols.manage
GET/programs/{program_id}/plans/protocols/{protocol_id}plans.protocols.read
PATCH/programs/{program_id}/plans/protocols/{protocol_id}plans.protocols.manage
POST/programs/{program_id}/plans/protocols/{protocol_id}/transitionplans.protocols.manage
POST/programs/{program_id}/plans/protocols/{protocol_id}/approveplans.protocols.approve
GET/programs/{program_id}/plans/experimentsplans.experiments.read
POST/programs/{program_id}/plans/experiments201plans.experiments.manage
GET/programs/{program_id}/plans/experiments/{experiment_id}plans.experiments.read
PATCH/programs/{program_id}/plans/experiments/{experiment_id}plans.experiments.manage
GET/programs/{program_id}/plans/reportsplans.reports.read
POST/programs/{program_id}/plans/reports201plans.reports.manage
GET/programs/{program_id}/plans/reports/{report_id}plans.reports.read
PATCH/programs/{program_id}/plans/reports/{report_id}plans.reports.manage
POST/programs/{program_id}/plans/reports/{report_id}/transitionplans.reports.manage
POST/programs/{program_id}/plans/reports/{report_id}/approveplans.reports.approve
GET/programs/{program_id}/plans/toolsplans.tools.read
POST/programs/{program_id}/plans/tools/{tool_id}/invocations202plans.tools.use
GET/programs/{program_id}/plans/tools/invocations/{invocation_id}plans.tools.read
POST/programs/{program_id}/plans/agent-runs202plans.agents.use

Router review (19)

GET/programs/{program_id}/review/sopsreview.sops.read
POST/programs/{program_id}/review/sops201review.sops.manage
GET/programs/{program_id}/review/sops/{sop_id}review.sops.read
PATCH/programs/{program_id}/review/sops/{sop_id}review.sops.manage
POST/programs/{program_id}/review/sops/{sop_id}/approvereview.sops.approve
GET/programs/{program_id}/review/checksreview.checks.read
POST/programs/{program_id}/review/checks202review.agents.use
GET/programs/{program_id}/review/checks/{check_id}review.checks.read
GET/programs/{program_id}/review/findingsreview.findings.read
GET/programs/{program_id}/review/findings/{finding_id}review.findings.read
PATCH/programs/{program_id}/review/findings/{finding_id}review.findings.manage
POST/programs/{program_id}/review/findings/{finding_id}/decisionreview.findings.approve
GET/programs/{program_id}/review/deviationsreview.deviations.read
POST/programs/{program_id}/review/deviations201review.deviations.manage
GET/programs/{program_id}/review/deviations/{deviation_id}review.deviations.read
PATCH/programs/{program_id}/review/deviations/{deviation_id}review.deviations.manage
GET/programs/{program_id}/review/sops/{sop_id}/versionsreview.sops.read
GET/programs/{program_id}/review/sop-versions/{version_id}review.sops.read
POST/programs/{program_id}/review/sop-versions/{version_id}/requirements/decisionreview.sops.approvedecision signature required; RequirementSetDecision

Router progress (17)

GET/programs/{program_id}/progress/overviewprogress.milestones.read
GET/programs/{program_id}/progress/milestonesprogress.milestones.read
POST/programs/{program_id}/progress/milestones201progress.milestones.manage
GET/programs/{program_id}/progress/milestones/{milestone_id}progress.milestones.read
PATCH/programs/{program_id}/progress/milestones/{milestone_id}progress.milestones.manage
DELETE/programs/{program_id}/progress/milestones/{milestone_id}204progress.milestones.manage
GET/programs/{program_id}/progress/dependenciesprogress.dependencies.read
POST/programs/{program_id}/progress/dependencies201progress.dependencies.manage
DELETE/programs/{program_id}/progress/dependencies/{dependency_id}204progress.dependencies.manage
GET/programs/{program_id}/progress/open-itemsprogress.open_items.read
POST/programs/{program_id}/progress/open-items201progress.open_items.manage
PATCH/programs/{program_id}/progress/open-items/{item_id}progress.open_items.manage
GET/programs/{program_id}/progress/approvalsprogress.approvals.read
POST/programs/{program_id}/progress/approvals201progress.approvals.manage
POST/programs/{program_id}/progress/approvals/{approval_id}/withdrawprogress.approvals.manage
POST/programs/{program_id}/progress/approvals/{approval_id}/decisionprogress.approvals.approve
POST/programs/{program_id}/progress/agent-runs202progress.agents.useProgressAgentRunCreate {kind: progress_propose}; Progress moves to preview at R3 exit

Router skill_runs (1, R4)

POST/programs/{program_id}/skill-runsevidence.agents.usethe skill’s own <product>.agents.use, checked per skill (any of the four); SkillRunIn {skill_key, inputs, citations?} -> SkillResultOut synchronously, stored as a skill_result artifact with run_id NULL; 422 unit_required for a value without a unit; audited skill_run.start

Router twins (13, R3)

GET/programs/{program_id}/twinsprogram.twins.read_ownown twins; program.twins.read lists every twin of the program
POST/programs/{program_id}/twins201program.twins.useTwinCreate {persona_key, duties?, expiry_hours?, digest_time?, batch_times?, budget_tool_calls_month?}; the person’s own act, never on someone’s behalf; one active twin per person and program (409 twin_active_exists); 409 baseline_missing on a partner program without a baseline row; enabling a duty enqueues its standing_plan cycle; audited twin.create
GET/programs/{program_id}/twins/{twin_id}program.twins.read_ownown, or any under program.twins.read; TwinOut with activity (the transparency counts)
PATCH/programs/{program_id}/twins/{twin_id}program.twins.useTwinUpdate; a changed duty invalidates its standing plan and re-asks; audited twin.update, twin.duty_enable, twin.duty_disable
POST/programs/{program_id}/twins/{twin_id}/pauseprogram.twins.useown; program.twins.manage pauses anyone’s twin with TwinPause {reason} required; audited twin.pause
POST/programs/{program_id}/twins/{twin_id}/resumeprogram.twins.useown; refuses while the standing plan is missing or invalid; audited twin.resume
POST/programs/{program_id}/twins/{twin_id}/run-now202program.twins.useown; enqueues one twin_cycle with initiated_by = user; 409 twin_paused; audited twin.enqueue
GET/programs/{program_id}/twins/{twin_id}/triggersprogram.twins.read_ownPage[TwinTriggerOut]
POST/programs/{program_id}/twins/{twin_id}/triggers201program.twins.useTwinTriggerCreate {duty_key, kind, event_action?, spec?, min_interval_seconds?, priority?}; event_action must be in TRIGGERABLE_ACTIONS (422 duty_unavailable)
PATCH/programs/{program_id}/twins/{twin_id}/triggers/{trigger_id}program.twins.useTwinTriggerUpdate {spec?, min_interval_seconds?, priority?, enabled?}
DELETE/programs/{program_id}/twins/{twin_id}/triggers/{trigger_id}204program.twins.use
GET/programs/{program_id}/twins/{twin_id}/scheduleprogram.twins.read_ownTwinScheduleOut {rrule, next_run_at, last_run_at, locked_at}
PUT/programs/{program_id}/twins/{twin_id}/scheduleprogram.twins.useTwinSchedulePut {trigger_id, rrule}

Router handoffs (7, R3)

GET/programs/{program_id}/handoffsprogram.handoffs.read
POST/programs/{program_id}/handoffs201program.handoffs.manageHandoffCreate {to_persona_key, to_user_id?, kind, linked_type, linked_id, message_md}; created sent; 409 handoff_exists, 409 self_handoff; one inbox item per holder of the addressed persona; a twin’s console.propose_handoff reaches this row only when the sender approves; audited handoff.send
GET/programs/{program_id}/handoffs/{handoff_id}program.handoffs.read
POST/programs/{program_id}/handoffs/{handoff_id}/acceptprogram.handoffs.manageaddressee or a current persona holder; the first accept sets to_user_id, withdraws the sibling inbox items and opens a program-visible thread pinned to the record; fires the receiving twin’s twin_handoff; audited handoff.accept
POST/programs/{program_id}/handoffs/{handoff_id}/declineprogram.handoffs.manageaddressee or persona holder; HandoffDecision {reason}; audited handoff.decline
POST/programs/{program_id}/handoffs/{handoff_id}/completeprogram.handoffs.managethe person who accepted; audited handoff.complete
POST/programs/{program_id}/handoffs/{handoff_id}/withdrawprogram.handoffs.managethe sender; audited handoff.withdraw

Router board (1, R3)

GET/programs/{program_id}/boardBoardOut {columns[{persona_key, label, cards[]}]}: a read over inbox_items joined to records, filtered by what the caller may read; cards are records, never messages; a card from a private thread reads “From a private thread”; the proposing person’s name appears only under program.twins.read, otherwise “a scientist’s twin (agent)” with the persona chip; the key the router exercises is fixed when it lands (Part 7 names none; every program persona holds program.twins.read_own and program.handoffs.read)

Router metrics (1, R3)

GET/programs/{program_id}/metrics/decisionsprogram.audit.readDecisionMetricsOut {by_persona[], by_user[]?}; by_user[] is included only when the caller holds program.twins.read (viewer-principal test); counts come from records and are labelled as such

Router notifications (2)

GET/me/notificationsany signed-in user
POST/me/notifications/{notification_id}/readany signed-in useraudited notification.read

Router launch (1)

GET/launch/{launch_id}any signed-in usersingle use, 10-minute expiry, program.threads.use on the resolved program; audited launch.consume

Router hooks (1)

POST/hooks/{provider_id}/{connection_id}202webhook signatureno bearer; body cap 1 MB; verified before parsing; answers in under 2 s

Router agent_tools (1)

POST/agent-tools/mcprun token (kid run-<n>)stateless MCP tool server; JSON-RPC errors, the one exception to problem+json

Router public (2)

POST/public/inquiries202public, no tokenJSON or form-encoded, body cap 8 KB; consent true, website empty, started_at at least 3 s old, role a persona key, stage a stage id, topic in preview, validation, general, at most two links in message; 5 per hour and 20 per day per salted address hash (kept 30 days), 3 per day per e-mail, 429 with Retry-After; returns {id, received_at}; a form-encoded post answers 303 to the site’s /contact/sent; audit inquiry.create with actor_kind = system; rows deleted after 24 months by the worker job purge_inquiries
POST/public/events204public, no tokenbody cap 2 KB; {path, referrer_host, viewport_class, event, props} with event in page_view, cta_click, docs_exit, contact_submit; increments site_event_counts by day, path, event and props hash; the address is rate-checked in memory and never stored; no cookie, no identifier, no audit row per event (the one deliberate exception, ADR 0026)

Both public routes accept cross-origin calls from the site origin only (public_cors_origin); every other route refuses them.

Providers

GET /providers returns the connection providers of app/integrations/registry.py. Contract v2 retires the generic eln id and adds two named providers whose docs pages are titled by category; every provider keeps its release status until the R1 gates pass.

idnamecategorycredential classeventswrite policystatus
literature_searchLiterature searchliteratureplatform keysnone (polling and on-demand)read onlydesigned
benchlingELN connectorelnservice (client credentials)signed webhooks (ecdsa_jwks), launchable canvasread_onlydesigned
veeva_qualitydocsQMS/SOP connectordocumentsservice (integration user)none (15-minute delta poll, nightly reconciliation)read_onlydesigned
document_libraryDocument librarydocumentsoauth2noneread onlydesigned
analysis_toolsAnalytical and design toolstoolsapi_keynoneread onlydesigned

All fetching of external content runs in the API or the worker, never in the agent container; the connectors reference describes each adapter.

Agent runs

Phase 1 kinds stay callable: POST .../evidence/agent-runs (evidence_compare, evidence_gaps, evidence_hypotheses with a question_id), POST .../plans/agent-runs (plans_draft_protocol, plans_draft_report with a plan_id) and POST .../review/checks (review_check, now bound to an SOP version through CheckCreate.sop_version_id or the automatic rule). Phase 2 adds console_turn (one per message in a thread, mode = interactive), progress_propose (POST .../progress/agent-runs), review_extract_requirements, review_sop_impact and review_check_batch (enqueued by the worker when a new SOP version becomes effective). R3 adds twin_cycle (a duty’s pass under its standing plan, enqueued by the schedule job, an event trigger, a mention click or POST .../twins/{twin_id}/run-now) and twin_handoff (preparation after handoff.accept), both mode = background with requested_by the bound person, initiated_by, priority = 10 (after user-requested runs) and twin_id. Every run starts with a plan artifact (twin runs with the approved standing plan seeded, never re-asked); every write is a tool_calls proposal a person approves with the write’s own permission; the run rail reads GET .../agent-runs/{run_id}/tool-calls, /interrupts and /snapshot under the thread rule. Public error codes: the seven of phase 1 plus gate_expired, policy_denied, plan_violation, config_mismatch, runtime_updating, rate_limited. R4 (designed) adds no run kind: every run wears one of the 20 harness keys recorded in agent_runs.harness_key (or none for a viewer), skills run inside a turn as tool calls or synchronously through POST .../skill-runs with no run row, and the artifact kinds become 12 with skill_result (harnesses, skills).

Source: api/scripts/designed-routes.json (206 rows, PRODUCT-CONTRACT.md 12.7), api/tests/route_matrix.py, api/app/main.py, api/app/routers/_scope.py, api/app/routers/health.py, api/app/routers/me.py, api/app/routers/permissions.py, api/app/routers/personas.py, api/app/routers/providers.py, api/app/routers/orgs.py, api/app/routers/programs.py, api/app/routers/connections.py, api/app/routers/agent_runs.py, api/app/routers/threads.py, api/app/routers/artifacts.py, api/app/routers/documents.py, api/app/routers/evidence.py, api/app/routers/plans.py, api/app/routers/review.py, api/app/routers/progress.py, api/app/routers/notifications.py, api/app/routers/launch.py, api/app/routers/hooks.py, api/app/routers/agent_tools_mcp.py, api/app/routers/signatures.py, api/app/routers/audit_export.py, api/app/errors.py, api/app/config.py, api/app/auth/jwt.py, api/app/auth/run_tokens.py, api/app/services/signatures.py, api/app/db/models/agent.py, api/app/db/models/twin.py, api/app/db/models/inbox.py, api/app/db/models/handoff.py, api/app/db/models/delegation.py, api/app/services/interrupts.py, web/lib/api/types.ts, web/lib/api/endpoints.ts, PRODUCT-CONTRACT.md sections 10.3, 10.7, 11.3, 11.4 and 11.7, PRODUCT-PLAN.md Part 4 sections 4.8 and 7.3, Part 7 sections 4, 5 and 7.4

Last updated on