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) withlimit1 to 200 (default 50) andoffsetfrom 0. - Every
*Outcarriesid,created_at,updated_at; scoped rows carryorganization_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) carryorigin,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,/providersand/launch/{launch_id}need only a valid token;/hooks/...needs the provider’s signature;/agent-tools/mcpneeds a run token. - Audit rule: every mutation writes an audit row in the same transaction, carrying
actor_kind,reasonwhere required,interrupt_id,proposal_hashandapproved_hashfor agent writes;entry_seq,row_hashandcreated_atare 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, orprogram.threads.read);program.agent_runs.readalone lists such runs as metadata (id, kind, status, timings, agent version).route_matrix.pycarries 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_approvalandbatch_approvalare resolvable by the bound person or an active delegate who holds the item’s write permission;plan_approvalby the bound person only; anyone else gets403 bound_user_only.POST /me/inbox/{item_id}/resolveapplies 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:
withdrawto the sender,acceptanddeclineto the addressee or a current holder of the addressed persona,completeto 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:
201on create,202when work is queued (sync,agent-runs,checks,invocations,hooks),204on delete, otherwise200.GET .../threads/{thread_id}/streamanswers200 text/event-streamand 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):
| Code | Status | When |
|---|---|---|
gate_expired | run failure | an approval interrupt passed its expires_at; Resume opens a new interrupt |
policy_denied | run failure | a tool outside the role allowlist, or access lost during the run (live re-validation) |
plan_violation | run failure | a tool outside the approved plan |
config_mismatch | run failure | the container’s model id, inference profile or region differ from the active agent version’s model configuration |
runtime_updating | 409 | a release is quiescing the interactive lane; retry after the rollout |
reauth_required | 401 | a decision route received no usable re-authentication token (fail closed) |
record_signed | 409 | update attempted on a signed protocol, report or requirement set; create a new version instead |
sop_external | 409 | POST .../review/sops/{sop_id}/approve on a connector SOP; the QMS is authoritative |
rate_limited | 429 or run failure | a rate budget is exhausted; the rail shows “budget exhausted for source X, retry after N s” |
rate_limited_public | 429 with Retry-After | POST /public/inquiries over 5 per hour or 20 per day from one salted address hash, or 3 per day from one e-mail |
consent_required | 422 | POST /public/inquiries without consent: true |
honeypot | 422 | POST /public/inquiries with the website field filled |
too_fast | 422 | POST /public/inquiries with started_at under 3 s old |
stale_proposal | 409 | the 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_only | 403 | an 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_forbidden | per item in InterruptResolveResult | a batch item whose ToolSpec.permission the resolver lacks; the item is failed, the siblings proceed |
twin_active_exists | 409 | a second active twin for the same person in the same program |
twin_paused | 409 | an action that needs an active twin while it is paused |
duty_unavailable | 422 | a duty template naming an action outside TRIGGERABLE_ACTIONS, or a duty whose permission the person lacks |
handoff_exists | 409 | a hand-off of the same kind to the same persona on the same record is already sent or accepted |
self_handoff | 409 | the sender would be the addressee |
delegation_invalid | 422 | self-delegation, a delegate without program membership, more than 30 days, or a chained routing |
baseline_missing | 409 | enabling a twin on a partner program before its baseline row exists in CLAIMS-REGISTER.md |
workbench_role_not_held | 422 | R4: 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_promotable | 422 | R4: “Keep across programs” on an episodic or lesson memory record (shown beside the Keep control) |
third_party_content | 422 | R4: “Share to board” on a document flagged as third-party content inside an upload |
unit_required | 422 | R4: a skill input without a unit (shown beside the unit field) |
harness_mismatch | 401 | R4: a run token whose harness claim differs from agent_runs.harness_key |
harness_denied | tool refusal | R4: 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_required | 422 | R4: DELETE /programs/{program_id}/agent-memory/{record_id} of another person’s record without a reason (MemoryForget {reason?}) |
not_tier1 | 409 | R4: “Keep across programs” on a record that is not a kept tier-1 record |
kind_not_admitted | 422 and tool refusal | R4: a memory kind the tier does not admit |
injection_refused | tool refusal | R4: a proposed memory note that reads like instructions |
members_only | 403 | R4: a third-party-suspected document read by a non-member |
licence_locator_only | 403 on the two signed-link routes; 422 on gather | R4: 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_input | 422 and tool refusal | R4: skill inputs; each carries field for the inline error beside the input |
run_not_found | 401 | R4: 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_ownerThree more token shapes exist in phase 2:
- Re-authentication tokens (
reauth_tokeninApproveRequestandRequirementSetDecision) go through the same issuer, audience and JWKS path and must carryoidandtidequal to the session principal and anauth_timewithin 300 s; theutihash is stored with a unique index so a token signs once. A token withoutauth_timefails closed withreauth_required. Development mode:scripts/dev_token.py --reauthmints a 300 s token withauth_timeand ajti. - Run tokens (
POST /agent-tools/mcp) are RS256 withkid = run-<n>, audiencetrovensa-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_tokenbranches on therun-prefix. R4 (designed) adds aharnessclaim copied fromagent_runs.harness_keyat mint time: the server refuses401 harness_mismatchwhen the claim differs from the row, andtools/listandcheck_allowedread 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)
/workbench-rolesany signed-in userWorkbenchRoleOut[] (15 at WORKBENCH_ROLE_VERSION = 1); the catalogue, not the caller’s choiceRouter skills (2, R4)
/skillsany signed-in userSkillOut[] with references[{key, version, retrieved_on, status}], status and release per skill/skills/{skill_key}any signed-in userSkillOutRouter me_memory (2, R4)
/me/memoryany signed-in user?program_id=; the caller’s own tier 1 in that program and tier 2; MemoryRecordOut[]/me/memory/{record_id}204any signed-in userown record only; audited memory.forget; a tombstone by source_ref prevents re-derivationRouter health (2)
/healthpublic, no token/health/readypublic, no tokenRouter me (2)
/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/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_updateRouter inbox (9, R3)
/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/me/inbox/{item_id}any signed-in userown items, or items routed to the caller under an active delegation/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/me/inbox/{item_id}/snoozeany signed-in userInboxSnooze {until}; audited inbox.snooze/me/inbox/{item_id}/feedbackany signed-in userInboxFeedback {feedback: helpful | not_useful, note?}; evaluation data for twin_evaluate; audited inbox.feedback/me/briefany signed-in userPersonaBriefOut: counts and items per program, grouped signatures-or-not; the same content the daily digest e-mail links to/me/delegationsany signed-in userdelegations from and to the caller/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/me/delegations/{delegation_id}204any signed-in userrevokes; audited delegation.revokeRouter permissions (1)
/permissionsany signed-in userRouter personas (1)
/personasany signed-in userRouter providers (1)
/providersany signed-in userRouter orgs (14)
/orgs/current/orgs/current/orgs/current/programs/orgs/current/programs201/orgs/current/programs/{program_id}/orgs/current/users/orgs/current/groups/orgs/current/groups201/orgs/current/groups/{group_id}/orgs/current/groups/{group_id}204/orgs/current/role-assignments/orgs/current/role-assignments201/orgs/current/role-assignments/{assignment_id}204/orgs/current/auditRouter programs (7)
/programs/{program_id}/programs/{program_id}/programs/{program_id}/members/programs/{program_id}/role-assignments/programs/{program_id}/role-assignments201/programs/{program_id}/role-assignments/{assignment_id}204/programs/{program_id}/auditRouter signatures (1)
/programs/{program_id}/signatures/{signature_id}SignatureOut.valid recomputed from the current record hashRouter audit_export (2)
/programs/{program_id}/audit/exportAuditExportQuery {from?, to?, format}; CSV or JSON with entry_seq and row_hash/orgs/current/audit/exportsame shape, every program/orgs/current/twins/exportR3; 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)
/programs/{program_id}/connections/programs/{program_id}/connections201/programs/{program_id}/connections/{connection_id}/programs/{program_id}/connections/{connection_id}204/programs/{program_id}/connections/{connection_id}/sync202/programs/{program_id}/connections/{connection_id}/runs/programs/{program_id}/connector-objectsRouter agent_runs (11)
/programs/{program_id}/agent-runs/programs/{program_id}/agent-runs/{run_id}/programs/{program_id}/agent-runs/{run_id}/events/programs/{program_id}/agent-runs/{run_id}/cancel/programs/{program_id}/agent-runs/{run_id}/tool-callsand the thread rule when thread_id is set/programs/{program_id}/agent-runs/{run_id}/interruptsand the thread rule when thread_id is set/programs/{program_id}/agent-runs/{run_id}/snapshotand the thread rule; RunSnapshotOut.model_check/programs/{program_id}/agent-runs/{run_id}/interrupts/{interrupt_id}/resolveplus 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/programs/{program_id}/agent-memorydesigned; 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/programs/{program_id}/agent-memory/{record_id}204an owner or lead with a reason; audited memory.forget in R4 (memory.delete before)/programs/{program_id}/agent-memory/{record_id}/promoteR4; own record only; MemoryPromote {} -> MemoryPromoteOut {record, scrubbed_terms_count}; preference | vocabulary | rubric_adjustment only, else 422 kind_not_promotable; audited memory.promote/programs/{program_id}/agent-capabilitiescontext of use, limitations, risk classAgentRunOut 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)
/programs/{program_id}/workbenchWorkbenchOut: 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}/programs/{program_id}/workbench-roles/{user_id}WorkbenchRoleAssign {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)
/programs/{program_id}/pinsthe caller’s own pins plus the program board; PinOut[]/programs/{program_id}/pins201plus 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/programs/{program_id}/pins/{pin_id}204own pin (own_pin), or program.documents.manage for a board pin; audited pin.deleteRouter threads (10)
/programs/{program_id}/threadsown and shared threads; program.threads.read lists every thread/programs/{program_id}/threads201private by default/programs/{program_id}/threads/{thread_id}thread rule/programs/{program_id}/threads/{thread_id}owner, or manage with a reason; ThreadUpdate {title?, status?}/programs/{program_id}/threads/{thread_id}/shareowner only; ThreadShare.confirm_counts must name the messages and artifacts that become visible/programs/{program_id}/threads/{thread_id}/messagesthread rule/programs/{program_id}/threads/{thread_id}/messages201creates the console_turn run; the state event is written in the same transaction/programs/{program_id}/threads/{thread_id}/messages/{message_id}/feedbackthread rule; audited message.feedback/programs/{program_id}/threads/{thread_id}/streamthread rule; text/event-stream, id: <seq>, 15 s keepalive, resumes from Last-Event-ID/programs/{program_id}/threads/{thread_id}/exportowner, or manage with a reason; carries the provenance footerRouter artifacts (3)
/programs/{program_id}/artifactsthread rule/programs/{program_id}/artifacts/{artifact_id}thread rule; versions share a lineage_id/programs/{program_id}/artifacts/{artifact_id}/promoteplus the target record’s permission; yields a tool_calls proposal, never a direct writeRouter documents (4)
/programs/{program_id}/documentsR4 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/programs/{program_id}/documents201DocumentIngest {identifier_scheme, identifier_value}; no URL field/programs/{program_id}/documents/{document_id}/programs/{program_id}/documents/{document_id}/chunkschunk text is licence-gated; R4 adds asset_id? and row_index? to DocumentChunkOutDocumentOut 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)
/programs/{program_id}/documents/{document_id}/assetsDocumentAssetOut[] {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)/programs/{program_id}/documents/{document_id}/assets/{asset_id}302a 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/programs/{program_id}/documents/{document_id}/blob302the document’s own file, same rule and audit/programs/{program_id}/documents/{document_id}/gather202enqueues 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/programs/{program_id}/documents/{document_id}/third-party-flagThirdPartyFlagIn {third_party_suspected, reason} -> DocumentOut; audited document.third_party_flagRouter evidence (22)
/programs/{program_id}/evidence/questions/programs/{program_id}/evidence/questions201/programs/{program_id}/evidence/questions/{question_id}/programs/{program_id}/evidence/questions/{question_id}/programs/{program_id}/evidence/questions/{question_id}204/programs/{program_id}/evidence/sources/programs/{program_id}/evidence/sources201/programs/{program_id}/evidence/sources/{source_id}/programs/{program_id}/evidence/sources/{source_id}/programs/{program_id}/evidence/sources/{source_id}/links201/programs/{program_id}/evidence/findings/programs/{program_id}/evidence/findings201/programs/{program_id}/evidence/findings/{finding_id}/programs/{program_id}/evidence/findings/{finding_id}/programs/{program_id}/evidence/hypotheses/programs/{program_id}/evidence/hypotheses201/programs/{program_id}/evidence/hypotheses/{hypothesis_id}/programs/{program_id}/evidence/hypotheses/{hypothesis_id}/programs/{program_id}/evidence/hypotheses/{hypothesis_id}/decision/programs/{program_id}/evidence/gaps/programs/{program_id}/evidence/gaps/{gap_id}/programs/{program_id}/evidence/agent-runs202Router plans (26)
/programs/{program_id}/plans/plans/programs/{program_id}/plans/plans201/programs/{program_id}/plans/plans/{plan_id}/programs/{program_id}/plans/plans/{plan_id}/programs/{program_id}/plans/plans/{plan_id}/transition/programs/{program_id}/plans/plans/{plan_id}/approve/programs/{program_id}/plans/protocols/programs/{program_id}/plans/protocols201/programs/{program_id}/plans/protocols/{protocol_id}/programs/{program_id}/plans/protocols/{protocol_id}/programs/{program_id}/plans/protocols/{protocol_id}/transition/programs/{program_id}/plans/protocols/{protocol_id}/approve/programs/{program_id}/plans/experiments/programs/{program_id}/plans/experiments201/programs/{program_id}/plans/experiments/{experiment_id}/programs/{program_id}/plans/experiments/{experiment_id}/programs/{program_id}/plans/reports/programs/{program_id}/plans/reports201/programs/{program_id}/plans/reports/{report_id}/programs/{program_id}/plans/reports/{report_id}/programs/{program_id}/plans/reports/{report_id}/transition/programs/{program_id}/plans/reports/{report_id}/approve/programs/{program_id}/plans/tools/programs/{program_id}/plans/tools/{tool_id}/invocations202/programs/{program_id}/plans/tools/invocations/{invocation_id}/programs/{program_id}/plans/agent-runs202Router review (19)
/programs/{program_id}/review/sops/programs/{program_id}/review/sops201/programs/{program_id}/review/sops/{sop_id}/programs/{program_id}/review/sops/{sop_id}/programs/{program_id}/review/sops/{sop_id}/approve/programs/{program_id}/review/checks/programs/{program_id}/review/checks202/programs/{program_id}/review/checks/{check_id}/programs/{program_id}/review/findings/programs/{program_id}/review/findings/{finding_id}/programs/{program_id}/review/findings/{finding_id}/programs/{program_id}/review/findings/{finding_id}/decision/programs/{program_id}/review/deviations/programs/{program_id}/review/deviations201/programs/{program_id}/review/deviations/{deviation_id}/programs/{program_id}/review/deviations/{deviation_id}/programs/{program_id}/review/sops/{sop_id}/versions/programs/{program_id}/review/sop-versions/{version_id}/programs/{program_id}/review/sop-versions/{version_id}/requirements/decisiondecision signature required; RequirementSetDecisionRouter progress (17)
/programs/{program_id}/progress/overview/programs/{program_id}/progress/milestones/programs/{program_id}/progress/milestones201/programs/{program_id}/progress/milestones/{milestone_id}/programs/{program_id}/progress/milestones/{milestone_id}/programs/{program_id}/progress/milestones/{milestone_id}204/programs/{program_id}/progress/dependencies/programs/{program_id}/progress/dependencies201/programs/{program_id}/progress/dependencies/{dependency_id}204/programs/{program_id}/progress/open-items/programs/{program_id}/progress/open-items201/programs/{program_id}/progress/open-items/{item_id}/programs/{program_id}/progress/approvals/programs/{program_id}/progress/approvals201/programs/{program_id}/progress/approvals/{approval_id}/withdraw/programs/{program_id}/progress/approvals/{approval_id}/decision/programs/{program_id}/progress/agent-runs202ProgressAgentRunCreate {kind: progress_propose}; Progress moves to preview at R3 exitRouter skill_runs (1, R4)
/programs/{program_id}/skill-runsthe 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.startRouter twins (13, R3)
/programs/{program_id}/twinsown twins; program.twins.read lists every twin of the program/programs/{program_id}/twins201TwinCreate {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/programs/{program_id}/twins/{twin_id}own, or any under program.twins.read; TwinOut with activity (the transparency counts)/programs/{program_id}/twins/{twin_id}TwinUpdate; a changed duty invalidates its standing plan and re-asks; audited twin.update, twin.duty_enable, twin.duty_disable/programs/{program_id}/twins/{twin_id}/pauseown; program.twins.manage pauses anyone’s twin with TwinPause {reason} required; audited twin.pause/programs/{program_id}/twins/{twin_id}/resumeown; refuses while the standing plan is missing or invalid; audited twin.resume/programs/{program_id}/twins/{twin_id}/run-now202own; enqueues one twin_cycle with initiated_by = user; 409 twin_paused; audited twin.enqueue/programs/{program_id}/twins/{twin_id}/triggersPage[TwinTriggerOut]/programs/{program_id}/twins/{twin_id}/triggers201TwinTriggerCreate {duty_key, kind, event_action?, spec?, min_interval_seconds?, priority?}; event_action must be in TRIGGERABLE_ACTIONS (422 duty_unavailable)/programs/{program_id}/twins/{twin_id}/triggers/{trigger_id}TwinTriggerUpdate {spec?, min_interval_seconds?, priority?, enabled?}/programs/{program_id}/twins/{twin_id}/triggers/{trigger_id}204/programs/{program_id}/twins/{twin_id}/scheduleTwinScheduleOut {rrule, next_run_at, last_run_at, locked_at}/programs/{program_id}/twins/{twin_id}/scheduleTwinSchedulePut {trigger_id, rrule}Router handoffs (7, R3)
/programs/{program_id}/handoffs/programs/{program_id}/handoffs201HandoffCreate {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/programs/{program_id}/handoffs/{handoff_id}/programs/{program_id}/handoffs/{handoff_id}/acceptaddressee 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/programs/{program_id}/handoffs/{handoff_id}/declineaddressee or persona holder; HandoffDecision {reason}; audited handoff.decline/programs/{program_id}/handoffs/{handoff_id}/completethe person who accepted; audited handoff.complete/programs/{program_id}/handoffs/{handoff_id}/withdrawthe sender; audited handoff.withdrawRouter board (1, R3)
/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)
/programs/{program_id}/metrics/decisionsDecisionMetricsOut {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 suchRouter notifications (2)
/me/notificationsany signed-in user/me/notifications/{notification_id}/readany signed-in useraudited notification.readRouter launch (1)
/launch/{launch_id}any signed-in usersingle use, 10-minute expiry, program.threads.use on the resolved program; audited launch.consumeRouter hooks (1)
/hooks/{provider_id}/{connection_id}202webhook signatureno bearer; body cap 1 MB; verified before parsing; answers in under 2 sRouter agent_tools (1)
/agent-tools/mcprun token (kid run-<n>)stateless MCP tool server; JSON-RPC errors, the one exception to problem+jsonRouter public (2)
/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/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.
| id | name | category | credential class | events | write policy | status |
|---|---|---|---|---|---|---|
literature_search | Literature search | literature | platform keys | none (polling and on-demand) | read only | designed |
benchling | ELN connector | eln | service (client credentials) | signed webhooks (ecdsa_jwks), launchable canvas | read_only | designed |
veeva_qualitydocs | QMS/SOP connector | documents | service (integration user) | none (15-minute delta poll, nightly reconciliation) | read_only | designed |
document_library | Document library | documents | oauth2 | none | read only | designed |
analysis_tools | Analytical and design tools | tools | api_key | none | read only | designed |
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