Dashboard
The dashboard is a Next.js UI that talks to the runtime web API. The short architecture summary identifies it as the web UI for chat, agents, and settings over HTTP and SSE endpoints exposed by the runtime web-chat backend.
The dashboard visual system is the Claude Design “Steelmoth App.html” handoff:
porcelain/paper light surfaces, carbon/graphite dark surfaces, hairline rules,
and champagne metal accents. globals.css maps those design values into the
existing HSL triplet token contract so legacy hsl(var(--...)) consumers keep
working without per-component rewrites.
The unauthenticated login surface lives in LoginScreen.tsx. On mobile the
form pane carries its own top-right theme toggle because the desktop brand
panel is hidden below md; the large display headline is the transparent moth
mark, not text. The SSO actions are a centered, full-width Steelmoth primary
button followed by a split Google/GitHub row. The lower form row exposes only
Request invite and
Forgot password. Prontera currently has no unauthenticated Authentik recovery
flow: default recovery slugs return 404 and default-password-change is
access-denied without a pending user, so the dashboard links forgotten-password
users to the support reset mailbox until a recovery-flow slice is configured.
The dashboard shell brand mark is BrandMark.tsx, which renders the
transparent moth PNGs from the design handoff: moth-dark-t.png on light
surfaces and moth-light-t.png on dark surfaces. Sidebar and mobile drawer
wrappers preserve the wide moth silhouette instead of squeezing it into the old
square logo slot.
The visible navigation is owned by dashboard/components/layout/sidebar-nav.ts.
The COLLAPSED sidebar (64px rail) renders EVERY resolved item as a direct
icon link under small section-icon headers — full parity with the expanded
sidebar for operator, clients and owlet (operator-directed 2026-07-14;
RailItemLink in Sidebar.tsx). This replaced the section-icon +
CSS-hover-flyout rail, which was unreachable on touch and hid badges; badges
now render on the rail icons, the active ITEM carries the stripe, and
sidebar-icons.test.ts pins every current section label to a real, distinct
icon (Governance/Advanced/Help had all been the Bookmark fallback). The ⌘K
command palette (components/command-palette/CommandPalette.tsx) gained a
“Documents” group (2026-07-14): useForgeDocuments("active") fetched only
while the palette is open AND the caller’s forge flag is on, matched by
title via cmdk’s value filter, opened via deskStore.openDocument +
router.push("/forge"); KIND_ORDER now leads with documents + chats (the
operator’s priority directive) and drives both the group order and the chip
row.
Feature-flagged sections are hidden until /v1/me reports the relevant slice
flag. Runtime-side flag parsing lives in dashboard_flags.py and route wiring
lives in the FastAPI app under web_chat/app.py. (For an OBSERVER the flags
come from the operator brain’s GET /v1/system/dashboard-feature-flags
instead — PR-3 S3.2 — because /v1/me stays on their own gateway, whose flag
set differs from the operator’s.)
Roles and the observer read-only view (ADR 0225, fix stack 2026-07-24/25)
Section titled “Roles and the observer read-only view (ADR 0225, fix stack 2026-07-24/25)”Three roles: admin (Mark), user (a client), and observer (a teammate
with a READ-ONLY view of the operator sidebar). The observer is a client in
their own world (own gateway, own chat/workspace via the TesterShell) plus
a read-only audit view of the operator surface. The render model is a third
view-mode (ViewMode = "admin"|"user"|"observer"): AdminOnly gates render
for admin OR observer, isAdmin stays false (every write affordance gated on
it stays hidden), and the observer keeps their own-world userOnly rows
while operator rows with colliding labels disambiguate (observerLabel) or
collapse same-href twins (observerHidden). API classification is per
endpoint, not per prefix (OPERATOR_READ_API_PATHS — see route-access.ts);
the 4 formerly secrets-classified pages open with their secret fields
redacted at the runtime (redact-not-hide). A persistent “Viewing operator
data — read only” banner marks every operator-surface page (S4.2), and write
controls on those panels are visibly disabled via useReadOnly() (S4.1) —
the server refusal (require_admin / the canonical pin) is the real lock.
Forge (Forge PR-3) adds one operator-only Workspace row — labelled Desk
since the Desk revamp (/forge, adminOnly + featureFlag:"forge", so it is
hidden from clients and until the forge slice flag is flipped) — plus a
document canvas that opens beside the chat from the live “Open in Desk” chip. The
row is purely additive (HR7-bis) and, being adminOnly, needs no
CLIENT_ALLOWED_PREFIXES change (the route-access.ts drift-guard stays green
untouched). ChatPane returns the pre-Forge tree byte-identically while the
canvas is closed. The full Forge dashboard surface (page, canvas, editor,
serializer, downloads, and the forge_document SSE wire fix) is documented on
forge.md.
The Desk revamp renamed this surface to “Desk” wherever the user sees it
(sidebar-nav.ts label + LampDesk icon, the page eyebrow/title/metadata,
“New Desk item”, the ForgeDocumentSurface badge fallback, the “Open in Desk”
chip, and the #desk composer tag paired with the runtime #forge alias). The
route path (/forge), the component/file names, the forge feature flag and the
forge REST/tool internals are ALL unchanged — labels only (HR16). Three
behaviours ride the same page, all gated on BOTH focusActive AND
usePathname() being /forge, so no other page is ever affected and leaving the
page restores everything:
- Help button removed site-wide (2026-07-14). The floating “?”
HelpButtonFAB was deleted on every page (operator-directed; the second HR7-bis-confirmed removal after the 2026-07-13 Desk-only one — the operator was shown the rule conflict and explicitly chose the site-wide removal).components/help/HelpButton.tsx+ its per-route content map (lib/help-content.ts) are gone; the/helpwiki PAGES (app/help/page.tsx+lib/help/help-docs.tsx+ the separatecomponents/help/help-content.tsx) are untouched. The bottom-right corner now belongs to the Desk Focus button on /forge and is empty elsewhere; the chat-bubble FAB keeps its one-slot-above position on every page for consistency. - Focus mode (
stores/deskStore.ts, zustand + localStoragedesk-focus). A bottom-right circular Focus button on the Desk page togglesfocusActive; while active,Topbarrendersnull(top bar + breadcrumb hidden),Sidebardrops to its 64px rail, and the page header is hidden so the documents list gains vertical space (theForgeWorkbenchheight math is made focus-aware). The rail is the focus DEFAULT, not a lock (operator-directed 2026-07-13): the expand/collapse buttons and ⌘B stay usable and flip the EPHEMERALdeskStore.focusSidebarExpandedflag — never the storeddashboard.sidebar.collapsedpreference — so leaving focus (or the page) restores the user’s saved sidebar shape and each focus activation starts back at the rail (navCollapsed = deskFocus ? !focusSidebarExpanded : collapsed). The interception lives in ONE seam —useSidebarState.toggleCollapseditself — so the header buttons and ⌘B can never drift. The exit affordance is ALWAYS visible: an “Exit focus” pill, theEscapekey (guarded — not while a dialog is open), orCtrl/Cmd+Shift+F; leaving/forgealso restores chrome.DeskWorkspace.tsx(a client component) owns the page frame, the Focus/Exit affordances and the keyboard handlers, soapp/forge/page.tsxstays a thin server component that still exportsmetadata. - Chat bubble (
components/desk/DeskChatBubble.tsx, mounted SITE-WIDE bycomponents/desk/GlobalChatBubble.tsxin the root layout — operator-directed 2026-07-13; before that it was Desk-focus-only). Renders on every authenticated page EXCEPT/, after a mounted gate (its state seeds from localStorage, so SSR must not render it). A bottom-right panel mounting<ChatPane variant="bubble" />— the SAME one canonical conversation as the homepageChatPane(samechatStore+useConversations()seed +useHistorycache), so a message typed in the bubble also appears in the homepage chat.ChatPanegained an optionalvariant?: "full" | "bubble"prop that DEFAULTS to"full"and renders byte-identically to the pre-variant tree when absent (every bubble-only change isisBubble-gated);"bubble"drops the ForgeCanvas aside, the mobileinset-0takeover and the floating mobile ModelPicker, and puts the composer in-flow. Single-lease rule: the bubble renders on every route EXCEPT/, and the homepageChatPaneonly ON/— mutually exclusive, so exactly one live ChatPane ever holds the runtime’s single SSE stream lease. Because the mount lives in the persistent root layout, an open panel / streaming reply / unread badge survive client-side navigation. Collapsed, it is a FAB: on DESKTOP one slot ABOVE the bottom-right corner (right-4 md:bottom-[4.5rem]), on MOBILE at the bottom-LEFT with uniform 1rem insets (bottom-4 left-4, operator 2026-07-25 — bottom-left is free on every page, the Focus button owns bottom-right), and the OPEN panel also bottoms out atmd:bottom-[4.5rem]— so neither state can ever cover the Desk Focus button that owns that corner on /forge (the #1312 overlap lesson; review-confirmed for the panel too; the “?” HelpButton that used to share the corner was removed site-wide 2026-07-14). It ALWAYS starts collapsed for a fresh session (seeding it open from the persisted Desk-focus flag would pop the panel over whatever unrelated page the session lands on); Desk focus activation auto-opens it (setFocusActive(true)setsbubbleCollapsed:false). The ChatPane inside is LAZY-LATCHED: it mounts on the FIRST expand (a full ChatPane costs 2 chat GETs + a persistent SSE stream — a hidden always-mount would pay that on every page load site-wide) and stays mounted from then on; collapsing only toggles CSS visibility (hidden), so the lease + streaming state survive; the FAB shows an unread badge (counted from first use onward). A bubble send carriespage_context {kind:"forge_document", document_id}ONLY while the pathname is/forge(a doc staged in the store but no longer on screen is never reported), so “make the intro shorter” edits the open document; onmessage_completethe bubble — ONLY while on/forge, the same gate as the send side (review-confirmed: a “this document” toast on/settingswould be nonsense) — invalidates["forge"]to refresh the workbench, with a draft-clobber guard (an unsaved draft ⇒ refresh only versions + list + toast, never the open detail). A liveopen_forge_documenthint auto-opens the doc in place on/forge; from a bubble on ANY other page it rides the same chat→Desk handoff the homepage chat uses (stage +focusActive+requestDeskNavigation()→DeskOpenRedirect). BecauseSendRequestisextra="forbid", the RUNTIME must be deployed BEFORE the dashboard or every bubble send is a 422 (seeforge.md). The current admin/operator sidebar sections (2026-06-23 restructure), in order, are Workspace, Memory, Governance, Morpheus, Agent Authority, Operations, Settings, and Advanced. Memory is a top-level category. Since the 2026-07-04 client hub merge, a client seesManage(/memory/hub— a chip-switched Search / Browse / Review page that reuses the three admin panels unchanged),Knowledge Graph, andAgent Profile, all memory-flag-gated. The operator additionally sees the now-adminOnlyRetrieval,Manage, andReviewrows (the three tools the client hub merges) plus the other operator rows (What Luna Learned,Living Documents,Luna).CLIENT_ALLOWED_PREFIXESinroute-access.tsgains/memory/huband drops the three old prefixes in lockstep. Settings groups Integrations and the Tokens tree.
The Memory · Manage surface (MemoryManageSurface) renders, per record row, a
read-only confidence-state pill (Memory Control Panel Phase 1): grounded →
“Confirmed”, provisional → “Tentative”, contradicted → “Conflicted”, superseded
→ “Outdated”, with a neutral “Ungraded” fallback when the grade is absent (the
row is never dropped — HR7-bis). A StateSeg segmented control (a role=tablist
clone of the existing Current/Archived StatusSeg) adds a state filter that
defaults to “All” (no predicate, every grade shown) and narrows to one grade
server-side so paging stays truthful. The badge labels are deliberately distinct
from the Current/Archived lifecycle words even though the underlying
confidence_state and status enums share the superseded/contradicted
tokens. This is additive: the kind ledger + counts, search, paging, Archive, and
Restore are unchanged, and no edit/promote/downgrade control is added (later
phases). It is backed by one nullable confidence_state field threaded through
the existing GET /v1/memory/objects contract chain (no migration — the column
exists on every memory family since the initial schema) plus one optional,
validated confidence_state query param applied as a parameterized per-family
predicate alongside the existing status predicate.
Morpheus (its own category) and Agent Authority (its
own category, led since 2026-07-25 by Overview — an at-a-glance all-time
stats landing page backed by GET /v1/operator-authority/stats; Active Sessions stays directly below it) were lifted out of Settings and
Governance respectively. Governance groups the governance activity/review/options
pages, plus the admin-only Browser Logs page (/governance/browser-logs,
dashboard/components/admin/governance/BrowserLogsPanel.tsx — task run
outcomes from GET /v1/browser-tasks?include_finished=true and the browser
slice of the decision log via the additive capability query param on
GET /v1/governance/decisions; PRD 0050 / RFC 0108 / ADR 0194). Advanced groups Mail Broker, env overrides, a Memory maintenance
sub-group (Object Inspector / Consolidation / Admin Repairs / Backfill Status),
the runtime Tools/Browser entries, and the Wiki. Operations groups the
agents/access-requests/provisioning/pending-approvals pages.
In user/Owlet mode, the Memory section no longer exposes a separate Living
Documents item. The living-documents route is surfaced under the Memory category
as Memory > Agent Profile (a userOnly shortcut relocated out of Settings,
2026-06-30). The same Memory category also shows the client Memory > Agent Lessons row — the per-audience userLabel for the operator’s What Luna Learned
item; the operator still sees What Luna Learned. Both client rows are
flag-free so they render together for a client even with no memory flag. The
desktop Owlet sidebar keeps Workspace expanded regardless of persisted section
state, renders Workspace, Memory, Settings, and Governance rows as children of
their section labels, and uses user-mode icon overrides for Chat, Todos,
Reminders, Settings, and client-facing Memory. Operator/admin mode keeps the
existing section expansion behavior, icon set, and Memory > Living Documents
placement. The vertical-line rail under Workspace / Memory / Governance /
Settings (the section-level verticalRule flag) now renders in BOTH operator and
client views; Browser tasks is operator-only (adminOnly, removed from the
client route allowlist in lockstep).
The Advanced section is hidden in user mode, so Docs, Operations, Endpoints,
Health API, Chat API, and Settings API are not client sidebar entries. The
client route allowlist mirrors that visible surface: clients keep access to
their own chat, todos, reminders, living documents, settings, integrations,
governance activity/review/options, team overview, and inbox routes, while
Advanced routes such as /docs/wiki and /endpoints redirect for clients.
Within Living Documents, clients may open the index, the five persona detail
routes, and /memory/living-documents/learning-profile. Learning Profile is
the client-facing alias for the owner-scoped user_model_summary document.
The client editor calls the dashboard API with the same learning-profile
alias; the runtime maps that alias to user_model_summary server-side and
rejects raw user_model_summary for non-admin callers.
Operator-only detail routes such as
/memory/living-documents/agent_system_prompt, tool_playbook,
operational_notes, and user_model_summary redirect for clients but remain
reachable for admins. The detail page validates route kinds server-side, then a
client audience gate waits for /v1/me before choosing the admin editor or the
client editor so operator-only admin routes do not cold-load as false 404s
while current-user state is still pending.
For the agent_system_prompt document specifically, the redesigned in-place
sheet (LivingDocsSheet) and the admin detail panel both render a shared
read-only EffectiveSystemPromptDefaultBlock — the runtime-assembled “Current
effective default” the assistant runs on when no operator override is set
(GET /v1/system/effective-system-prompt-default). This was restored to the
redesigned sheet, which had shipped without it, so the operator can always see
the live system prompt even when the override document is empty (INV-002 /
HR7-bis). It is read-only and never writes the override.
The /docs/wiki dashboard route is a read-only user-facing wiki browser over
docs/system-wiki, backed by /v1/wiki/*. It is intentionally separate from
the engineering standards editor; the wiki API is admin-only and read-only.
The Mail Broker dashboard surfaces are behind the mail_broker feature flag.
The Settings sidebar section exposes the admin /workspace/mail-broker page
and /integrations for brokered Gmail connection state. Mail Broker
health/control/connect/audit destinations are no longer separate sidebar
sub-anchors; they remain reachable from the Mail Broker page itself. The
Google connect form on /integrations posts through
/api/integrations/google/connect, which currently self-links the canonical
Gmail account through the runtime service-account endpoint and maps missing
tenant credentials to the service_account_unavailable page banner. The OAuth
callback route remains wired for a future handoff path. /integrations now
renders three cards over one shared Google grant: Gmail (read-only), Google
Calendar (read-only calendar layering), and Google Drive (read-only
search/read).
/integrations also carries three UX controls. A client-side search box
(IntegrationsScreen.integrationMatchesSearch) filters the card list only —
never the WebsiteCredential/Passkey panels above or the header status pills,
which always count the full set (HR7-bis). An operator-directed
BLOCKED_INTEGRATIONS set (in integration-seed.ts) renders named cards
greyed-but-visible: they resolve to the disabled state with a short reason and
show their real connect affordance made inert inside a disabled fieldset, rather
than being removed. And each connected, capability-managed card shows a gear
beside its status pill that opens IntegrationCapabilityDialog; the dialog reads
the runtime capability catalog through the /api/integrations/capabilities proxy
(operator → runtime via capability-prefs.ts; enrolled client → own gateway via
client-capability-prefs.ts, INV-007) and PUTs the full unchecked action set.
The dashboard only records the preference — runtime enforcement ships
separately; the catalog fetch is fail-closed to an empty set (no gear, page
intact).
The Anytype card is in the knowledge category. Its connect form accepts the
user’s API key, a non-secret API origin, and an API route layout. The operator
form defaults to the NAS Tailscale listener
http://100.112.139.47:31012 with cli_v1; Steelmoth appends /v1 to that
origin. desktop_v1 remains selectable for legacy desktop API rows, and stored
cli_root values normalize to cli_v1. Operator and enrolled-client flows
both use the generic token step-up connector; the client form writes the same
client-anytype-token alias inside that client’s own vault. Authentik returns
through /login, where the shared callback table maps ay. to the operator
callback and cay. to the client callback before the broker write completes.
After the write, the dashboard marks the owner connection row active with the
validated API origin and layout. Disconnect revokes that row without returning
or deleting the broker value.
The same form exposes a default-off inbound-chat option. Enabling it requires the exact Anytype space ID, the dedicated “Ask Steelmoth” chat ID, and a comma-separated exact creator-ID allowlist. These values are non-secret connection metadata; the runtime flag must also be enabled before a listener starts. The user creates and invites the dedicated Anytype CLI identity, generates its API key, and selects these IDs. The dashboard and runtime do not perform account creation, key generation, space joining, or chat discovery.
The Telegram card on /integrations posts a bot token to
/api/integrations/telegram/connect. The route validates the token shape
before touching the broker, creates a client-mark-telegram-bot-token write
challenge, stores the plaintext and PKCE verifier only in a five-minute
server-memory pending map, and redirects through Authentik with
prompt=login, max_age=0, the broker nonce, an S256 code_challenge, and
the provider-advertised acr_values when present. The flow uses
https://app.steelmoth.ai/login as the OAuth redirect_uri; the /login
route relays Authentik code/state or error/state query params to
/api/integrations/telegram/callback, where the callback exchanges the
Authentik code with the stored code_verifier, sends the returned ID token as
X-Steelmoth-Step-Up-Token, and writes the secret through the prontera broker.
The broker challenge authorization resolves the Authentik OAuth
access_token from a short-lived server-side cache keyed by an Auth.js
access_token_ref; sessions without a valid cached reference are rejected with
telegram_session_refresh_required before any prontera write-challenge call.
The connect/callback routes build their redirects from the
forwarded public host/proto, not the container-internal origin, so the
operator’s browser stays on https://app.steelmoth.ai through the step-up
flow. The token is not sent to chat, the runtime API, memlink, or client-side
storage.
An enrolled client (owlet) now sees the same /integrations surface as the
operator — the Website Credentials panel plus the Gmail/Calendar/Drive cards
and the Telegram card — instead of the earlier stripped view (additive; the
client surface only grows, HR7-bis / INV-002). IntegrationsScreen takes an
optional typed readiness map (websiteCredential / google / telegram)
that defaults to all-true; the operator call site omits it, so the operator
render is byte-identical. The client branch of page.tsx derives readiness
from three default-off, fail-closed flags
(STEELMOTH_DASHBOARD_CLIENT_WEBSITE_CREDENTIALS_ENABLED,
STEELMOTH_DASHBOARD_CLIENT_GOOGLE_ENABLED,
STEELMOTH_DASHBOARD_CLIENT_TELEGRAM_ENABLED, read via
clientIntegrationReadiness in dashboard/lib/integration-principal.ts). With
every flag off — the default after merge — each surface renders present but
non-interactive (the card’s “Available in your workspace” affordance and a
non-interactive Website Credentials placeholder that keeps its heading/copy;
WebsiteCredentialPanel gains an optional viewOnly), so the slice merges
inert with no broker change. The client branch never calls the operator status
fetchers, hard-nulls googleEmail (no operator address leaks), and restricts
its banner/success-modal derivation to the website_credential_* status
family. Turning a flag on would let the client cards POST to the
operator-scoped /api/integrations/* routes, which is a later client-scoped
backend/broker phase — hence the flags stay default-off for now. The website-credential
connect + resume API routes additionally enforce the same clientIntegrationReadiness().websiteCredential
flag server-side (defense-in-depth): a client principal whose flag is off fails
closed with a website_credential_disabled status, so the readiness gate cannot
be bypassed by POSTing past the UI. Operator principals are never gated (they do
not carry the client kind).
The scheduling reminders page (/scheduling/reminders) now includes a
CalendarSourcesCard in the right rail beside the weather and reminder form
cards. It reads /v1/scheduling/calendar-sources, toggles one source through
POST /v1/scheduling/calendar-sources/{calendar_id}, and invalidates both
calendar-source and calendar-event query keys so the month grid re-fans out
after each toggle.
The Automations page (/scheduling/browser-tasks) carries a labelled
Triggers section (TriggersSection.tsx, background work lane / ADR 0205)
below the automations list and the unsubscribe follow-ups — purely additive
(HR7-bis), dual-mounted in BOTH the <AdminOnly> and fallback positions like
its siblings, and owner-scoped the same way (/v1/triggers is require_authed
and server-clamps to web_chat_key(user.email)). It lists standing trigger
rules (“when an email from X arrives, do Y” / “N minutes before a matching
calendar event, do Z”) with a status pill per rule (Draft / Armed / Paused /
Cancelled). A rule drafted from chat waits here for the operator’s Save &
switch on button — the ratification gate that POSTs /v1/triggers/{id}/arm
(the ONLY draft→armed path; the store’s arm_trigger initializes the
no-backlog checkpoint server-side). Pause/Resume are direct reversible
mutations with no confirm dialog (the UnsubscribeFollowups “Mark done” idiom);
Cancel is terminal and sits behind a confirm dialog (the BrowserTasksPanel
idiom). Each expanded rule offers a lazy “Recent fires” disclosure
(GET /v1/triggers/{id}/fires fetched only when opened) whose rows link the
run_id each fire started. A chat chip’s “Review & Save” deep link lands as
?draft=<trigger_id>, which highlights and auto-expands that draft row so Save
is one click away. Data layer: dashboard/lib/triggers-queries.ts
(browser-tasks-queries conventions — inline keys, 30 s staleTime, retry: false mutations invalidating the bare ["triggers"] prefix).
UserSettingsPanel.tsx renders the user-settings tab. The #user-notifications
fieldset now contains a Channels sub-section listing the seven canonical
notification channels (Inbox, Telegram, Email, Slack, WhatsApp, Instagram,
Webhook) in server-canonical order returned by /v1/settings.user.channels
(PRD 0029 / RFC 0064 / ADR 0084). Configured channels render as toggleable
checkboxes; unconfigured rows render greyed with an aria-disabled checkbox
and a muted (Not configured) suffix. Save now PATCHes the typed
channels: [{name, enabled}] shape; the legacy notifications shape is
still accepted by the runtime for back-compat but is no longer in the
outbound body. The fieldset id #user-notifications is preserved (HR7-bis /
INV-002 — visible surface does not shrink); the three pre-existing toggles
(Desktop, Email, Chat) are reincarnated as the canonical Inbox / Email /
Telegram channel rows.
UserSettingsPanel.tsx also exposes a Strict email allow list checkbox
directly below the Self-learning toggle in #user-profile (PRD 0049 /
RFC 0107 / ADR 0193, 2026-07-03). Default unchecked: email sends skip the
recipient allow-list scan and are gated by the approval chip alone; checked
restores the fail-closed allow-list block. It reads/writes
user_settings_profile.strict_email_allowlist_enabled (migration 0126)
through the same GET/PATCH settings contract (toFormState maps a missing
field to false so the panel stays safe against an older runtime).
UserSettingsPanel.tsx also exposes a Self-learning Ability toggle
(relabeled from “Morpheus self-evolution”, plain-English copy, 2026-06-30)
under the #user-profile section (PRD 0033 Phase 2.5a, default-on). It
reads/writes user_settings_profile.morpheus_auto_enabled through the
PATCH route; the deploy-wide MOTH_HYBRID_MORPHEUS_AUTO_ENABLED env var
remains the global kill switch, mentioned on the operator copy as a generic
“deploy-wide kill switch” caveat (the env-var name is no longer shown in the
UI). The runtime begins honoring this toggle once Phase 2.5c activates the LLM
consolidation adapter — Phase 2.5a only plumbs the read/write end-to-end. The
same control is also surfaced to clients on the Agent Profile page
(/memory/living-documents, user view) via the exported
SelfLearningAbilityCard, which reuses the same morpheus_auto_enabled PATCH
contract but saves immediately on toggle and omits the kill-switch caveat.
The Settings User rail appends #user-delete-account after Profile and
Notifications. DeleteAccountSection.tsx renders the destructive client
account deletion UI: an empty type-your-email confirmation input, a disabled
destructive button until the typed value matches the signed-in email
case-insensitively, a direct browser POST to
/api/v1/clients/me/delete-account, inline error handling, and persistent
scheduled/already-scheduled result blocks. Admin/operator sessions keep the
section visible but render only an informational note; the operator view does
not render the form or issue the deletion request.
Reminder delivery (out-of-process daemon at
steelmoth-runtime/scripts/reminder-daemon.py, core loop in
steelmoth_runtime.reminder_daemon.run_cycle) now enqueues per-channel
delivery intents into steelmoth.notification_outbox instead of calling
providers directly. Each enqueue writes a synthetic steelmoth.sidecar_runs
row for provenance; one-shot reminders are marked sent when at least one
enabled channel enqueue succeeds. steelmoth-notifier performs delivery from
the outbox; explicit reminder channels (email, inbox_only, dashboard,
webhook) are preserved, while preference auto-routing is only used for
legacy default rows. The dashboard reminders page is not affected — the
change is in the daemon/outbox delivery path. The loop now also emits
reminder_daemon.stale_cycle_alert (default 120s threshold) and
reminder_daemon.backlog_alert events when due reminders persist across
multiple cycles.
The Tokens dashboard section is admin-only and gated by the tokens feature
flag. sidebar-nav.ts exposes /tokens, /tokens/by-model, /tokens/by-lane,
and /tokens/events. The overview page shows 24h, 7d, and 30d totals, top
models, top lanes, and recent events; the by-model and by-lane pages show
sortable 30-day breakdown tables; the event log supports 24h/7d/30d range
presets, model/lane/provider filters, cursor pagination, and a Tool column
backed by the source_tool field for external proxy callers. The dashboard
query layer mirrors the runtime /v1/tokens/summary, /v1/tokens/events, and
/v1/tokens/series contracts, while the current pages consume summary and
event feeds. The current lane/provider dropdown options are defined in
LlmEventFilterBar.tsx; external-tool rows are visible through the Tool column
even though the lane selector currently does not enumerate every backend lane.
Dashboard session identity separates operator identity from route-session
authentication. dashboardEmailFromClaims() remains fail-closed to the
canonical Authentik email for operator-only paths, while
hasDashboardSessionIdentity() accepts any non-empty Auth.js email so enrolled
client sessions can reach client-allowed routes such as /scheduling/todos;
the /api/v1/* proxy still enforces enrollment and gateway routing for
non-operator users. The helper no longer synthesizes emails from username,
subject, or name claims. Frontend admin checks now trust the server role only:
dashboard/lib/auth.ts:isAdmin() returns true only for user.role === "admin".
The docs-host edge gate checks the Auth.js session’s dashboard-admins group
claim because middleware cannot round-trip to /v1/me; private loopback
bypass defaults to the same dashboard-admins group but only carries
username/groups when the configured private email is canonical. The /api/v1/*
proxy returns 403 for non-canonical private identities before forwarding
trusted runtime headers. The proxy strips inbound x-steelmoth-internal-*
headers and re-injects the internal runtime hop credential from a server-only
secret file before calling runtime upstreams; runtime-like containers receive
only the SHA-256 digest used to verify that header.
dashboard/auth.ts stores only a short-lived access_token_ref in the Auth.js
JWT/session for broker-boundary step-up challenge calls; the real OAuth
access_token stays in server memory and is rehydrated per request. It also
exposes endSessionEndpoint() derived from AUTHENTIK_ISSUER. The Auth.js JWT
no longer stores raw OIDC tokens, which keeps protected-route response headers
smaller on callback/sign-in completion.
The sidebar account footer uses the current user’s display name, then username,
then email fallback. The account dropdown shows the signed-in identity, links
My Settings to https://app.steelmoth.ai/settings, and exposes Sign out. The
separate footer settings cog is not rendered. The Sign out control navigates to
/api/dashboard/logout, a plain route that expires Auth.js dashboard cookies,
including chunked session-token cookies and configured cross-subdomain variants,
before redirecting to Authentik’s end-session endpoint. Middleware treats the
logout route as public so it can clear already-stale sessions. Logging out
directly at auth.steelmoth.ai only clears the IdP cookie; dashboard logout
must also clear the app.steelmoth.ai Auth.js session cookie.
Public mode defaults to the Steelmoth Authentik issuer
https://auth.steelmoth.ai/application/o/steelmoth-dashboard/, while the
cross-subdomain redirect allowlist accepts HTTPS Steelmoth hosts under
steelmoth.ai. The dashboard entrypoint
can resolve AUTH_SECRET and AUTHENTIK_CLIENT_SECRET from prontera broker
aliases into temporary files. The public deploy script feeds the entrypoint a
one-shot token through /run/steelmoth-dashboard-token, and the entrypoint
deletes that token file immediately after reading it. Since Agent Vault 0.22
requires member-level authority to mint temporary sessions and the runtime
identity is intentionally proxy-only, the entrypoint can use that proxy token
directly, in memory only, for the read-only broker secret fetch before unsetting
it. A Node preload reads the dashboard secret files after process start so
Auth.js can use normal runtime env without raw secret values appearing in
Docker inspect or the process startup environment.
The Admin Assignments settings section is now a read-only viewer for the
configured Authentik admin group and the current actor’s membership status. It
does not PATCH admin emails or group membership; the admin_emails client
write mapping is removed, the server rejects admin-email env PATCHes, and
actual membership changes happen in Authentik.
The chat surface creates an empty pending assistant bubble immediately after
send, swaps it to the acknowledged server id when /v1/chat/send responds,
and buffers incoming SSE token_delta text separately from the assistant text
currently visible in the transcript. useTypewriterStream advances the visible
prefix with requestAnimationFrame, renders the first streamed character
immediately, clamps long hidden-tab elapsed intervals, and accelerates the
tail after completion without dumping the whole buffered reply in one frame.
message_complete marks the bubble completed, keeps the accumulated token
text as the typewriter target, warns if server final_text diverges, and only
allows the persisted history twin to replace the streaming row after
typewriterDone is true. Completed message ids are remembered for the current
conversation so post-completion SSE reconnects cannot replay a fresh transient
streaming bubble over the persisted reply. Stop snaps the visible prefix to
the buffered text, marks the turn stopped, and ignores later events for that
message id.
Each chat message carries a Reroll + Reply-to action row (chat-reroll-reply
plan). Hover a bubble (desktop) or long-press (mobile) to reveal, in the shared
message-actions row: Reply (message-quote-reply, both user and assistant
bubbles) quotes the message into the composer as a chip and rides the next send
as reply_to {message_id, author}; Regenerate (message-reroll, assistant
bubbles only, finished + non-errored) fires useRerollMessage() →
POST /v1/chat/reroll and the fresh answer streams in as a NEW tail bubble via
the existing channel-turn path (append-only — the old reply stays). The quote
lives in chatStore.quotedReply (a distinct namespace from the ADR 0187 2FA
replyTarget; the quote chip renders in the composer’s NORMAL branch so it is
naturally suppressed while the 2FA ReplyComposer is mounted). A quoting user
bubble renders a compact quoted block (message-quoted) above its body that
jumps to the quoted message on click (the ⌘K anchor path); it survives reload
via the history endpoint join. On mobile the long-press opens the 2FA reply when
a code-ask is present (exact prior behavior) and quote-reply otherwise. Nothing
was removed — this is additive to the 2FA reply affordance (HR7-bis).
The chat shows a progressive staged status line instead of a tool/duration
timeline. On send, ChatPane sets a stable liveTurnKey (the clientMessageId,
held for the whole turn so it survives the streaming.messageId mutations) and
the useStagedStatus hook shows an instant stage-1 line, advances one generic
status line every ~1.5 s (capped at five in-progress stages), and settles on a
terminal stage-6 “Done”-pool line when the turn completes (liveTimelineState →
complete). message_start also sets liveTurnKey as a fallback so
channel-originated turns get a status line too. The status lines live in
chat-status-stages.ts (six pools of ~200 generic, task-agnostic phrases) and
are picked deterministically by a seeded hash, so re-renders never reshuffle them
and no tool name or duration is ever shown.
ChatPane still builds liveToolSteps from the SSE planner/tool events, but only
to size the scroll reserve — the steps are no longer rendered. ToolUseTimeline
renders the staged status (a staged prop): plain text the same size as the
reply but faded, a very slow soft left→right shimmer while running, and a
trailing > that expands the line — as a left-bordered, indented block — to
reveal the status phrases it moved through.
MessageList renders the streaming bubble from the typewriter-visible
renderedText, keeps the spiral streaming loader visible until the typewriter
is done, and keeps a dynamic bottom reserve while the sent-message anchor or
active stream needs space. At end-of-turn it holds the last reserve until the
persisted assistant twin lands, the conversation changes, or the user
explicitly scrolls to the bottom. This keeps short completed replies
from collapsing the transcript while history refetches catch up. If a late stream update arrives
without an active anchor, MessageList keeps using the held reserve instead
of replacing it with a zero active reserve. After history has loaded for a
non-empty conversation, MessageList performs an automatic scroll to the
latest message the first time the current conversation key is rendered after
mount or key change; it skips that restore while scrollAnchorId,
streaming, or streamingActive is present so active turns keep the
send-anchor scroll behavior. ChatPane hides any persisted
assistant twin while the typewriter is still draining. If message_complete
has fired, the typewriter is done, and no persisted assistant twin appears in
history, ChatPane keeps the streaming bubble for the first 2.5 seconds and
then releases the bubble, live timeline, and scroll anchor after 3 seconds as a
safety net. useHistory disables reconnect-triggered history refetches so a
network reconnect does not manually refresh the transcript during a live turn.
MessageBubble no longer renders a separate empty-text thinking placeholder;
the live staged status line is the progress cue while streaming. The composer
returns focus to the textarea immediately after Send.
For persisted history rows, MessageList renders finalized
message.reasoning_blocks as collapsible ThinkingSection blocks above the
reply body when the runtime supplied readable reasoning summaries/content. If no
reasoning block exists, persisted rows render no extra thinking or settled-status
block above the assistant reply (the canned staged-status fallback was dropped in
#512). The real message.tool_timeline is no longer shown (no tool
names, no durations). While a turn is still streaming,
live reasoning_delta events feed the same ThinkingSection affordance and
the live timer-driven staged status remains the fallback when no readable
reasoning stream exists.
MessageBubble chrome is intentionally minimal: it renders no avatar and no
visible sender name (role reads from row alignment plus the user bubble tint,
which uses the --rule token; a screen-reader-only label preserves per-message
attribution). The timestamp and copy-to-clipboard action share a single
hover-revealed bottom row (sm+ only), so neither is visible until the message
row is hovered. The bottom row also renders for attachment-only messages so
their timestamp is not lost. The spiral streaming loader plus stopped/error
chips still render in a compact status row above the body when present.
Phase 10 attachments render in both modes: ChatPane parses the additive
attachments array on tool_call_end (intermediate, e.g. screenshot tool
output mid-turn) and message_complete (terminal, captured on the final
bubble) SSE events and threads them into MessageList; MessageBubble
renders image attachments as inline thumbnails via GET /v1/chat/attachments/{attachment_id}.
Memlink history round-trips attachments through the metadata_json.attachments
back-channel, so a reload after a long-running turn preserves the rendered
image instead of degrading to text-only.
User-uploaded attachments use the same render surface. InputBar keeps the
full Attachment metadata returned by POST /v1/chat/attachments, passes it
to ChatPane for the optimistic user row, and ChatPane still sends only
attachment_ids to /v1/chat/send. If a matching persisted user history row
arrives without Message.attachments during a backend/memlink catch-up window,
ChatPane overlays the optimistic attachments onto that row so the chip does
not disappear mid-turn. MessageBubble renders non-image user attachments as
download chips through GET /v1/chat/attachments/{attachment_id} and omits the
empty user text bubble for attachment-only sends.
The composer (InputBar) is a rounded card whose + attach menu, textarea, and
send/stop button sit on one vertically-centered row: a 42px circular +
(surface fill + hairline border) whose Photos item opens an images-only
picker (png/jpg/gif/webp) and Documents item opens a pdf/txt/md picker — two
scoped <input> accept lists over the one upload route (POST /v1/chat/attachments), which still enforces the real server allowlist (so
executables / office / svg are 415’d; svg + office are follow-ups). Below them are
visual-scaffold-only Research and Apps → Drive items (Apps reveals a
right-hand flyout reusing the google-drive ProviderLogo mark; neither is wired
to behavior yet), a single-line auto-grow textarea (24px
line that grows to a max then scrolls internally), and a 42px circular send
button (it morphs to a Stop square while streaming). The earlier composer chips
(Attach / Deep think / Use memory), the composer token counter, and the “Moth can
make mistakes” disclaimer footer were all removed. The topbar no longer shows the
runtime · ok · p50 status pill or the Codex-limit “Resets in…” timer.
The composer also carries a “#” app-tag autocomplete: when the caret sits at
the end of a # + letters token that starts a word, a small cmdk picker pops
up above the composer card (chrome matches the attach menu; row anatomy matches
the command palette rows) listing the canonical app tags — Gmail, Calendar,
Drive, Tasks, Todos, Web, Browser, PDF, Reminders, Weather, Contacts, Code,
Devices. Detection, filtering, dismissed-token memory, and insertion are pure
helpers in dashboard/components/chat/app-tag-autocomplete.ts (the vocabulary
mirrors the runtime’s steelmoth_runtime/tool_router/app_tags.py, canonical
tags only); the popup itself is
dashboard/components/chat/AppTagAutocomplete.tsx. Row-reference shapes like
#2/#g2 never open it; nothing is ever auto-inserted — only an explicit
Enter/Tab/click splices #tag (always with a trailing space, since two tags
without a space between them parse as zero tags server-side), and the caret is
restored in an effect after the state commit. While the popup is open, a
guard-first branch at the top of the composer’s keydown handler owns
ArrowUp/ArrowDown/Enter/Tab/Escape and early-returns, so an Enter that accepts
a suggestion can never fall through to send; Escape dismisses the current token
until it changes.
Mobile chat PWA chrome (2026-06): on mobile (<md) the chat shell goes
chrome-light. The burger
becomes a floating, transparent, drop-shadowed Steelmoth logo top-left
(Sidebar) that opens the same nav drawer; and the bottom tab bar (MobileBottomBar) is removed — its
destinations stay reachable from the drawer. The drawer gains a search row (opens
the ⌘K command palette) plus the theme toggle, above the nav. Since
2026-07-25 (operator) the top bar also renders on mobile — no longer
hidden md:grid — as a shorter transparent strip whose only job is anchoring
the collapsible icon tray (TopbarIconTray: reload / pinned / ghost / theme
as a vertical stack of round filled chips, the menu/close toggle chipped too)
at the top-RIGHT corner (Topbar.tsx; the right cluster is pinned to grid
column 3 because the hidden col-1 search wrapper would otherwise shift grid
auto-placement and centre the tray). The standalone mobile ghost-chat FAB
that floated at top-[5.5rem] was folded into that tray the same day
(operator-directed; ghost stays reachable on / one tap away). The chat stream
fills to the top edge and scrolls up behind the floating logo/tray with a top
fade scrim — ChatPane goes absolute inset-0 on mobile to cover main’s top
padding, MessageList pads its first message down (max-md:pt-[88px], matching
the bumped send-anchor offset), and a from-background → transparent gradient
keeps messages legible under the chrome. The composer floats over the stream
(translucent + backdrop-blur) rather than sitting in flow, and the composer
textarea is exactly one line while empty (it only grows once the user types — an
empty field’s scrollHeight reflects the placeholder, which wraps on mobile). The installed PWA is locked to portrait: the dashboard
manifest sets orientation: "portrait" (the declarative lock Android obeys),
and PortraitLock adds screen.orientation.lock('portrait') + a guarded
force-portrait CSS rotate fallback for iOS / browser tabs. All of this is
mobile-only (md:/max-md:); the desktop sidebar/topbar shell is unchanged.
The memory living-document detail panel treats owner id as display-only. It
derives the visible owner from the selected client or runtime fallback, omits
owner_id from revision write payloads, and relies on the runtime memory API
to inject the canonical owner.
The dashboard shell is host-aware. layout.tsx renders a minimal no-sidebar
view when the request host is docs.steelmoth.com, while the normal
authenticated dashboard host keeps the full sidebar/topbar shell on desktop
(mobile uses the chrome-light chat variant described above). The auth
middleware also applies host-aware policy: docs-host requests require a
dashboard-admins session for non-public routes. In docs-host mode, layout
also registers /sw.js and the metadata manifest is host-aware so the install
profile is branded as Steelmoth Wiki. The authenticated dashboard manifest is
branded simply as Steelmoth and its app/touch icon family is generated from
the transparent moth mark on the carbon app background, replacing the old
pre-rebrand icon tile. The authenticated dashboard host now registers the same
service worker, mounts the offline banner, and renders the install prompt. The
install prompt follows the app design’s
graphite/champagne sheet treatment: a dim scrim, 52px app-icon tile, folio
steps, and full-width primary action for iOS add-to-home-screen guidance. On
non-iOS browsers it prefers the native beforeinstallprompt event, but falls
back to generic browser-menu install instructions when the event is suppressed
or unavailable. Only explicit Not now / Got it actions persist the
14-day suppression; closing the sheet or tapping the scrim hides only the
current prompt, and the old pre-redesign suppression key is cleared on load.
The service worker precaches only the public offline shell and icon/logo
assets, uses network-first navigation, and bypasses API, login, and OAuth paths
so protected dashboard/wiki responses are not persisted in browser caches.
Sign-out flow: the avatar dropdown invokes the signOutFromDashboard server
action defined in dashboard/app/login/actions.ts. The action clears the
Auth.js session cookie (via signOut({ redirect: false })) and then redirects
the browser to Authentik’s end_session_endpoint with
post_logout_redirect_uri (no id_token_hint). This still clears the local
session and routes through Authentik logout; the post-logout browser now lands
at /, where the root-route auth gate renders the no-shell login view for
unauthenticated visitors. Private-loopback mode short-circuits to /
because the env is the identity and there is no Auth.js session to destroy.
The Authentik application steelmoth-dashboard on Prontera must allowlist
https://app.steelmoth.ai/api/auth/callback/authentik and
https://app.steelmoth.ai/ under its redirect and post-logout URI settings;
the same list governs authorization callbacks and post-logout redirects. The
dashboard CSP form-action allows https://auth.steelmoth.ai as the
Authentik form target. The CSP also sets img-src 'self' data: (RFC 0095 /
ADR 0148 Phase 6c): the browser refuses external image hosts, so a
model-supplied markdown image cannot auto-exfiltrate on render. Every
legitimate image is same-origin — the /public logos and chat attachments
served via the same-origin /api/v1/chat/attachments/... proxy
(NEXT_PUBLIC_API_BASE=/api) — so nothing visible breaks; MarkdownContent
renders a model image as a click-through link as the in-renderer companion.
steelmoth.app is a redirect alias only and must not proxy the dashboard
directly.
The Automations page (/scheduling/browser-tasks,
dashboard/app/scheduling/browser-tasks/page.tsx) composes three owner-scoped
sections, each dual-mounted in BOTH the <AdminOnly> children and its
fallback positions so the admin-only-must-gate lint finds an enclosing
gate while client sessions still render their own owner-clamped view:
BrowserTasksPanel (scheduled browser automations), UnsubscribeFollowups,
and — background work lane, PRD 0058 / ADR 0203 — BackgroundRunsSection
(dashboard/components/admin/scheduling/BackgroundRunsSection.tsx +
dashboard/lib/background-runs-queries.ts). The Background runs section lists
this owner’s off-chat runs from GET /v1/background-runs in status buckets
(Running / Awaiting approval / Paused / Queued plus Completed / Failed /
Cancelled behind a “Show finished” toggle), expands a row to lazily fetch the
run’s recorded step ledger from GET /v1/background-runs/{run_id} (re-polled
every 30 s only while the run is live; a terminal run’s detail never polls),
and offers Pause/Resume as direct no-dialog text buttons (reversible soft
signals) with Cancel behind the section’s confirm dialog. Affordances gate on
explicit statuses and the pause_requested_at/cancel_requested_at
soft-signal timestamps, never on display labels. Purely additive (HR7-bis) —
no sidebar or route-access change; the page was already client-visible.
Inside BrowserTasksPanel’s create/edit form (2026-07-25): the mode radio
reads Repeat (was Daily) and the time field Time — display-only label
renames; the wire recurrence stays daily@HH:MM everywhere. Repeat mode also
offers an optional from/to date pair mapping to the nullable
starts_on/ends_on DATE columns on world.browser_tasks (migration 0151):
inclusive calendar days in the task’s timezone, each independently optional,
NULL unbounded so existing rows are unchanged. The route clamps the first
firing to starts_on, the finish path stamps finished_at once the next
computed firing falls past ends_on, and a malformed date, ends_on < starts_on, or a zero-firing window is a 400 INVALID_ARGUMENT; the same
window threads the chat tool’s BrowserTaskCreateArgs (INV-028 parity), and
the dashboard omits the keys from the payload when blank. The form’s timezone
default also can no longer capture UTC before the saved zone resolves:
runtime.get_web_user_profile is now really implemented (it reads
steelmoth.user_settings_profile.timezone through the shared attached
repository, fail-open to None so /v1/me stays 200-best-effort) and the
panel’s loading gate waits on /v1/me before rendering.
Dashboard work has repo-specific hard rules. CLAUDE.md requires canonical
dashboard deploy paths, public/private fingerprint parity, visible browser
verification for UI behavior, and no unapproved build or container recreation.
It also forbids shrinking the visible dashboard surface unless the operator
explicitly confirms that scope.
Maintenance Notes
Section titled “Maintenance Notes”- Update this page when dashboard routes, sidebar surface, API relationship, or deploy verification rules change.
- Pair dashboard page changes with the corresponding
web_chat/routes_*.pysource when the page depends on runtime data. - Do not add dashboard system-wiki editing without design artifacts, auth classification, tests, and page contracts.
Known Unknowns
Section titled “Known Unknowns”- This page does not inventory every dashboard route or component; use the sidebar source and app directory for exact UI coverage.

