Skip to content

Web chat API

The dashboard talks to a FastAPI app built by steelmoth-runtime/src/steelmoth_runtime/web_chat/app.py. The app installs logging, CORS for the local dashboard origin, shared error handlers, health routes, and all /v1/* dashboard/runtime routers.

build_app(runtime, config) creates shared process-local objects for inbox, conversation replay, stream leases, turn broadcasts, optional Redis fanout, owner id, scope resolution, durable chat storage, and notification preferences. Those objects are exposed through FastAPI dependency overrides so route modules stay testable.

Family Primary Source Purpose
Health app.py /v1/healthz and /v1/readyz.
Chat routes_chat.py, routes_chat_stream.py Send, history, and SSE stream. POST /v1/chat/send also accepts a reply_to {message_id, author} (chat reply-to rail): the runtime resolves the quoted turn owner-scoped, derives the snippet server-side (author picks user_text vs final_text), frames it as one DATA prompt line + a router-context entry (INV-300), and drops a foreign/bogus id silently. POST /v1/chat/reroll {message_id, model?} (reroll rail) re-asks an earlier turn’s prompt as a brand-new queued turn with a new msg_ id (append-only — the original reply row is never touched), sharing /send’s model-access + token-limit gates; the SSE consumer defers the user-side memlink ingest (metadata source web_chat.reroll) so the prompt bubble is not duplicated. Both are owner-scoped one-row reads via get_turn.
Ghost mode routes_chat_ghost.py Stateless, zero-persistence Ghost-mode lane. POST /v1/chat/ghost/stream streams a reply from the default chat model for the whole browser-held transcript with NO tools, NO memlink, NO durable store, NO conversation ids — only the token-limit gate (billing) is kept. Reuses /send’s auth + token-limit + model-access gates (_enforce_model_gates, main_brain_models.effective_default_id), DEFAULT_SYSTEM_PROMPT, and the honesty gate apply_fabricated_action_gate (judge wired via runtime._build_honesty_judge, INV-029/HR20). Transcript caps (≤40 messages / ≤16k chars each / ≤128k total) enforced fail-closed before any LLM call; frames are token_delta* then message_complete {final_text} or error. Same route on the operator brain + every client gateway + owlet (INV-028 parity, flagless).
Identity/auth context routes_me.py, auth.py Current user shape, admin role, feature flags.
Agents routes_agents.py Agent snapshots. (The routes_team.py dispatch-team sidecar views were removed 2026-06-16 with the dispatch subsystem — #354/#363/#367.)
Runtime routes_runtime.py Runtime snapshot, models, channels, and sub-agents.
System routes_system.py Admin-only read of the runtime’s effective default system prompt.
Capabilities/autonomous/Morpheus routes_capabilities.py, routes_autonomous.py, routes_morpheus.py routes_capabilities.py survives (slimmed by PR #611, 2026-06-22) serving the snapshot read (GET /v1/capabilities/snapshot) and the approval write (PATCH /v1/capabilities/approvals/{id}) for the Pending Approvals page; the architect/probe/truth/remediation subsystem behind the old “capability truth” framing was deleted in that PR. Plus autonomous status and Morpheus controls.
Memory routes_memory.py Memlink proxy reads, runtime context assembly, living documents, and admin repair actions.
Tokens routes_tokens.py Admin-only LLM usage summary, event listing, and time-series routes.
Workspace routes_workspace.py Inbox, outbox, notification triage, sidecar run detail, and event reads.
Background runs routes_background_runs.py Owner-scoped background-run lane surface (PRD 0058 / ADR 0203): GET /v1/background-runs[?include_finished=], GET /v1/background-runs/{run_id} (includes the recorded step ledger), POST …/pause, POST …/resume, DELETE …/{run_id} (idempotent soft-cancel). Every endpoint is require_authed with owner_id = web_chat_key(user.email); a cross-owner or unknown id is an opaque 404 on GET and a benign false on the writes (no existence oracle); the run instruction body is never logged.
Operations/standards/self-code routes_operations.py, routes_standards.py, routes_self_code.py Scenarios, reports, standards artifacts, and self-code review decisions.
Wiki routes_wiki.py Read-only user-facing system wiki pages.
Triggers routes_triggers.py Owner-scoped standing trigger-rule CRUD for the Automations page’s Triggers section (background work lane, ADR 0205): GET/POST /v1/triggers, POST /v1/triggers/{id}/arm (the dashboard Save — the only draft→armed path; delegates to trigger_store.arm_trigger, which owns the no-backlog checkpoint init), POST …/pause / …/resume, PATCH (draft-only matcher edit; condition_kind immutable), DELETE (idempotent soft-cancel), and GET …/{id}/fires (the exactly-once fire ledger, newest first, each fire linking its run_id). require_authed + web_chat_key(user.email) owner derivation, per-actor write rate limit, non-leaking 404s — templated on routes_browser_tasks.py.
Forge documents routes_forge.py Owner-scoped document/spreadsheet/deck CRUD for the Forge canvas (Forge PR-2): GET/POST /v1/forge/documents, GET …/{id}, GET …/{id}/versions[/{n}], PUT …/{id}/model (canvas Save), POST …/{id}/llm-edit (assistant edit), POST …/{id}/restore/{n}, POST …/{id}/archive, POST …/{id}/export {format}{attachment_id, mime_type} (materialized through the existing attachment store; downloaded via GET /v1/chat/attachments/{id}). Every endpoint is require_authed with owner_id = web_chat_key(user.email); a cross-owner or unknown id is an opaque 404 (never 403); per-actor write rate limit; no-store on reads. Create-with-prompt + llm-edit call the runtime’s shared forge_generate_initial_model / forge_apply_llm_edit. Push-to-Drive is PR-5.
Settings/clients/calendar/todos/reminders/mail Matching routes_*.py files User/admin settings and workspace tools.

PRD 0038, RFC 0073, and ADRs 0094-0096 define a future sliced API for Moth mid-turn controls: POST /v1/chat/interrupt, POST /v1/chat/branch, a busy_mode extension on POST /v1/chat/send, turn_interrupted, queue_chip_update, and steer_received SSE events, queue and branch schema objects, and an explicit ToolSpec.on_abort policy.

As of this verification pass, those API and schema surfaces are design intent only. The current implemented chat API still exposes send/history/conversation routes from routes_chat.py; SendRequest contains conversation_id, text, and client_message_id only; turn_events.py does not define the planned mid-turn event models; and a source/migration search found no current implementation matches for the planned route, event, handoff, or abort-policy names. Treat the PRD/RFC/ADR set as the per-slice target until the matching migrations, runtime routes, stream events, dashboard handlers, and tests land.

The web API is not an independent runtime. It shares the same runtime object as Telegram-facing Moth and uses RuntimeConfig for feature gates, model settings, memlink URL, durable chat enablement, owner id, and deployment scope.

Memlink access is centralized through an httpx.AsyncClient created in the FastAPI lifespan hook. When no memlink URL is configured, memory routes degrade through their route-level unavailable behavior instead of crashing app startup.

Durable chat storage is only constructed when durable_chat_enabled is true. Readiness reports whether the durable chat repository is configured when the runtime expects it.

GET /v1/chat/history still reads transcript rows from Memlink, but when durable chat is available it can enrich assistant messages with Message.tool_timeline. routes_chat.py first tries to derive the assistant msg_* id from Memlink event ids (out:web:msg_<id>:...), then falls back to an optional durable link lookup for opaque event ids. It fetches persisted stream events for that message id (through durable_chat_runtime.stream_events_for_message_async()) and folds them into planner/tool/sub-step/composing timeline steps. Timeline lookup failures are logged and treated as non-fatal so text history still returns.

Assistant messages can also carry Message.reasoning_blocks. For GLM this is fed by streamed reasoning_content; for Codex/GPT it is fed by public Responses reasoning-summary deltas requested with reasoning.summary: "auto". stream_engine.py includes finalized reasoning blocks on message_complete and persists them to steelmoth.chat_message_reasoning; routes_chat.py joins that sibling table back into history by assistant message_id. Turns without a readable reasoning stream leave the tuple empty, so persisted dashboard rows render no extra thinking block for that turn.

When the Memlink read itself fails, the failure is observable in two layers. The shared proxy (_memlink_proxy._execute) funnels all three httpx transport arms (timeout, connect-refused, other transport error) through _record_transport_failure, which emits one dashboard.memory.memlink_transport_error event carrying exception_type, elapsed_ms, the resulting circuit state, and secret-free signer booleans (has_auth / oracle_sign / signing_vault_present — never the credential, signing-vault name, or signature, per INV-006), and attaches that safe detail bundle to the raised WebChatError (surfaced into the web_chat.error log via errors.py). The history route additionally logs a web_chat.history.fetch_failed event (reason, elapsed_ms, conversation_id, and data_loss=False) so a reload outage is distinguishable from message loss — the durable transcript persists; only the read failed.

The breaker is per route family, not process-wide: app.py wires a _MemlinkCircuitRegistry and _execute resolves one _MemlinkCircuit per _circuit_key(path) (the first two path segments — id-bearing tails like /v1/objects/<id> collapse to one bounded breaker). A transport failure on the chat-history family (/v1/events) therefore opens only that breaker, leaving the living-document (/v1/living-documents) and runtime-context (/v1/runtime-context) reads unaffected, instead of black-holing every web-chat memlink read for the open window. _resolve_circuit still accepts a bare _MemlinkCircuit unchanged for unit tests and legacy callers.

The Message wire type also exposes message_id — the runtime-assigned per-turn msg_<24-hex> surfaced from world.events_raw.message_id by memlink-hybrid’s ChatEventEntry. This is independent of Message.id (which is memlink’s durable evt_<uuid> storage identity). The dashboard’s chat-bubble dedup at ChatPane.tsx::isPersistedAssistantTwin keys on message_id rather than on reply text or on the evt_ id, so future chat-reply-pipeline mutations that append footers, truncate, or otherwise mutate text post-stream cannot cause the doubled-bubble bug seen in production on 2026-05-18 06:02 UTC. See PRD 0035 / RFC 0070 / ADR 0090 for the design rationale and FAILURE_MODES.md rows 251-255 for the rolling-deploy + transitional considerations. The complementary backend wide event web_chat.turn.text_diverged (and its text_equal paired event) fires once per LLM-streaming-path turn so an operator can detect new pipeline regressions early.

routes_chat_stream.py now treats durable and non-durable stream sessions slightly differently. Non-durable mode still uses StreamLeaseRegistry to supersede older peers for the same chat key; durable mode uses reconnect-safe replay/turn-attach and avoids lease supersession so peer dashboards do not kick each other off in reconnect loops. In durable mode, stream body polling also tightens to min(inbox_wait_s, _DURABLE_CLAIM_POLL_S).

The stream route delegates turn production to stream_engine.stream_turn(). The runtime event contract lives in turn_events.py and includes message_start, planner_decision, token_delta, tool_call_start, tool_step_progress, tool_call_end, message_complete, assistant_action_hint (PRD 0036 / RFC 0071 / ADR 0091 — structured chip hints emitted by the chat-reply pipeline; render below the assistant bubble in the dashboard), channel_message, and error. channel_message represents a user message that arrived on a non-dashboard channel (Telegram/email/webhook). The event type, durable allowlist (memlink-hybrid/migrations/0055_chat_stream_events_channel_message.up.sql), and dashboard handler (ChatPane.tsx::channel_message case, PR #259) all shipped in Phase 6. The producer for non-dashboard-origin turns has not yet shipped — Phase 9 in flight per RFC 0080 / ADR 0121. The current Telegram relay handler at routes_telegram_relay.py consumes the runtime’s event generator into a local Python summarizer that recognises four event kinds (message_complete, tool_call_start, planner_decision, error) for the HTTP response payload and silently drops the rest, including ChannelMessage, MessageStart, TokenDelta, ToolCallEnd, and AssistantActionHint. TurnBroadcastRegistry.start(...) and RedisTurnFanout.publish_frame(...) are invoked only by dashboard-origin turns (one call site each, both in routes_chat_stream.py). Today, the user bubble Web UI shows for a Telegram-origin turn comes from /v1/chat/history polling of the in:<update_id> row the worker writes pre-relay, not from a live SSE event. The Phase 9 fix (RFC 0080 §Design) routes the relay route’s event stream through a new stream_engine.persist_and_publish helper so non-dashboard turns persist into steelmoth.chat_stream_events AND publish through the same broadcast/fanout pipeline the dashboard already uses. stream_engine.py maps those events to SSE payloads and now carries planner and tool metadata fields such as action, rationale, result_preview, and error_message in the tool/planner frames. durable_chat_models.py accepts the same new event types for persisted stream replay rows. Live TokenDelta events can be forwarded directly as SSE token_delta frames. If a turn completes without any live deltas, stream_engine.py chunks MessageComplete.final_text into fallback token_delta frames before message_complete; after a live delta has been emitted, it suppresses those completion chunks to avoid replaying the answer. Migration 0038_chat_timeline_stream_events keeps the durable chat_stream_events.event_type allowlist aligned with those new planner/sub- step event types.

Phase 10 (ADR 0120 / 0122) populates the nullable JSONB columns steelmoth.chat_stream_events.attachments (per-event) and steelmoth.chat_turns.final_attachments (terminal) added by migration 0056. The durable replay buffer carries the per-event attachment list on RecordedTurn, and DurableChatRepository.persist_turn writes both columns on completion, so a dashboard reconnect mid-turn replays the same attachment stream and the post-turn persistence captures the final attachment list for history reads. Memlink round-trips attachments through the events_raw.metadata_json.attachments back-channel — runtime.persist_web_assistant_reply writes the list when present; MemlinkChatEvent carries it back via _attachments_from_metadata, which degrades to empty list on malformed metadata rather than 500-ing the history page. The producer keeps extra='forbid' (INV-012) by hydrating only inside the typed schema.

Auth is fail-closed in two layers, both sourced from Authentik via OIDC. Before either identity layer can run, the runtime auth boundary derives peer metadata from the immediate socket peer (request.client.host, modeled in regression tests with TestClient(..., client=(host, port))) rather than from forwarded headers. When internal hop auth is configured, the dashboard-to-runtime hop credential is required before trusted x-authentik-* headers are accepted. x-forwarded-for is metadata only: it may be retained for logging/forensics after the runtime boundary passes, but it must not grant peer trust or override the socket peer. The regression suite pins two denial cases: an untrusted socket peer spoofing x-forwarded-for: 127.0.0.1 cannot authenticate, and client-provisioning/admin-provisioning routes inherit the same boundary.

After that transport boundary, the shared _authenticate helper (web_chat/auth.py) resolves the peer gate, the Authentik email, and the role — with NO canonical pin. The principal identity layer lives in require_authed: _authenticate + the canonical-operator email pin (403 PERMISSION_DENIED on a mismatch, no observer exemption — observer fix S1.2). The role layer _role_for() returns one of THREE roles (ADR 0225): "admin" (membership in config.admin_group, checked first), "observer" (membership in config.observer_group AND the operator-brain deployment check — observer fix S1.1), otherwise "user". The observer is admitted only via require_admin_read (admin OR observer, GET/HEAD reads + the two declared read-shaped POSTs); require_admin (writes + un-redacted secrets) refuses it. /v1/me returns owner_id, the deterministic Authentik auth_subject_id, role, groups, profile fields, and feature flags. Per RFC 0074 / ADR 0097, the legacy admin_emails ∪ admin_group two- rail union is fully retired as of PR-D: config.admin_emails and its env aliases are gone from the runtime, the wire field AdminAssignmentsView.admin_emails and the transitional requesting_user_is_self_in_list legacy alias are gone from the contract + schema, and AdminAssignmentsView.requesting_user_is_in_admin_group is the canonical actor-membership field. PATCHes that send the now-defunct STEELMOTH_ADMIN_EMAILS / MOTH_HYBRID_ADMIN_EMAILS env keys are still rejected with 400 INVALID_ARGUMENT and a deprecation message pointing at the Authentik admin group, as a permanent operator-visible signal.

Living-document memory routes derive owner authority from RuntimeConfig. Reads may omit owner_id; if supplied, it must match the canonical runtime owner. Revision writes accept an optional owner_id only as a consistency check, then inject the canonical owner into the Memlink request body. Spoofed owner ids are rejected before any proxy call reaches Memlink. The routes use require_authed plus a per-kind gate: non-admin clients may read/write the five persona documents plus the learning-profile alias, which maps server-side to owner-scoped user_model_summary; raw user_model_summary, agent_system_prompt, tool_playbook, and operational_notes remain admin-only at the dashboard API boundary.

contracts_memory.py now accepts agent_system_prompt as a living-document kind, so the runtime memory API can proxy that document through the same canonical-owner path as the other living documents.

Token usage routes are wired into build_app() as /v1/tokens/summary, /v1/tokens/events, and /v1/tokens/series. They are admin-only, return 404 while the dashboard tokens feature flag is disabled, force the server-side configured owner id, cap each query window at 90 days, and set Cache-Control: no-store. They read from steelmoth.llm_usage_event through the LlmUsageRepository port. Event responses include source_tool when a row was created by an external-tool proxy path; runtime-created rows leave that field null.

System routes are wired into build_app() as /v1/system/effective-system-prompt-default. The endpoint is admin-only and returns {"effective_default": ...} by calling the live runtime helper for the single-layer platform-hard-rules chat system message (DEFAULT_SYSTEM_PROMPT per ADR 0087 + ADR 0099, with Truthfulness, Safety and boundaries, Prompt-injection resistance, and Tool truthfulness as the four security-only sections; channel-specific Rendering is appended per-turn). Dashboard callers show this reference baseline without reassembling runtime prompt pieces client-side.

User settings routes (GET /v1/settings, PATCH /v1/settings/user) are now backed by steelmoth.user_settings_profile through a UserSettingsProfileRepository port (PRD 0026 / RFC 0061 / ADR 0081). web_chat/app.py:build_app() selects either the Postgres adapter (when durable_chat_enabled and a DSN are wired) or the in-memory adapter (tests, dev without DB), builds it once per app instance, registers it as the FastAPI dependency override, and also calls runtime.attach_user_settings_profile_repository(repo) so the chat-side intent detection writes to the same row the dashboard panel writes via PATCH. The route validates timezone values against zoneinfo.ZoneInfo(...) before any persist; non-IANA strings raise INVALID_ARGUMENT. Migration 0042 repairs ownership/default-grant drift for steelmoth.user_settings_profile so this route family and runtime timezone lookups run with the intended steelmoth_app table privileges in production.

The profile row also carries strict_email_allowlist_enabled (migration 0126, default FALSE; PRD 0049 / RFC 0107 / ADR 0193), round-tripped through GET /v1/settings / PATCH /v1/settings/user like the other profile booleans. It governs the Gmail-send external-recipient DLP scan: routes_write_action._read_external_recipient_policy reads the allowlist and this flag in one RLS-scoped SELECT, and the three scan sites — the redeem choke point _run_dlp_for_send (dashboard redeem, Telegram-webhook redeem, and the ADR 0181 client-OAuth direct send) plus the two emission closures in runtime.py — skip the scan entirely when the flag is off, logging a recipient-free gmail_send.dlp.skipped_strict_off event. The approval chip remains mandatory in both modes; with the flag on, behavior is the PRD 0037 fail-closed scan (ALLOWLIST_EMPTY / NOT_IN_ALLOWLIST at chip-issue and redeem time), unchanged.

The settings response also carries a workspace-boundary projection for admin visibility. Its protected_paths list now imports self_code_safety.py:PROTECTED_RELATIVE_PATHS, the same tuple used by runtime self-code path resolution and source-search exclusion, so the dashboard view stays aligned with the actual read/search guard.

The workspace boundary is operator-pickable (plan docs/plans/2026-08-15-selfcode-home-workspace.md). Admin-only GET /v1/settings/admin/workspace-dirs returns the deployed base root (self_code_workspace_base_root — the compose-pinned mount), the effective current root, and the base root’s immediate subfolders (names only; protected classes filtered through the same self_code_safety denylist). A picker PATCH of STEELMOTH_RUNTIME_SELF_CODE_WORKSPACE_ROOT persists the pick as <base>/.selfcode-root (one relative path) on the host-backed mount; config boot resolution (config.py:_resolve_self_code_workspace_root) applies it, so picks survive restarts. Picks outside the base, onto protected paths, or onto non-directories are refused INVALID_ARGUMENT; an unpersistable pointer is FAILED_PRECONDITION. The boundary projection re-resolves the pointer live; the self-code tools themselves capture the root at boot and follow on the next runtime recreate.

/v1/settings GET now also returns a user.channels projection (PRD 0029 / RFC 0064 / ADR 0084). supported_channels.supported_notification_channels(cfg) is the single source of truth for the seven canonical channels (Inbox, Telegram, Email, Slack, WhatsApp, Instagram, Webhook) with server-derived configured and supported flags. inbox_only is always configured; telegram tracks cfg.telegram_bot_token; email tracks cfg.mail_broker_enabled; the rest are placeholders the dashboard renders greyed-out. PATCH /v1/settings/user accepts either the legacy notifications: {desktop, email, chat} shape or a typed channels: [{name, enabled}] shape; enabling a channel whose canonical row reports configured=false is rejected with INVALID_ARGUMENT before any preference row is written, so the dashboard cannot bypass the runtime’s authority. _preferences_from_settings writes one steelmoth.user_notification_preferences row per supported channel; the existing 0024 CHECK constraint is unchanged in this workstream.

Calendar routes now expose two scheduling surfaces. GET /v1/calendar/events returns no-store event snapshots for a start/end window, fans out across the owner’s selected world.calendar_subscriptions rows when available, and falls back to primary-calendar-only when no rows are selected or the repository is temporarily unavailable. GET /v1/scheduling/calendar-sources returns the owner’s source list with current selected flags, and POST /v1/scheduling/calendar-sources/{calendar_id} toggles one source using an owner-scoped lookup; unknown/non-owned calendar ids return NOT_FOUND.

Phase 11 — User-uploaded attachments (PRD 0044 / RFC 0083 / ADRs 0127-0131)

Section titled “Phase 11 — User-uploaded attachments (PRD 0044 / RFC 0083 / ADRs 0127-0131)”

The consumer-side counterpart to Phase 10. Phase 10 landed the producer side (tools emit Attachment objects that the dashboard / Telegram render). Phase 11 adds the user side: composer drag-drop / paste / file-picker, a new POST /v1/chat/attachments multipart route, multimodal LLM handoff with an injection-resistance preamble.

Routes (planned, P1+):

  • POST /v1/chat/attachments — multipart upload, owner-scoped, magic-byte MIME sniff, per-kind size cap (image 10 MB, PDF 32 MB, text/CSV/code/JSON 2 MB). Auth: Depends(require_authed) (ADR 0131 — no new CSRF surface). Reuses PostgresAttachmentStore.materialize with source_tool="composer_upload" and a new origin='user_upload' column. Returns the typed Attachment-shaped response plus an extraction_status field.
  • POST /v1/chat/send — extended with attachment_ids: tuple[str, ...] = () on SendRequest (preserves extra="forbid"). The send handler resolves each id against owner_id = scope.owner_id; mismatch → 404 (the same 404-not-403 contract as routes_attachments.py:91-101).

Schema (migration 0059): steelmoth.attachments gains origin (tool | user_upload), extracted_text, extracted_text_truncated, extraction_status (ok | skipped | truncated | failed | pending). bytes_size CHECK lifted from 10 MB to 33554432 (32 MB) to match Claude’s native PDF cap. Idempotent ALTERs with IF NOT EXISTS; default 'tool' on existing rows preserves Phase 10 semantics. HARD RULE 4: migration ships before code.

Multimodal handoff (ADR 0129): A new adapter web_chat/llm_content_assembler.py translates (role, text, attachments) tuples into provider-native multimodal blocks at the provider boundary. Anthropic-via-vault gets {"type":"image","source":{"type":"base64",...}} for images and labelled extracted-text sections for documents; OpenAI gets input_image blocks. The runtime turn engine (_build_messages) stays transport-neutral per INV-011. When any attachment carries extracted text, the system message is prefixed with the injection-resistance preamble (INV-022 ¶3 precedent extended from Morpheus consolidation to chat turns). Codex provider is text-only (llm/codex_client.py:138, 221-238); _provider_attempt_order filters Codex out when any image-kind attachment is present. Current-turn attachment prompts such as “summarize this” bypass web follow-up and tool-planner routing when the turn carries user upload ids, so PDF/document blocks stay on the LLM attachment path instead of being mistaken for a web search follow-up.

Extraction (web_chat/upload_extractors.py): Synchronous, 5 s asyncio deadline. PDF via pypdf (256 KB cap, truncated flag if exceeded). CSV via stdlib csv (schema + first 50 rows as a markdown table). Text/code/JSON: UTF-8 decode, 2 MB cap, fenced code block. Images: dimensions probe only (the assembler base64-inlines bytes at LLM-call time).

History persistence: User-side attachments persist into events_raw.metadata_json.attachments mirroring the Phase 10 assistant-side path (covered earlier in this page). Memlink ingest metadata accepts nested JSON values for those attachment objects, and the v2 compatibility bridge preserves that metadata into the v1 history back-channel. Message.attachments already exists on the wire type (Phase 10 wired it for assistants); Phase 11 extends the user-side writer to populate it for author='user' rows. P3 adds budgeted recall (“the PDF I sent 3 turns ago” works) per ADR 0130 (per conversation attachment recall).

The dashboard treats upload metadata as a client-side continuity aid while the durable backend remains authoritative. InputBar stores the full Attachment-shaped upload response so ChatPane can render the just-sent user bubble with a chip immediately, while /v1/chat/send still receives only the attachment_ids list. If a persisted user row matches the optimistic turn but has no Message.attachments yet, ChatPane reuses the optimistic metadata for rendering until the history path catches up.

Phased delivery:

  • P0 — standards bundle (PRD 0044 + RFC 0083 + ADRs 0127-0131 + THREATS / FAILURE_MODES appends + this wiki section).
  • P1 — MVP: composer + image + PDF + migration 0059 + upload route + assembler
    • extractors + composer wiring + history persistence + feature flag STEELMOTH_USER_UPLOADS_ENABLED.
  • P2 — text / code / CSV extractors.
  • P3 — history hydration (budgeted recall).
  • P4 — Telegram-IN parity (media becomes origin=user_upload; closes the pre-existing silently-dropped-photo bug at runtime.py:9211-9245).
  • P5 — async extraction + audio (deferred; extraction_status='pending' reserved in the CHECK for this).

pdf.from_url render redeem + delivery (PDF plan S4–S7b)

Section titled “pdf.from_url render redeem + delivery (PDF plan S4–S7b)”

Rendering a webpage to PDF from chat is a tier-3, approval-gated flow that reaches the brain through the existing /v1/chat/approval/redeem route — not a new route. End to end:

  1. Plan + chip (S4/S6). A URL in a PDF request routes to the from_url action; the runtime mints a single-use approval token (tool_name = "pdf.from_url", args_hash over the canonical URL) and emits an approve_action chip. No fetch happens yet — the handler defers. The chip body (_chip_target in web_chat/pdf_from_url_action_emission.py) shows the verbatim URL the token binds — scheme://host/path plus the query, always surfaced (truncated to ?<first 48 query chars>…(+N chars) when long, so a big opaque query stays a visible decline signal). It is shown from the literal URL string (only surrounding whitespace + non-printable chars stripped, so a crafted URL can’t inject a fake line into the body; no urlparse round-trip — what’s read is the bound URL, not a reconstructed normalization) — RFC 0095 §3.4 / ADR 0148 (6d), narrowing the HITL display gap. This is a display narrowing, not the anti-tamper binding: the dashboard-side independent expected_args copy (RFC 0090 §4) remains the tamper guarantee and is still a follow-up. One residual is pre-existing + egress-gated, deferred: a pathologically long userinfo (https://<long>@host/) precedes the host, so front-truncation can still push the host past the cut — egress (6a/6b) is the destination control, this chip is defense-in-depth. _chip_target is shared by the S2 router-proposal leg and the live deterministic from_url leg.
  2. Redeem → brain render port (S7). On the operator’s Approve, the dashboard POSTs /v1/chat/approval/redeem. _execute_redeemed routes pdf.from_url to an injected brain executor port (_pdf_from_url_executor_dependencyruntime.execute_pdf_from_url_redeem; the mail-broker dispatch arms can’t reach the Steel session or the owner-scoped attachment store, so the port is wired in app.py). Default None ⇒ the arm fails closed without affecting any mail-broker tool.
  3. Deterministic render (S7). agent_tools/pdf_from_url.run_pdf_from_url_sync opens THIS gateway’s own Steel session (_resolve_browser_client_slug — a client renders in its own container, never the operator’s; owlet w/o slug fails closed), navigates, re-checks check_egress on the RESOLVED url (catches a 30x redirect to an internal host before any render), then renders via CDP Page.printToPDF (PlaywrightBrowserSession.print_pdf — headed-safe; Playwright page.pdf() is headless-only and Steel runs headed). Egress is fail-closed on both the requested and resolved URL.
  4. Owner-scoped store + delivery (S7/S7b). The bytes are materialized as an owner-scoped attachment under the redeemer’s RuntimeScope (_materialize_file_attachment_for_scope — no SSE turn exists at redeem), and the redeem response carries a RedeemedAttachment (attachment_id / mime_type / kind / alt) lifted from the per-item result by _redeemed_attachment_from_per_item. The dashboard chip’s resolved view renders a download link to the existing owner-scoped serve route (GET /v1/chat/attachments/{id}, ADR 0122).

Failure posture (fail-closed throughout) is enumerated in docs/operations/FAILURE_MODES.md rows 374–385 (render fail, redirect-recheck, printToPDF unavailable, Steel-down→502, attach-fail with no token re-spend, executor-unwired, cross-tenant slug). Governed in shadow as browser.session.execute (PDF plan S5). Default-off behind STEELMOTH_RUNTIME_PDF_FROM_URL_ENABLED; enablement + a live Steel printToPDF smoke-test + HARD RULE 2 browser verify gate going live.

background_run.create redeem arm (ADR 0206)

Section titled “background_run.create redeem arm (ADR 0206)”

The long-task background offer’s approval chip (minted by the bounded tool loop — see the runtime page’s “Long-task offer in chat”) redeems through the same /v1/chat/approval/redeem route. _execute_redeemed dispatches background_run.create to _execute_background_run_create before GSuite identity resolution (a pure world.background_runs Postgres insert — no Google/mail-broker call): ONE inert status='queued' / source='chat_offer' row via background_run_store.create_run. The OWNER is server-derived from the redeem session’s runtime_scope via owner_main_chat_key (INV-007, never the token body); the delivery CONVERSATION is the token’s server-bound chat_key canonical arg, which the loop bound at mint time (INV-022 — it is not otherwise available at redeem); request_text is replayed byte-for-byte from the hash-bound args. The resolved chip shows a friendly queued outcome line; missing args fail closed with no row written. Nothing executes in the request — the background-run scheduler claims the row.

GSuite identity resolution at redeem (_resolve_redeem_identity + D3b)

Section titled “GSuite identity resolution at redeem (_resolve_redeem_identity + D3b)”

The redeem route is a second GSuite credential chokepoint, separate from the interactive owner gate (runtime._resolve_gsuite_tool_gate, D3 of the client Google OAuth program). _execute_redeemed resolves identity in two layers:

  • _resolve_redeem_identity — an OPERATOR token (no provider_grant_id) returns the mark/moth service-account identity, unchanged. A provider-bound token first re-validates the grant (exists / owner / active / provider-match), failing closed (GRANT_UNAVAILABLE / GRANT_NOT_ACTIVE / GRANT_PROVIDER_MISMATCH) before any Google call.
  • _resolve_redeem_oauth_context (D3b) — for a CLIENT’s validated calendar grant, loads the opaque vault handle (provider_grants.load_active_oauth_grant) into a gsuite.auth.ClientOAuthContext, set around _execute_calendar_cancel so build_service runs as the client’s OWN Google (_CLIENT_OAUTH_IDENTITY) and reset in finally. The operator path and any non-build_service provider (gmail is mail-broker mediated; _REDEEM_OAUTH_PROVIDERS = {google_calendar, google_drive}) return None → byte-identical. INV-007: a client never reaches the operator service account; a vanished grant fails closed. Inert until the client-link minter (D4/D5) populates a calendar provider_grant_id.

Per-client Google connect endpoints (routes_integrations_google, Model 2)

Section titled “Per-client Google connect endpoints (routes_integrations_google, Model 2)”

The connect surface that composes the D1–D4a primitives into a client-linking flow. The runtime’s role is deliberately narrow and INV-024 / HR10 clean: it mints the consent URL and books the grant, but never receives the authorization code, exchanges it, or writes vault material — that is the privileged prontera-side / dashboard provisioner’s job (Model 2, mirroring how the operator’s service-account credential is provisioned out-of-band and only ever read by the runtime).

  • GET /v1/integrations/google/consent-url — admin-only. Builds the Google consent URL via gsuite.auth.build_google_consent_url (calendar + drive read; gmail excluded — it stays mail-broker mediated for clients). The GCP app client_id + redirect_uri resolve through the broker (GOOGLE_OAUTH_APP_CLIENT_ID / GOOGLE_OAUTH_REDIRECT_URI); until they are provisioned the builder raises and the route fails closed 412.
  • POST /v1/integrations/google/grants/complete — admin-only. Called AFTER the provisioner has exchanged the code and written the refresh token to the per-client vault. Records the google_calendar + google_drive grants (provider_grants.record_oauth_grant). The body carries no owner_id and no vault handle: both derive server-side — the owner from the authed WebChatScopeResolver scope, the handle from credentials.client_oauth_credential_ref(owner_id, provider)oauth-refresh-<owner>-<provider>. So a caller can only ever link its own owner to its own handle; it can never point a grant at another tenant’s refresh token (the confused-deputy hazard). extra="forbid" rejects a smuggled owner_id.
  • GET /v1/integrations/google/status — admin-only. Reports which providers the gateway owner has actively linked (load_active_oauth_grant).

Inert until the GCP OAuth app + the prontera vault-write land (D5, externally gated): consent-url 412s, and a recorded grant resolves to an empty handle and fails closed at the gsuite chokepoint — never a service account (INV-007).

  • POST /v1/integrations/google/disconnect — admin-only. The dashboard Unplug action: revokes this owner’s active client-OAuth grants under the caller’s own RLS scope (provider_grants.revoke_active_oauth_grants), so credential reads fail closed until the next Connect. The vault refresh-token handle is untouched (broker deletes need their own step-up). Idempotent.
  • Update this page when build_app includes/removes routers or when shared app state changes.
  • For a new dashboard page, cite both the route module and the corresponding dashboard page/component in dashboard.md or a more specific wiki page.
  • Route decorator searches are useful for inventory, but route semantics still require reading the handler and tests.
  • This page inventories route families, not every request/response model.
  • Live auth, internal-hop, and reverse-proxy behavior must be verified against deployment config before exposing any new public route.