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.
Route Families
Section titled “Route Families”| 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. |
Planned Mid-Turn Control Design
Section titled “Planned Mid-Turn Control Design”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.
State Boundaries
Section titled “State Boundaries”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). ReusesPostgresAttachmentStore.materializewithsource_tool="composer_upload"and a neworigin='user_upload'column. Returns the typedAttachment-shaped response plus anextraction_statusfield.POST /v1/chat/send— extended withattachment_ids: tuple[str, ...] = ()onSendRequest(preservesextra="forbid"). The send handler resolves each id againstowner_id = scope.owner_id; mismatch → 404 (the same 404-not-403 contract asroutes_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.
- extractors + composer wiring + history persistence + feature flag
- 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 atruntime.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:
- Plan + chip (S4/S6). A URL in a PDF request routes to the
from_urlaction; the runtime mints a single-use approval token (tool_name = "pdf.from_url",args_hashover the canonical URL) and emits anapprove_actionchip. No fetch happens yet — the handler defers. The chip body (_chip_targetinweb_chat/pdf_from_url_action_emission.py) shows the verbatim URL the token binds —scheme://host/pathplus 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; nourlparseround-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 independentexpected_argscopy (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_targetis shared by the S2 router-proposal leg and the live deterministicfrom_urlleg. - Redeem → brain render port (S7). On the operator’s Approve, the dashboard
POSTs
/v1/chat/approval/redeem._execute_redeemedroutespdf.from_urlto an injected brain executor port (_pdf_from_url_executor_dependency→runtime.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 inapp.py). DefaultNone⇒ the arm fails closed without affecting any mail-broker tool. - Deterministic render (S7).
agent_tools/pdf_from_url.run_pdf_from_url_syncopens 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-checkscheck_egresson the RESOLVED url (catches a 30x redirect to an internal host before any render), then renders via CDPPage.printToPDF(PlaywrightBrowserSession.print_pdf— headed-safe; Playwrightpage.pdf()is headless-only and Steel runs headed). Egress is fail-closed on both the requested and resolved URL. - 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 aRedeemedAttachment(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 (noprovider_grant_id) returns themark/mothservice-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 agsuite.auth.ClientOAuthContext, set around_execute_calendar_cancelsobuild_serviceruns as the client’s OWN Google (_CLIENT_OAUTH_IDENTITY) and reset infinally. The operator path and any non-build_serviceprovider (gmail is mail-broker mediated;_REDEEM_OAUTH_PROVIDERS = {google_calendar, google_drive}) returnNone→ 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 calendarprovider_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 viagsuite.auth.build_google_consent_url(calendar + drive read; gmail excluded — it stays mail-broker mediated for clients). The GCP appclient_id+redirect_uriresolve through the broker (GOOGLE_OAUTH_APP_CLIENT_ID/GOOGLE_OAUTH_REDIRECT_URI); until they are provisioned the builder raises and the route fails closed412.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 thegoogle_calendar+google_drivegrants (provider_grants.record_oauth_grant). The body carries no owner_id and no vault handle: both derive server-side — the owner from the authedWebChatScopeResolverscope, the handle fromcredentials.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 smuggledowner_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).
Maintenance Notes
Section titled “Maintenance Notes”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_appincludes/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.mdor a more specific wiki page. - Route decorator searches are useful for inventory, but route semantics still require reading the handler and tests.
Known Unknowns
Section titled “Known Unknowns”- 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.

