Console
The console is a program section at /p/<slug>/console, not a fifth product. It appears in the rail when
program.threads.read_own or program.threads.read passes and is used with program.threads.use. Its
status word is preview since 2026-09-15 (contract 11.6: the two phase-2 measurements were registered and the
transition was made in one commit); the twin surfaces of R3 that build on it (inbox, board, twin panel) stay
designed until the R3 gates pass (roadmap). Pilot positioning, quoted from the contract: “non-GxP decision support with a light validation package”. The
ELN stays the scientific record, the QMS stays the SOP record, Trovensa is the record of what agents proposed
and what people decided.
Regions
| Region | Content | Width at 1440 px and above | Remembered in |
|---|---|---|---|
| Conversation | messages (role=feed), plan and approval cards, citation markers [n], composer with Stop and slash commands | 560 to 720 px | nothing |
| Canvas | current artifact, version selector, selection-to-edit, promote, version diff, source viewer | 480 to 640 px, collapsible | cookie console_canvas |
| Run rail | steps, tool calls (name, argument summary, scope, policy decision, permission_exercised, span id), sources read, budget used, SOP binding, model configuration record | 320 px, collapsible | cookie console_rail |
Below 1100 px the canvas and rail become a tabbed drawer; below 768 px a bottom sheet with full-width cards. Mobile reads, approves, declines and stops; artifact editing is desktop-only and the button says why. Cookies hold layout only; everything else is a record.
Thread states: empty, planning, awaiting approval, running, paused (the composer prints “Paused,
waiting for a decision”), finished, failed (with the public error code, for example gate_expired,
policy_denied, config_mismatch), archived.
Threads and messages
| Record | Fields (DTO) | Rule |
|---|---|---|
Thread (ThreadOut) | title, status (open, archived), visibility (private, program), created_by, scope_type (twin for a twin’s own thread, handoff for the program-visible thread an accepted hand-off opens), scope_id, product_slug, launch_context, pinned_context, last_message_at, message_count, redacted_at; schedule was migrated into paused twins and dropped by 0005_twins | private by default; the owner reads it with program.threads.read_own; quality reviewers and leads read every thread with program.threads.read; only the owner shares, with a confirmation naming what becomes visible (ThreadShare.confirm_counts) |
Message (MessageOut) | seq, role (user, agent, system, tool), status (streaming, complete, failed), content_md, content_json, agent_run_id, author_user_id, parent_message_id, citation_count, claims_without_source, feedback (none, helpful, corrected, dismissed), feedback_by, feedback_note, mentions (R3: @name references in a program-visible thread; a private composer says “Share this thread to mention colleagues.”) | text passes redaction before storage and never contains retrieved source text; citations reference document_chunks.id; feedback is rendered as labelled icons, not status chips |
Agent run (AgentRunOut) | phase-1 fields plus thread_id, mode (interactive, background), lane, agent_version_id, plan_hash, usage, trace_id, and for twin runs twin_id, trigger_id, initiated_by (schedule, event, handoff, user) | one console_turn run per agent turn; runtime_session_id equals the run id; twin runs (twin_cycle, twin_handoff) live in one private thread per twin and their footer reads “Started by … under Name’s standing plan”, never “Requested by” |
Interrupt (RunInterruptOut) | seq, kind (plan_approval, write_approval, consent, question, batch_approval), detached, payload, status, resolved_by, resolved_at, resolution (decision, reason, edits diff; for a batch partial and per-item outcomes), permission_exercised, expires_at | default expiry 24 h for console turns and 72 h for twin proposals (twin_definitions.expiry_hours), extendable; resolution needs program.threads.use plus the write’s own permission, and on a twin run the bound-user rule below |
Tool call (ToolCallOut) | specialist (from the run token), tool_key, arguments (redacted), proposal_hash, basis_hash (the linked record’s {target_type, target_id, updated_at, status} at proposal time), approved_hash, status, approved_by, approved_at, permission_exercised, resolution_reason, policy_decision, injection_flag, result_ref, otel_trace_id, otel_span_id | writes need an approved row whose approved_hash matches the payload the server writes |
Snapshot (RunSnapshotOut) | context (chunk ids and hashes, record ids, sop_version_ids, prompt version, allowlist, policy version, permission snapshot), context_hash, model_check (expected_model_config_id, reported_model_id_hash, inference_profile_id, outcome) | every run is reproducible from the database; events stay excerpt-free |
The composer shows the fixed phrase “Private to you. Quality reviewers and program leads can read this thread
for review.” A record created from a thread links (thread_id, message_id, artifact_id, artifact_version, agent_run_id) and its page reads “From thread T-31” or “From a private thread” according to the reader’s
rights. Conversations are redacted to tombstones after organizations.settings.thread_retention_months
(default 24) while tool calls, interrupts, snapshots, signatures and audit rows persist (thread.redact).
Run modes and plan-first gates
Every run starts with a plan artifact (actions, tools, sources, expected writes, budget). The approved plan
(agent_runs.plan_json, plan_hash) is the control flow: a tool outside it is refused with plan_violation
and surfaces as a new interrupt. The mode is remembered per user in users.settings.console_run_mode
(PATCH /me/settings):
| Mode id | Label | Behaviour |
|---|---|---|
approve_each_write | Run and approve each write | reads run; every write pauses the run for a decision |
auto_reads_gated_writes | Run with automatic reads and gated writes | default; reads run within the plan, writes pause |
keep_planning | Keep planning | the plan is refined without running tools |
Approval card
One component handles plan approval, write approval, connector consent and questions. It shows what will be
written, which sources it cites, the downstream consequence and the permission exercised (disabled with the
permission name when the viewer lacks it). Buttons: Approve (Enter), Edit then approve, Decline
(Esc, the reason is stored as evaluation data); Resume re-opens an expired gate; Stop ends the run.
Edits are applied by the server to the stored proposal, approved_hash is recomputed, the diff goes into
resolution, and the record is written from the server-side payload only; the record page then reads
“edited before approval” with the diff. A card that waits more than 15 minutes becomes a background run card
and stays actionable. Approval is not a signature: signatures are reserved for approve routes and
requirement-set acceptance (signatures).
Batch approval (R3)
A batch_approval interrupt bundles up to 10 write proposals of the same tool and target type, each its own
tool_calls row, under one batch_key; one open batch per run. In a console turn a batch arises when the
person asks for several records at once and the run waits (attached); in a twin cycle batches are detached.
The decision batch card extends the approval card with a checkbox column and a per-row diff. There is no
“Approve all”: each item shows an expanded summary (tool, target, sources, permission, consequence, citation
coverage) and a checkbox; Approve selected applies to explicitly selected rows, is disabled with the
reason named when any selected item lacks the resolver’s permission or its citation coverage is below the
program threshold, and turns the button into a two-step control naming the count, the tool and the permission (“Approve 8 checks
with review.sops.manage”) before the call. The request is InterruptResolve.items[] (1 to 10, each
{tool_call_id, decision, edits?, reason?}); a top-level decision on a batch is refused (422), as is
items[] on any other kind. The service mints one batch_id, checks each item’s own ToolSpec.permission
(403 batch_item_forbidden, the item failed, the siblings untouched), verifies basis_hash
(409 stale_proposal per item), requires a reason on every decline, executes approved items on the call that
approves them, and writes one audit row per item. The answer is InterruptResolveResult {batch_id, items[{tool_call_id, status, code?}]}; the card announces “4 approved, 1 declined” through role=status.
Undecided items expire one by one; the card then reads “5 approved and written, 5 expired”. At 390 px the list
degrades to /me/inbox/[itemId], stepping “item k of N” with the same controls.
Detached interrupts and the inbox resolve path (R3)
A twin cycle files and finishes: open_interrupt(detached=True) inserts the interrupt and its proposals
without touching run.status, the container is acknowledged and continues, and the run ends succeeded.
Approval later executes the write server-side from the stored arguments through the tool handler under the
approver’s principal; nothing resumes a container. Expiry writes nothing, marks the item “Expired, nothing
written” and leaves the run succeeded; Resume re-files it on the twin’s next pass. Console turns stay
attached and behave as the previous sections describe.
Interrupts on a twin run belong to the bound person: write_approval and batch_approval are resolvable by
the bound person or by an active delegate who holds the item’s write permission, plan_approval by the bound
person only, and anyone else, including holders of program.threads.read and program.threads.use, gets
403 bound_user_only naming the twin. The resolve path is POST /me/inbox/{item_id}/resolve: it is
authorised by owning the inbox item or by an active delegation for its routing, then calls the same interrupt
service with the caller’s principal, and never requires reading the private thread. The inbox, the brief
e-mail link and the phone all use it; the thread-scoped route POST .../agent-runs/{run_id}/interrupts/{interrupt_id}/resolve
keeps working for the bound person under the same rule. Digest-priority filings pass notify=False and
create no approval_requested notification; the person meets them in the inbox (“Decisions waiting for
you”) and in the daily brief. Every decision writes the audit envelope with interrupt_id,
permission_exercised, proposal_hash, approved_hash and, from R3, batch_id, twin_id, trigger_id and
delegated_from_user_id. The guides Decide from your inbox and
Delegate while away walk through both paths.
Artifacts and canvas
Kinds are a fixed vocabulary rendered by UI-owned components: plan, comparison_table, finding_set,
hypothesis_set, protocol_draft, report_draft, deviation_table, sop_diff, milestone_view,
summary and, from R3, persona_brief (the daily brief, written by the worker job twin_digest from inbox
items with no model call, in the twin’s thread with run_id null). Versions are append-only within a lineage_id; selection-to-edit yields v(n+1) with
origin = agent, direct edits yield v(n+1) with origin = human and authored_by_user_id. Promote turns
a version into a tool_calls proposal awaiting approval, never a direct write and never a visibility change.
Items of kind observation need non-empty chunk_refs and cannot be promoted otherwise. Footer:
“Agent-generated draft, unsigned.” followed by “Cites N sources.” and “Model configuration MC-n (recorded
<date>)”.
Citations and citation coverage
Every factual sentence carries at least one [n] bound to (document_id, chunk_id, char_start, char_end).
The source viewer shows title, provenance, licence code, retrieved_at, snapshot stamp, the cited span and
“Open in notebook” or “Open at publisher”; rendering follows the licence codes.
Citation coverage is a deterministic count of claim-shaped sentences without a marker (the pattern list lives
in web/lib/content.ts claimPatterns); the header reads “All 9 claims carry a citation” or “1 claim without
a citation”. It verifies markers, not that the chunk supports the claim; semantic grounding is a judged check
in the evaluation suite. “Needs sources” withholds the write gate until citations are repaired or the user
overrides with a note.
Record actions, palette and slash commands
Ctrl K gains Ask. On any record page . opens the record actions “Ask about this”, “Check against
SOP”, “Prepare a draft from this”, “Compare with…”, “Send to QA” and “Accept into record”, filtered by the
viewer’s permissions. The composer accepts /check, /draft, /compare, /brief and /pin. “Ask the
agent” replaces the phase-1 agent action button; the run rail replaces the run dialog; legacy run kinds stay
callable as canned prompts.
Keyboard and accessibility
role=feed composites for messages and rail; streamed text is announced through a polite role=log in
sentence chunks; approval cards receive focus on appearance and return it on resolution; the sticky composer
never obscures focus. Single-key shortcuts (a accept, . record actions, Enter, Esc) are active only
with focus on the card or record header; users.settings.shortcuts remaps them to alt_chords or turns them
off. Agent-origin content never uses the pass tone until accepted.
Notifications
notifications (bell and /me/notifications) fire on approval_requested, needs_input, run_finished,
run_failed, sop_changed, batch_completed, connector_reauth and, from R3, handoff_received (only when
the hand-off names a person), digest (one per person per day, written after the brief exists),
twin_paused, mention and decision_reminder (to holders of program.twins.read only, at most once per
item per day). E-mail carries the record type, the human-set program name, the record id and a link only: no
titles, no agent text, never a token that acts, no approve-by-reply. Quiet hours (users.settings.quiet_hours
and timezone) hold delivery through deliver_after, not the record; the sweep selects
COALESCE(deliver_after, created_at) <= now(). run_finished and run_failed are not sent for twin runs;
genuine twin failures reach holders of program.twins.read. Reading a notification is audited
(notification.read).
Performance budgets (design targets)
Optimistic echo under 100 ms; working indicator under 1 s (the state event is written in the message
transaction); INP under 200 ms; SSE keepalive every 15 s; reconnect from Last-Event-ID within 2 s.
First-token latency and the cold bound are measured and recorded in CLAIMS-REGISTER.md before any number
appears in copy; none appears here.
Source: web/lib/content.ts (consoleSection, consoleCopy, runModes, recordActions, slashCommands, approvalActions, claimPatterns, twinCopy, inboxKinds), web/lib/api/types.ts (InterruptResolve, InterruptResolveResult, InboxItemOut), web/lib/api/endpoints.ts, api/app/db/models/conversation.py, api/app/db/models/agent.py, api/app/db/models/inbox.py, api/app/services/interrupts.py, api/app/services/notifications.py, api/app/auth/permissions.py, docs/lib/content.ts, PRODUCT-CONTRACT.md sections 10.1 to 10.4, 11.4, 11.6 and 11.7, PRODUCT-PLAN.md Part 4 section 3 and Part 7 section 5