*Skald dev-docs — architectural reference for coding agents. Index: [README.md](README.md) · Entry point: [../CLAUDE.md](../CLAUDE.md)* **Read this when:** you touch anything under `web/` — components, chat tabs, routing, i18n, theme, the security-group picker. --- # Frontend components (`web/components/`) All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/chat-session.js`) is the shared base for WS-connected chat UIs. **The chat is the home page.** `` is a single persistent element with two layout modes driven by the route (`llm-page-change`): `mode="full"` on the home route (it fills the workspace — the conversation IS the landing page, with a welcome hero + prompt suggestions as its empty state) and `mode="dock"` on every other route (the classic resizable side panel). Same element ⇒ WS, tabs, scroll and drafts survive navigation; you watch files/projects update live while the conversation keeps going. Collapse only applies to the dock. The old dashboard content (hero, LLM stats charts, pending inbox, quick guide) lives on as the separate `#dashboard` page; the debug toggle moved to the Settings page. **Two kinds of tab, and the difference is what a tab names.** A **primary** tab is a *source*: it shows whatever `web` / `project-7` currently points at (`sources.active_session_id`), which is also where background delivery lands — `notify`, a finished async task, an inbound Telegram message — and what a `/new` moves to a fresh row. At most one per source; a project's **Open chat** always lands on it and never mints a conversation (`provision_session(reset:false)`). A **secondary** tab is one specific conversation, opened with `+`: its source points elsewhere, so it is **unreachable by source name** and is addressed by id everywhere — REST, WebSocket, event filtering. Nothing is delivered to it from outside. `POST /api/sessions/new` creates one *without touching `sources`*, which is the entire difference from `POST /api/sessions` (a reset). Its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context. **The queue and the model pin are keyed by conversation, not by source** (`ChatHub.inboxes: HashMap`, `selected_clients: HashMap`). This is the load-bearing half: two tabs on one source would otherwise serialize into one queue and one turn, and share a `/model` pin — while the *security group* was already per-session and persisted, so the pin was the odd one out. The source-taking methods survive as one-line resolvers (`send_message` → `send_message_to_session`, and `_for_session` twins for context/cost/compact/mcp/model/cancel/resume/upload), so Telegram, mobile and cron are untouched. Cost of the rekey: queues now grow with conversations-talked-to-since-boot rather than with the four-or-five sources, so a reset **retires** the queue it replaces (`retire_inbox` → `ConversationInbox::close`, consumer breaks) instead of leaving a parked task forever. **Events are filtered per conversation** (`ge.session_id == Some(session_id)`), which is why anything a chat must see has to carry a session id — an untagged `GlobalEvent` now reaches nobody. Two emitters had to be fixed for exactly that: `show_file_to_user`'s `OpenFile` (the tool takes a `session_id` from `handler.session_id` via the interface-tools builder) and `revalidate_security_groups`, which now returns `(session_id, source, group)`. The inbox lifecycle events (`Approval*`/`Clarification*`/`Elicitation*`) stay the deliberate exception and go to every connection, since they carry ids only and drive the sidebar badge. A **primary** WS connection additionally follows `NewSession` for its source — re-binding `session_id` and its handler mid-loop — so a second window doesn't keep talking to a conversation another window just reset; a session-addressed one ignores it, having been pinned on purpose. **The tab bar is server-side state; the selection is not.** Which conversations the copilot shows survives a reload through `chat_sessions.is_open` (owner table, additive via `ensure_column`) — `GET /api/sessions/open` restores them (computing `primary` per row, since only `sources` knows), `PUT /api/sessions/{id}/open` opens/closes one, `PUT /api/sessions/{id}/title` renames one (`title` predated all this and was dead; an empty title stores `NULL`, so the rename box is also the undo). It is deliberately *not* localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. **Which** tab is selected stays in `sessionStorage` (`copilot-active-tab`), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) `is_open` defaults to **0** and `chat_sessions::create` never sets it — every `/new` leaves its predecessor behind and every system-agent pass mints a row, so `DEFAULT 1` would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset **moves** the flag: `provision_session(reset)` mints a new row, so `POST /api/sessions` returns the new id and the `new_session` event carries it, and `_bindTabSession` closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens *before* `super.connectedCallback()` (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS. **Theme** (`web/css/variables.css`): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (`--radius-sm/md/lg`), 16px-base chat type, WCAG-fixed contrasts, global `:focus-visible` ring and `prefers-reduced-motion` support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet. **i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1). **Plugin & backend i18n** — two seams, both keyed the same way. A plugin **page fragment** (served from its own router) localizes client-side: it ships a `web/i18n.js` module (`export default { en, it, fr }`, keys namespaced `plugin..`) and calls `addStrings(dicts)` (in `web/lib/i18n.js`) once at module load to merge into the host's shared `DICTS`, then uses the same `t()`/`I18nMixin` as the app (the fragment imports them from the absolute `/lib/i18n.js` — the *same* module instance the host uses, so `t()` and `locale-changed` are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: `common.js` registers the dict + re-exports `t`, and `MobileBase extends I18nMixin(LitElement)`. **Backend-generated strings** (a plugin's HTTP error/response text, notifications) go through `core_api::i18n`: a plugin declares `Plugin::i18n() -> Vec` (mobile-connector loads them from embedded `i18n/{en,it,fr}.json` via `include_str!`), the `PluginManager` merges every plugin's bundles once at boot into an `I18nCatalog` (`skald_core::i18n`) and injects it as `PluginContext.i18n: Arc`. At request time the handler resolves the caller (`Caller.user_id` from the auth layer) and calls `i18n.for_user(user_id, key, args).await` — which reads `users.locale`, runs it through the same `resolve_locale` chain, and renders `locale → en → key` with `{name}` placeholders. The frontend surfaces these already-translated: `jf()` throws the server's response text verbatim. Front and back keep **separate** tables (UI labels ≠ error strings; overlap is minimal) but share the `plugin..` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far. **Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. **Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. **Selection is gated once; the persisted group is re-checked on every load.** `validate_run_context_for_role` runs at *selection* time, and the result is persisted on `chat_sessions.run_context` — so on its own it let a group survive the role that granted it, indefinitely and across restarts (revoke `ops` from a role, and every session that had already picked it kept running on it). The fix is a second, narrower seam: `run_context::reconcile_group_for_user`, run by `ChatSessionManager::get_or_create_handler` on **every** handler build, which treats the stored group as *advisory* and degrades it when the owner's current role no longer allows it. Three properties are load-bearing: (a) it degrades to the **role's default group** (`role_default_group`, the same seam `sessions.rs` uses for a new session, so start-group and fallback-group cannot drift) — **never to `None`**, because a missing group means the catch-all `default`, whose rules are the fallback tier under every other group, so clearing *widens*; (b) it touches **only** `security_group`, unlike the selection path, so a project session's server-built `project_root`/`system_prompt` survive a permissions edit; (c) on uncertainty (unknown user, unreadable role, DB error) it leaves the stored group alone — guessing could only widen. The liveness half is `Skald::revalidate_security_groups_for_{user,role}`, called **synchronously** from the roles API (`update`) and the users API (role reassignment), which reconciles already-open handlers, persists, and emits `SecurityGroupSelected` so the pill re-syncs. Same rule as revocation: authorization is pushed, never left to the bus. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`) + a **default-assistant** select (→ `attrs.chat_agent`) fed by `GET /api/agents` filtered to `type:chat` minus `project-coordinator` (source-driven); the same exclusion is enforced server-side in the roles API (`validate_chat_agent`). | File | Element | Notes | | ---- | ------- | ----- | | `copilot.js` | `` | The chat surface (`_wsSource='web'`): full/dock roving layout, welcome hero empty state, privacy chip, composer with model pill, slash-command autocomplete | | `shared/chat-page.js` | `` | Mobile chat (`_wsSource='mobile'`) | | `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page | | `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); inbox badge is **live** — the chat WS forwards the inbox lifecycle events (`approval_requested/resolved`, `clarification_*`, `elicitation_*`) regardless of `source`, `chat-session.js` re-dispatches them as the `inbox-changed` window event, and the sidebar (+ `agent-inbox.js`) refreshes on it; a 60 s poll remains as fallback | | `topbar.js` | `` | Top nav bar; per-user avatar color hashed from the username | | `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide | | `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | | `file-viewer-page.js` | `` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` | | `shared/file-viewer-mobile.js` | `` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button | | `agents.js` | `` | Agent discovery and config | | `agent-inbox.js` | `` | Pending approvals + clarifications from background sessions | | `approval-rules.js` | `` | Approval rule management | | `cron-jobs.js` | `` | Scheduled job management | | `connectors.js` | `` | MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the **Add connector** dropdown (Marketplace / manual form at `#connectors/new`), per-row removal from the catalog, and the **Sign-in providers** modal (§7/§14/§15) | | `plugin-catalog.js` | `` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | | `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + a **read-only** roster of who holds it, linking to `#users/{id}` (plugin twin of `connector-detail.js`) | | `users-page.js` | `` | `#users` list + `#users/{id}` one user's page: Profile, **Connectors**, **Plugins**, Security. Both grant sections are the single write path for "what may this person use" | | `plugin-page-host.js` | `` | Host for plugin-contributed pages (`#plugin//`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `system-agents.js` | `` | `#system-agents` — one tab per background agent (plus "All"): its description, its settings (admin only) and the caller's own run history. Everyone sees the page; only an admin gets the config half | | `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` | | `shared-folders.js` | `` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context | | `projects/` | `` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section | | `files-page.js` | `` | `#files` — the caller's whole space. Level 0 is the **virtual root** (`GET /api/files/roots`), level 1 the shared ``. See the Files section | | `shared/file-explorer.js` | `` | The explorer itself, host-agnostic: `root` + `rootLabel` + optional `rel`, `can_write` read from the listing. Used by `#files` and the project board | | `connector-detail.js` | `` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section, with the plugin grants right below it), so "who has what" has a single surface | | `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch | | `llm-providers.js` | `` | LLM provider management | | `models-hub.js` | `` | Models hub landing (LLM / Transcription / Image) | | `models-llm.js` | `` | LLM model CRUD + drag-and-drop priority | | `models-transcribe.js` | `` | Transcription model CRUD | | `models-image.js` | `` | Image generation model CRUD | | `mobile-app.js` | `` | Mobile app shell | | `shared/settings-page.js` | `` | Mobile settings: per-user avatar, locale picker (`I18nMixin`), profile/preferences |