Files
Skald-Circle/CLAUDE.md
T
dguiducci daaceff6ba
Nightly Build / build (push) Successful in 7m34s
feat: show a conversation its own background tasks, and give it back every outcome
An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.

Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.

The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.

The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.

`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.

Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
2026-08-04 19:13:30 +01:00

120 KiB
Raw Blame History

Skald (project-family) — codebase guide

Rust async web app (Tokio + Axum). Runs as a local chat server with LLM tool-calling and a sub-agent system.

Never git commit unless explicitly asked. Staging, building, running and testing are fine on your own initiative; creating a commit is not. Do the work, leave it in the working tree, and let the user commit — or ask them to — even when a commit looks like the obvious next step.

Commit messages must be in English.

What this repository is

A dedicated fork of Skald, turning a single-user personal agent into a multi-user assistant for a small trusted group — positioned at families, but see the neutrality rule below.

The design lives in blueprint/project-family.md. Read it before any architectural work; its sections are referenced by number (§0.1 neutrality, §5.1 database layout, §11 UserManager, §12 auth schema, §16 LLM privacy tiers, §17 sequencing). The blueprint/ directory is gitignored and not under version control — treat it as the source of truth, and never assume a section says what you remember.

Load-bearing decisions from that document:

  • Not upstreamable. Nothing here needs to preserve Skald's schema or be portable back to it.
  • Greenfield — no longer true. The instance is in production. There are live users with data we cannot recreate, so the greenfield licence (restructure, rename, wipe, recreate) has expired: every schema change now needs a versioning mechanism, and "drop the box and re-run setup" stopped being an acceptable answer. Until that mechanism exists, the only safe change is an additive one through db::ensure_column (see the DB section); anything that renames, drops, retypes or moves a column or table is blocked on building schema versioning first, not something to do carefully by hand. A user's {userid}.db is SQLCipher-encrypted and readable only while they are logged in, so a migration cannot be a boot-time sweep over every file — it has to run per user, at unlock, and be idempotent. Design for that when the time comes.
  • Dual memory: a private per-user pool plus a shared pool. A user's private space is encrypted so that nobody else — the admin included — can read it through normal use of the system. Never claim "mathematically impossible": the honest promise is transparency plus verifiability (§3).
  • Threat model (§2): the adversary is the tempted admin, who owns the box but does not recompile the binary or dump RAM. Do not design against a forensic attacker.
  • Roles are data, not enums (§0.1): a roles table binds permission-group, run-context and data-handling attributes. "Children" is a seeded preset row, never a hardcoded type.

Event-driven coupling — think in events, not calls

Three global broadcast buses — never add a fourth without checking these first:

Bus Cap Events File
ChatEventBus 256 user message, assistant response, compaction done core-api/src/bus.rs
SystemEventBus 64 provider (un)registered, config key updated, job completed, session cancelled, user created/deleted/active-changed/mounts-changed, global connectors changed, connector reinstalled, report created core-api/src/system_bus.rs
GlobalEvent (per-user) 512 all ServerEvent variants → WS clients + inbox lifecycle core-api/src/events.rs

Plus internal mpsc queues: per-source SourceInbox (message serialization) and a central notify queue (background agents → user).

The user-lifecycle reconciler is the worked example of the rule. Creating a user, deleting one, deactivating one, or changing a shared-folder/project membership all need Docker work (provision, tear down, stop, recreate with new bind mounts); enabling or reinstalling a connector needs live runtimes re-snapshotted. None of the endpoints that make those changes touches ContainerManager or the refresh helpers: each announces SystemEvent::User{Created,Deleted,ActiveChanged,MountsChanged} / McpGlobalServersChanged / ConnectorReinstalled after its DB write, and one subscriber — skald::wiring::spawn_user_lifecycle, spawned post-construction because it reacts through Skald's own accessors, holding only a Weak — does the reacting, sequentially and best-effort. Being off the response path matters for ConnectorReinstalled in particular: it re-copies files and restarts servers inside every live user's container, seconds of work the admin's install no longer waits on. The payoff is that a future endpoint granting membership cannot forget to remount, because remounting was never its job. Reactions never block the HTTP response, and a failure settles at the user's next login or at boot reconciliation.

Where the bus stops: reconciliation rides it, authorization does not. SystemEventBus is a lossy 64-slot broadcast whose contract is "best-effort, settles at the next login" — right for a stale mount, wrong for a revocation, where "settles later" is the failure. So deactivating or deleting a user splits in two: Skald::revoke_user_runtime runs synchronously in the handler, before it responds (revoke every session → evict + cancel the UserContextUserManager::lock, in that order, so nothing is left querying a pool we then close and the DEK leaves RAM per §9), while only the container half — stop or remove — rides the bus. Before this, active = 0 blocked the next login but left live sessions working: login checks the flag, require_auth only maps token → id. Same split for security groups (see the picker section) and for connectors, where the test is worth internalising because the call is literally the same function: Skald::refresh_global_mcp_access is announced (McpGlobalServersChanged) when a global connector is enabled or deleted — the first only makes something appear, the second is already enforced by stop_server — but called directly from global_set_access and user_connectors_set, where set_access/set_for_user replace a grant set and the refresh is what actually revokes. Both sync call-sites carry a DELIBERATELY SYNCHRONOUS comment, because they look identical to the announced ones. Never put an access revocation on a bus.

Before you add a direct function call or a new import between two components, stop and ask: is one component producing data another needs? If yes, add a variant to an existing bus and spawn a subscriber. Don't call some_manager.log_thing(...) from the producer — emit a ThingHappened event on SystemEventBus and let the manager subscribe.

A new mpsc::channel or broadcast::channel is a code-review flag. Nine times out of ten you want one of the three buses above. If you truly need a new one, be ready to explain why none of the existing three fits.

The core is domain-neutral — this is a hard rule

"Family" is positioning, not architecture. Schema, engine, API, identifiers and comments must never contain family, household, parent, child or minor. A pivot to teams, small orgs or care settings must not require renaming anything.

Domain concept Technical primitive
the group implicit — it is the instance. No group entity. Future multi-group ⇒ tenant / workspace, never family
shared memory memory/shared
parent / admin role admin
child / minor a data-driven role defined by the admin
"the parent reads the child's data" a generic supervision edge between users

Domain words are allowed only in seed data, preset labels, UI copy and positioning.

Current state

UserManager (§11) is now consumed. Login exists (crates/skald-core/src/auth/mod.rs: SessionStorelogin/user_of/logout plus revoke_user, the admin-side "drop every session of this user" used by Skald::revoke_user_runtime; the deny-by-default middleware is src/frontend/api/guard.rs, whose require_auth maps token → id and does not re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by skald-setup), and the per-user owner-bound runtime is UserContext (crates/skald-core/src/skald/user_context.rs) — resolved by Skald::user_context / the frontend's require_context, keyed off UserManager::pool_of, and carrying its own CancellationToken (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, cron) route through the per-user pool; dev/stats read llm_requests — a registry table — from system.db, which is correct. The "owner-without-a-user" question resolved to there isn't one: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (Conversation/Tasks: the "ownerless" ChatSessionManager, ChatHub, cron TaskManager) are still constructed but inert — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because RunContextManager shares the Conversation bundle and is used, being registry-backed). See blueprint §19.

Direction of travel, decided but not yet executed: strip the power-user surface (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a binary-first layout — the app is built once and run from a compiled binary, not executed from its own source tree.

Workspace layout

The application core is the skald-core crate; the binaries are shells around it.

Crate Role
crates/skald-core/ Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no HTTP server and no concrete plugin cratePluginManager only ever sees Arc<dyn Plugin> from core-api
skald (root, src/) The server shell: main.rs, the Axum frontend/, config.rs. Constructs the plugin list and hands it to Skald::new. Runs headless as a background daemon under the run.sh supervisor
crates/skald-setup/ Guided first-run setup — a terminal shell over skald-core. Creates the first admin and seeds the instance through the shared seam skald_core::setup::initialize_instance (apply the chosen seed profile → register_user(admin) → set default locale) — the same function the web setup calls, so the two shells can't drift. Asks profile, interface language, whether to encrypt — default yes — and password. A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same seam. run.sh runs it before the server loop; it prompts only when users is empty and stdin is a terminal, otherwise a no-op. --check reports readiness by exit code (0 done, 1 needed)
crates/core-api/ The contracts both sides share: Plugin, Tool, event buses, provider types

Two rules keep the boundary real, and both are enforced by the compiler:

  • The core never names a plugin. A plugin contributes tools through Plugin::tools(self: Arc<Self>) — the sibling of http_router() — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via plugin-transcribe-whisper-local.
  • The core never learns about the process shell. There is no in-core restart hook — the former restart tool and its tools::restart::set_restart_handler seam were removed. The only coupling to the supervisor is now the run.sh exit-code protocol (exit 255 ⇒ re-exec the same binary by path), a seam no code currently triggers (kept for a future admin-driven restart). The live expression of this principle is skald_core::boot, which emits startup lines each shell renders (src/boot_format.rs here).

Plugin visibility & per-user config. The admin surface is #plugins (plugin-catalog.js), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus #plugin-detail?id=<id> (plugin-detail.js), which holds the instance-config form for one plugin (the plugin counterpart of connector-detail.js). Granting is user-side, exactly like a connector grant: the checkboxes live in the Plugins section of #users/{id} (users-page.js), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from plugin_access on every request that depends on it (sidebar pages, /plugins/mine, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (Plugin::manages_own_access, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is no generic per-user plugin page: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via Plugin::web_pages(), like mobile-connector. Enable/disable + instance config + access grants are gated by the plugin.manage capability (admin-only by construction). Visibility is a row in plugin_access(plugin_id, user_id), which grants a user sight of an enabled plugin (plugin_id is bare TEXT, never a FK — a plugins row exists only after the first toggle); the table is deny-by-default but the rows are written for you at install time — see the default-access section below. Per-user values are stored in plugin_user_configs (admin-readable system.db — never secrets) and applied through the Plugin::update_user_config hook, whose default just stores the blob via the PluginUserConfigApi on PluginContext.user_config. Telegram is the reference impl: its pairing page (a web_pages() fragment with no backend of its own) reads the {linked, chat_id} status blob from GET /api/plugins/mine and submits the code through PUT /api/plugins/{id}/my-config; the override turns it into a chat_id → user_id binding (same write path as the telegram_pairing tool). Endpoints: admin GET/PUT /api/plugins[/{id}], GET /api/plugins/{id}/access (read-only roster) + GET/PUT /api/users/{id}/plugins (the grant write path, the twin of /api/users/{id}/connectors); user GET /api/plugins/mine + PUT /api/plugins/{id}/my-config.

Plugin HTTP routes & web pages. Every plugin's http_router() mounts at boot under /api/plugin/<id>/enabled or not: two shared gates wrap each router (require_auth, then guard::plugin_enabled_gate, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry Cache-Control: no-cache. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute frontend pages via Plugin::web_pages() (PluginPage { page_id, title, icon, entry, admin_only, priority }): GET /api/plugins/pages returns the caller's visible pages (admin: all; others: non-admin_only pages of granted, enabled plugins) with entry_url resolved, and the sidebar renders them as menu entries routed #plugin/<plugin_id>/<page_id>. A single <plugin-page-host> (web/components/plugin-page-host.js) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the plugin-id attribute — the fragment talks to its backend only through /api/plugin/<id>/… and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior.

skald_core::boot emits curated startup lines on the boot tracing target; each shell decides how to render them (src/boot_format.rs here). The core says what happened, never how it looks.

Key modules

Path Role
src/main.rs Thin entry point: tracing → Skald::newWebFrontend::start → shutdown. Builds a tokio runtime and blocks on async_main, which runs the backend until a SIGINT/SIGTERM. Exposes run_backend() / shutdown_backend()
crates/skald-core/src/skald/ Skald — headless application core. mod.rs (struct + staged new() / shutdown()), runtime.rs (cross-cutting Runtime context), bundles.rs (8 domain bundles + build()), wiring.rs (wire() + spawn_background()), supervisor.rs (TaskSupervisor), accessors.rs (per-manager accessor facade — the API surface the frontend uses)
crates/agent-loop/ The LLM loop itself, as a standalone crate: kernel (round loop, fallback, tool fan-out), LoopManager, HistoryStore, projection (history→wire), DelegateTool (sub-agents), recovery.rs (restart), compaction.rs, plus the shipped model clients (models/). Knows nothing about Skald — see the loop section below
crates/skald-core/src/loop_adapters/ Skald's side of that crate's traits: history store, model selector, approval gate, tool set + bridges, agent catalog, event translator, projection knobs, async executor. This is where "how Skald does it" lives
crates/skald-core/src/session/handler/ What is left of the session layer: mod.rs (ChatSessionHandler + handle_message), kernel_turn.rs (the three loop entry points), config.rs, interface_tools.rs, media.rs
crates/skald-core/src/session/manager.rs Creates/retrieves ChatSessionHandler per session
crates/skald-core/src/chat_hub/ ChatHub: broadcast events to all connected WS clients
crates/skald-core/src/chat_event_bus.rs Global async bus for cross-session events
crates/skald-core/src/agents.rs Discovers agents from agents/*/, loads meta + system prompt
crates/skald-core/src/tools/ Built-in tools: exec (runs inside the caller's per-user Docker container via docker exec, as the non-root host uid — sudo for system installs — with a robust /stop that reaps the command's process-group; see container/; the only live path is run_with (needs ToolContext) — the context-free Tool::execute/execute_async now error (HOST_PATH_ERROR) instead of the old host sh -c, so nothing can run a command outside the sandbox), list_agents, fs/* (route user-memory//shared-memory/ to memory_docs, and every other physical path through ctx.fs to the caller's per-user host workspace — see DB tables + container), notify, ast_outline, image_generate, MCP tools, plugin tools, cron tools
crates/skald-core/src/container/ ContainerManager (§6): per-user Docker containers (the execution sandbox). Docker is a hard requirementcheck_docker() fails Skald::new (→ shell exits) if the daemon is unreachable. Builds our own skald-runtime image (python+node+sudo, plus a shell-work toolbelt — jq/ripgrep/unzip/ffmpeg/poppler-utils/tesseract/procps…; tag is versioned skald-runtime:v3 so a Dockerfile change forces a rebuild) once from the embedded Dockerfile, then reconcile_all() at boot ensures one running container skald-{userid} per active user. Each container runs as the host uid:gid (--user, §6 UID coherence) with --init (tini reaps zombies); ensure() self-heals a container that is stale on any of three axes — --user (e.g. an old root one), --init, or the image tag — by recreating it, and injects a passwd/shadow entry post-create so sudo (NOPASSWD, in the image) resolves the arbitrary uid. The image check is what makes a tag bump reach existing users: a container pins the image it was created from, so without it a rebuild would only ever equip new users. build_user_fs() assembles a user's UserFs (home {WD}/homes/{userid}/root, plus each shared/{name} they belong to). Shells the docker CLI (no client crate)
crates/skald-core/src/tool_catalog.rs ToolCatalog: unified tool listing façade (wraps ToolRegistry + McpManager)
crates/skald-core/src/events.rs ServerEvent enum streamed over WebSocket to the frontend
crates/skald-core/src/db/ sqlx SQLite — see below
crates/skald-core/src/users/ UserManager (§11): user directory CRUD on system.db, credential check, and the map userid → SqlitePool of unlocked databases. The pool is the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it
crates/skald-core/src/crypto/ Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts {userid}.db; users.database_password holds it sealed with AES-256-GCM under Argon2id(password, salt). The AEAD tag is the password verifier — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in spawn_blocking behind a 2-permit semaphore (256 MiB per derivation)
src/config.rs Loads config.yml; LLM clients, strength, data root. All relative paths (db, logs, data, …) resolve against the launch cwd
crates/skald-core/src/mcp/ MCP runtimes + the McpProvider seam (§7): the shared host global runtime and the per-user container runtimes, unioned per session as UserMcpView. See the MCP connectors section
crates/skald-core/src/plugin/ Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config
crates/skald-core/src/cron/ Scheduled job runner
crates/skald-core/src/system_agents/ The SystemAgent trait + run_and_record + the shared ephemeral-turn/run-context machinery, plus registry() (the one enumeration of the agents) and memory_lint.rs (the two lint agents). See the system-agents section
crates/skald-core/src/event_triage/ EventTriageManager: one pass of the event-triage system agent for one user. No timer of its own — the instance-wide scheduler is skald::wiring::spawn_system_agents
crates/skald-core/src/compactor.rs Context compaction policy — when to compact and with which model; the mechanics are agent_loop::compaction. The compactor is always constructed (manual /compact must work with no config); compaction.threshold_tokens is Option and arms only the automatic pass, and is unset by default — see the context-size defaults section. Model for the summary call: the instance-wide Settings pick (compaction_model, a PropertyType::LlmModel config property declared by compactor::config_set) wins; else AUTO by compaction.strength (config.yml); a missing configured model degrades to the same AUTO path
crates/skald-core/src/approval/ Approval rules engine
crates/skald-core/src/clarification/ ClarificationManager: background-session question/answer
crates/skald-core/src/elicitation/ ElicitationManager + bridge: MCP server-initiated input (elicitation/create), surfaced in the Inbox; secrets never logged/persisted
crates/skald-core/src/inbox.rs Inbox: unified façade for pending approvals + clarifications + elicitations (wraps ApprovalManager, ClarificationManager, ElicitationManager). The managers already emit the *Requested/*Resolved lifecycle events on the per-user bus; ws.rs forwards them to every connected client of that user regardless of source, so the web UI updates live (see sidebar.js row)
crates/skald-core/src/llm/ LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider types are runtime data, not code: providers/declared.rs loads providers.yaml at boot (see Config); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. Retriability (Model::is_retriable, agent-loop) keys on the real HTTP status carried by ModelError { status }, not a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. Request logging is the logging.rs::LoggingModel decorator, attached by the caller's ModelSelector (loop_adapters/selector.rs::SkaldSelector::with_log) — never by LlmManager, which builds one shared client per model and cannot know whose traffic it serves. The decorator's RequestLogTarget carries the owner: metadata → llm_requests in the registry (user_id, the column the UI filters on), payload bodies/headers → llm_request_payloads in that user's own encrypted DB, keyed by request_id; session + frame come from the request's own conversation/frame, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (ModelRequest::log is unused here)
crates/skald-core/src/transcribe/ Transcription providers
crates/skald-core/src/image_generate/ Image generation providers
crates/skald-core/src/memory/ Agent memory tools
src/frontend/mod.rs WebFrontend: wires router_factory, starts plugins, runs Axum
src/frontend/server.rs Axum router, static file serving
src/frontend/api/ HTTP + WebSocket handlers — State<Arc<Skald>>
web/components/ Lit web components (see below)

DB tables (sqlx SQLite)

database/system.db — the path is a constant (core::db::SYSTEM_DB_PATH), not configurable. init_system_pool creates the directory; SQLite only creates the file. Per-user files are database/{userid}.db, created by UserManager::register_user and encrypted with SQLCipher.

The schema is split into two buckets (§5.1), and the split is the point:

  • create_registry_tables — instance-wide, readable without any user key: users, roles, llm_providers, llm_models, transcribe_models, tts_models, image_generate_models, plugins, plugin_access + plugin_user_configs, approval_rules, tool_permission_groups, config, known_tools, llm_requests, mcp_catalog, mcp_global_servers + mcp_global_access, oauth_providers, role_capabilities, shared_folders + shared_folder_members, projects + project_members, supervision, system_agent_coverage. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); oauth_providers (accessor db/oauth_providers.rs) holds one row per identity provider (Google…) — endpoints + client_id/client_secret + redirect_uri, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: shared_folder_members (accessor db/shared_folders.rs) for the on-disk shared folders (§6), project_members (accessor db/project_members.rs) for projects (see the Projects section) — both let a member be read-only (can_write) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
  • create_owner_tables — one owner's content, identical schema in every file that has it: chat_sessions, chat_sessions_stack, chat_history, chat_llm_tools, chat_summaries, session_scratchpad, session_mcp_grants, stack_mcp_grants, scheduled_jobs, job_runs, system_agent_runs, system_agent_state, mcp_user_servers, mcp_events, sources, secrets, llm_request_payloads, memory_docs (+ FTS5 memory_docs_fts), reports. mcp_user_servers (a user's activated per-user connectors) carries catalog_name as a bare TEXT snapshot of mcp_catalog.name, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots oauth_provider + deliver_json, and its api_key column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because memory_docs is an owner table, one definition backs private memory in each {userid}.db and shared memory in system.db (the household owner) — see the memory namespace note below. (projects/project_tickets were owner tables in the single-user past: projects are shareable now, so projects + project_members are registry tables and project_tickets is gone.)

The schema is no longer greenfield (see the production note at the top): a full recreate is not an option anymore. db::ensure_columnALTER TABLE … ADD COLUMN swallowing the "duplicate column" error, a no-op on a fresh DB where the CREATE TABLE already carries it — is therefore not a convenience for dev boxes anymore but the only change shape that is currently safe, and additive-with-a-default is the shape to design towards. Used for the OAuth columns on mcp_catalog / mcp_user_servers. Anything destructive waits for real versioning.

No foreign key in the owner bucket may point at a registry table. SQLite cannot enforce a key across files, not even through ATTACH, and sqlx turns on PRAGMA foreign_keys: the CREATE TABLE succeeds and every INSERT fails. db::tests::owner_tables_stand_alone_with_foreign_keys_on enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. One key crossed and was fixed: chat_history.model_db_id (dropped — write-only, and llm_requests.model_name already records the model).

Memory namespace (blueprint §5). memory_docs (accessor db/memory_docs.rsget/upsert/list/search(FTS)/delete) backs a virtual note store surfaced through the fs-tools, not the disk. Two sibling roots (not the blueprint's nested memory/{userid} + memory/shared): user-memory/… routes to the caller's own pool (ToolContext::pool), shared-memory/… to the system pool (a singleton captured in fs::register_all). tools/fs/classify_memory() decides on the raw first path component (a .. in the tail clamps inside the store, never escapes to disk); read_file/write_file/list_files/edit_file/insert_at_line/replace_lines/search_file override run_with to route memory paths (each extracting a pure transform shared with its on-disk execute) and leave every other path on disk. The HTTP surface routes them the same way: GET /api/file classifies before resolve_view_path and serves the note from memory_docs (caller's pool / system pool), so the file viewer opens user-memory/… and shared-memory/… like any file, and show_file_to_user accepts memory paths too (existence-checked on the right pool). Approval (seeded in seed_fs_path_rules): user-memory/* is @fs_any allow (private, frictionless); shared-memory/* is @fs_read allow + @fs_write require — reads free, writes need approval so the agent can't silently push one person's data into shared memory. grep_files stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, memory_search (tools/fs/memory_search.rs), over the memory_docs FTS index — allowed by a path-less rule (it takes query, not path).

Supervision + coverage (registry). supervision(subject_user_id, supervisor_user_id) (accessor db/supervision.rs) is the §0.1 supervision edge — a generic directed edge between two users, deliberately attribute-free, whose domain reading ("a parent watches a child") lives only in seed data and UI copy. It answers two questions with one table: whom does a background agent look at (subjects()) and who may read what it produced (supervisors_of(), which is what reports.audience = 'supervisors' resolves against). Both FKs are registry→registry, so the cascade is real in both directions. system_agent_coverage(agent_id, subject_user_id, covered_through) (accessor db/system_agent_coverage.rs) is the per-subject watermark that makes "everything since last time" a window: it sits between system_agent_runs (a history for the human, skips idle passes) and system_agent_state (attempt marker, advances on every tick and before the work — which is precisely why it can never delimit the window the work is about), and differs from both by advancing only on a completed pass, so a crash re-covers rather than skips. Deriving it from the last report's period_end was the obvious alternative and is wrong for one ordinary reason: a supervisor deleting an old report would rewind the scheduler and regenerate the report they just discarded — a document is the user's to delete, scheduler state is not. Registry rather than owner because the pass runs in some supervisor's runtime and which one depends on who is logged in that night; the acting user's file would give one subject two unsynchronised clocks.

Reports (db/reports.rs, blueprint §13). The documents system agents write about a stretch of time — a daily review of a supervised account, a weekly "what you struggled to get done" digest. The second two-homes table, for the same reason as memory_docs and with the same mechanics: one owner schema, and the file a row lands in is its audience. A {userid}.db row is that user's own report, behind SQLCipher; a system.db row is an instance report, written about someone for the people who supervise them and therefore cleartext to whoever owns the box — deliberately, since they are the intended reader (§2). Which file a producer writes into falls out of its own AgentScope with no new concept (PerUserctx.pool, Instance → the registry pool it already holds), and the subject of an instance report cannot see it because their tools only ever reach their own pool — the invisibility is structural, so nothing anywhere filters by reader. subject_user_id/producer_user_id/run_id are bare snapshot columns, never FKs (owner→registry would fail every INSERT; for an instance row the system_agent_runs trace sits in the acting user's file). kind is producer-declared text, not an enum (§0.1). Rows are immutable but for mark_read, whose read_at IS NULL guard makes acknowledgement shared and first-reader-wins — two admins, one alert, dealt with once. Consequence worth internalising: since the admin cannot open the subject's encrypted sessions, there is no click-through to the evidence — whatever justifies a report must be narrated in its body, under the same rule the shared memory lint already follows (say which conversation and what kind of problem, without reproducing the sensitive line). Currently there is no producer, no API and no UI — the table, its accessor and its tests are the whole of it.

Memory injection into the prompt: AgentSystemContext::load_inject_memory (loop_adapters/system.rs) routes each meta.inject_memory entry — user-memory/… → owner pool, shared-memory/… → the shared (system.db) pool, both via memory_docs::get; anything else (data/…, $WD/…) is a disk read. The shared pool is threaded ChatSessionManagerUserLoopRuntimeAgentSystemContext. assistant and project-coordinator inject user-memory/index.md + shared-memory/index.md.

Prompt substitutions: an AGENT.md may carry <!-- KEY --> placeholders; agents::resolve_includes turns each into a __KEY__ sentinel, replaced at request time. Two are resolved by the system-context source itself (loop_adapters/system.rs) from the session owner (user_id) + registry (shared_pool), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: __SHARED_FOLDERS__ (the user's shared-folders table) and __USER_PROFILE__ (the owner's directory profile: Name, Date of birth with age computed at build time, Sex, Preferred language, admin Notes — unset values render as explicit unknown / not specified, the Notes line is omitted when empty). Any other key comes from the per-call SendMessageOptions::system_substitutions map.

system.db still gets both bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it is the owner of shared memory (memory_docs) plus, for now, the globally-scoped secrets (SecretsStore is built on the system pool and shared by reference into every UserContext; the global runtime's config now lives in the registry table mcp_global_servers, and per-user connector config in each user's owner mcp_user_servers). The global runtime no longer writes mcp_events there: notification persistence is an explicit McpManager::new argument (EventLog::{Persist,Discard}), Discard for the ownerless global runtime and Persist for each per-user one, because an event belongs to whoever it happened to and its only reader (event triage) is per-user. Every other owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping create_owner_tables from system.db is blocked on the §4 scope decision for secrets, not on call-site migration.

users (crates/skald-core/src/db/users.rs) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. Credentials is an enum mirroring the table's CHECK: an encrypted user carries a wrapped DEK (whose AEAD tag is the password verifier — hence no password_hash); a cleartext user carries an ordinary verifier, or none. User is deliberately not Serialize and its Debug redacts key material — use User::summary() for anything leaving the process. role_id references roles(id) (the roles table is now seeded before users in create_registry_tables). A nullable locale column (additive via ensure_column) holds the per-user UI language override; role-driven conventions live in the free-form roles.attrs JSON — never new columns per attribute — parsed at a single point by the typed db::roles::RoleAttrs (ui_mode, permission_groups, chat_agent, auto_grant — the last one being why that struct's Default is hand-written, see the default-access section): ui_mode (see the frontend section) plus the role's security-group set (roles.permission_group = the default group, attrs.permission_groups = additional allowed groups; Role::effective_groups() = the union, roles::role_allows_group() gates it with admin short-circuiting to all). See the security-group picker in the frontend section. The role's default entry (chat) agent is attrs.chat_agent — the neutral chat-type agent members of the role land on (§0.1: data, not an enum). Resolved by roles::default_chat_agent_for_user(registry_pool, user_id) — the single seam behind both the per-user ChatHub's default_agent (snapshotted at login in UserContextFactory::build, like fs/MCP access, so every session-creation path — explicit provision_session, lazy WS get_or_create_session, notify — honors it) and provisioning_for_source's non-project branch. Falls back to agents::DEFAULT_CHAT_AGENT ("assistant", the renamed former main) when unset. Seeded: admin/memberassistant, childrenkid (Companion). A per-user override is future work, layering on top in the same resolver. The stack root frame is created with the session's own agent_id (not a literal) — config.agent_id (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed directory profile lives in three more additive columns — birthdate (ISO YYYY-MM-DD), sex (free text), notes (admin-authored) — edited only from the Users admin page (set_directory_fields; validation — real non-future date, length caps — lives in the users_mgmt API, not the db layer) and rendered into agent prompts by the __USER_PROFILE__ substitution (see above). They are directory metadata written by the admin about the user, so the registry is their honest home under the §2 threat model.

Filesystem & containers (blueprint §6)

Each user has one permanent Docker container (skald-{userid}, our own skald-runtime image with python+node and a preinstalled shell toolbelt), created on user creation and started at boot (ContainerManager, crates/skald-core/src/container/). Docker is required: a missing daemon fails Skald::new and the process exits. What goes in the image vs. what the agent installs on demand is a real trade, and the Dockerfile states its rule: sudo apt-get install works in the sandbox but re-runs on every container recreate, inside a task, where it costs latency and can fail — while the image is one, shared by every container, so preinstalling costs its size once for the whole box. Anything an agent reaches for repeatedly is therefore baked in; build-essential/python3-dev and pandoc are deliberately left out as big and self-recoverable. The container runs as the host uid:gid (not root) so files created in-container and by the host-side fs-tools share ownership on the bind mounts (matters on native Linux; masked on macOS Docker Desktop). Because that user isn't root, the image ships passwordless sudo (a passwd/shadow entry is injected at create) so an agent can still sudo apt-get install …; --init runs tini as pid 1 to reap zombies.

The agent sees one namespace, routed on the first path component. The choke point is UserFs (core-api/src/user_fs.rs, a pure value type carried in ToolContext.fs), plus resolve_host_path() in tools/fs/mod.rs:

Agent path Backing Routed by
user-memory/… SQLite ctx.pool ({userid}.db) classify_memorymemory_docs
shared-memory/… SQLite system.db classify_memorymemory_docs
shared/{X}/… host {WD}/shared/{X} (if a member) UserFs::host_base_and_tail
projects/{O}/{S}/… host {WD}/projects/{owner_userid}/{S} (if a member) UserFs::host_base_and_tail
~/…, relative host {WD}/homes/{userid} UserFs::host_base_and_tail
any other absolute path (/tmp/…, /etc/…) the container's own filesystem resolve_targetcontainer::exec_fs

Two views, one storage: for the mounted subtree the fs-tools run host-side in the Skald process on {WD}/homes/{userid} + {WD}/shared/{X}; execute_cmd runs inside the container (docker exec -w <container-path> skald-{userid} sh -c …, via ExecuteCmd::run_with) on the same paths bind-mounted (homes/{userid}/root, shared/{X}/root/shared/{X}, read-only when can_write=0). A file written in the container appears to the host fs-tools and vice versa.

The security boundary is the container, not the mounted subtree — the mount is the fast path, not the only one. An agent already reaches every corner of its container through execute_cmd, which runs there with passwordless sudo; fs-tools that stopped at the mounts were not protecting anything, they were offering a poorer view of the same sandbox, and the model answered that by shelling out (the observed failure: read_file /tmp/cv.txt"path escapes your workspace" → the agent re-read it with cat). So resolve_target routes a physical path to one of two backings. An absolute path is container vocabulary — it is what execute_cmd prints — so it is reverse-mapped through UserFs::container_to_agent first: landing on a mount takes the host path (/root/x is ~/x, which the tools used to reject outright, since PathBuf::join with an absolute tail silently discards the base and the result then failed the prefix check); landing nowhere means it exists only in the container, and container::exec_fs acts there over docker exec (paths passed positionally as $1, so a path containing $(…) is data, not syntax). Membership is not bypassed: /root/shared/{X} for a non-member still resolves to the same error as shared/{X}.

One implementation per tool, not two. Every single-file fs-tool already funnels through the same shape — resolve, then run a sync execute over one absolute host path — so the container branch is a shuttle (fs::Shuttle, behind fs::run_physical): pull the file out of the container, run the unchanged tool on the copy, push it back if the content changed (compared by bytes, not mtime, whose one-second resolution would miss a fast edit). Nothing about a tool's messages, diffs or pure transforms is duplicated. A missing remote file is deliberately not pre-created — write_file reports "Created" vs "Overwrote" from whether the path existed, and a placeholder would make every creation lie. Three tools opt out of the shuttle because a single file is the wrong unit: list_files lists in place via exec_fs::list (find -printf; line_count is omitted, since counting lines would turn a listing into a docker exec per file), read_file reads container paths as text (a shuttled copy is gone by the time the projection would inline a MediaRef, so media stays a mount-only feature), and grep_files refuses container paths with a pointer to execute_cmd + rg — its regex flavour, glob, windowing and offset would all have to be re-derived from ripgrep's flags, and a grep that answers almost the same is worse than one that says where to go. The viewer follows the same routing through resolve_view_target (GET /api/file and show_file_to_user open container paths; served without an ETag, so the editor stays read-only there).

The memory roots are signposted inside the container, not merely absent. user-memory//shared-memory/ are virtual, so nothing of them existed on disk — and the nothing was worse than it sounds: cat user-memory/x.md returned a bare ENOENT (which reads as the note is missing, not wrong door), while mkdir -p user-memory && echo … > user-memory/x.md succeeded, writing a real file into the home that no reader ever visits and that the next ls then confirms as if it had worked. Each root is therefore a read-only bind mount ({WD}/.memory-signpost/{root}{container_home}/{root}:ro, gitignored, rewritten from consts on every ensure) holding a README that names the tools. Read-only as a mount, not as a mode: the container user has passwordless sudo, so a chmod would be a suggestion, whereas :ro holds — remounting needs CAP_SYS_ADMIN (verified: write, sudo write, sudo chmod, sudo mount -o remount,rw and sudo rm all fail). A README rather than an empty dir because Permission denied is an error, not an instruction — models answer it by reaching for sudo; the README puts the correction in the directory the failing command just named. These mounts are deliberately not in UserFs: they back no agent path and the host-side fs-tools must never resolve into them. They are the fourth self-heal axis in reusable() (signposts_mounted) rather than an IMAGE_TAG bump, since the image is unchanged and a bump would make every box rebuild it to fix a mount. The matching half is in classify_memory, which now strips the home spellings (./, ~/, /root/) before matching the root — without it ~/user-memory/x.md missed the match, fell through to the disk router, and became exactly the invisible physical file the signpost exists to prevent.

Containment (resolve_host_path) is unchanged and still guards the host branch: every path that lands on a mount is canonicalized (following symlinks) and prefix-checked against its mount base, fail-closed. That check is what it always was — the defence against a symlink planted from inside the container pointing at the host's /etc, which the host-side tool would otherwise follow off the box. Opening the container branch does not weaken it: that branch never touches the host filesystem, so there is no host to escape from, and the check keeps applying to everything mounted. grep_files stays disk-only (regex ≠ FTS; memory → memory_search) but resolves its root the same way. execute_cmd's workdir is an agent path mapped to its container path via UserFs::to_container.

The threading: UserContext.fs (built by container::build_user_fs at login, snapshotting shared memberships) → ChatSessionManagerChatSessionHandler.fsToolContext.fs. Admin CRUD is wired (src/frontend/api/shared_folders.rsGET/POST /api/shared-folders, PATCH/DELETE /api/shared-folders/{id}, POST/DELETE .../members[/{user_id}]; UI shared-folders.js): a create/describe/delete + per-member can_write surface, and each mutation emits SystemEvent::UserMountsChanged, on which the lifecycle reconciler runs Skald::refresh_user_mounts — rebuilding the affected user's fs + container mounts in place, so a membership change lands without a re-login (blueprint §6's "admin CRUD" + "membership refresh without re-login" TODOs, now closed; it still settles at next login/boot if the live remount fails). execute_cmd /stop is robust: the command runs under setsid -w in its own process-group (leader pid recorded in a container pidfile), and a KillReaper drop-guard reaps that group on /stop or timeout via a detached docker exec that walks /proc and kills members by positive pid (the container's dash mishandles kill -<pgid>); the pidfile is passed positionally ($1), and the container's --init (tini) reaps the killed processes so no zombies accumulate. Per-user MCP connectors now run inside this container (§7) — the container infra enabled it; see the MCP connectors section.

Projects

A project is a shareable, self-service workspace: a folder at {WD}/projects/{owner_userid}/{slug} plus membership in the registry. projects (accessor db/projects.rs — slug is immutable, UNIQUE(owner_user_id, slug)) + project_members (junction with can_write; the owner is always a write-member, so a private project = one member). Sharing is not admin-gated: the owner and any write-member can add/remove/re-grant members and edit metadata; only the owner can delete. Each membership mutation emits SystemEvent::UserMountsChanged for the affected user; the lifecycle reconciler remounts their container in place (Skald::refresh_user_mounts), so the folder is browsable at once (the explorer reads host-side) and reachable from execute_cmd a moment later. The mount appears in the agent namespace as projects/{owner_username}/{slug} (host keys on the stable userid, agent path on the username) — read-only members get a read-only bind mount in the container.

API (src/frontend/api/projects.rs): GET/POST /api/projects, GET/PUT/DELETE /api/projects/{id}, POST /api/projects/{id}/members, DELETE .../members/{user_id}, POST /api/projects/{id}/session. ProjectDetail carries root_path — the agent path of the folder, computed server-side (owner username ≠ owner_name, which may be a display name) — the explorer's root. A project-{id} chat source provisions the project-coordinator agent with a project RunContext (provisioning_for_sourceskald_core::projects::build_project_run_context: project_root + a system block with name/description/folder/members); every member keeps their own private project-{id} session — only the folder is shared.

UI (web/components/projects/): index.js (<projects-page> host — hash-routed: #projects, #projects/{id}, #projects/{id}/sharing, back/forward-aware), project-list.js (card grid + create/edit/delete modal), project-board.js (<project-board-section> — the detail page: header with Open chat, then a Files / Sharing tab bar using the .project-tab-bar styles in css/projects/board.css), project-files.js (<project-files-panel> — the explorer). The mobile app has its own read-only shared/projects-page.js (list → open project chat).

The explorer (project-files.js): one directory at a time via GET /api/files/dir?path=… (new endpoint in src/frontend/api/files.rs: immediate children with name/path/is_dir/size/created_at/modified_at, dirs-first; same resolve_view_path scoping as /api/file). Breadcrumb rooted at the project (/ = root_path); file click → window.openFile (existing viewer); folder click → navigate. Live: it subscribes the open directory on the existing /api/file/watch socket (web/lib/file-watcher.js singleton — notify NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to can_write members and ride the existing /api/file endpoints — POST gained dir:true (mkdir), DELETE handles directories (remove_dir_all), and binary upload is the new POST /api/file/upload?path=… (raw body, 256 MiB DefaultBodyLimit). Server-side write gate: all /api/file write handlers now call UserFs::can_write_to(agent_path) (core-api) — home → true, shared//projects/ → the membership's can_write, docs/ → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).

MCP connectors (blueprint §7/§14/§15)

MCP servers are surfaced to users as "Connectors" (UI naming; mcp/schema stays neutral, §0.1). The old single owner table mcp_servers, the agent-facing register_mcp/delete_mcp tools, and the mcp kinds of list_items/toggle_item are gone. Connectors are now admin-curated and user-activated through the Connectors UI/API — never written by the agent, which closes the §14 RCE vector (prompt-injection → agent writes+registers a local script → arbitrary code on the box).

Two runtimes, one view (§7). A session's MCP tools are the union of:

  • Global runtime — shared, stateless connectors (web-search, Tavily…) that run on the host, connected at boot from mcp_global_servers by McpManager::initialize. Filtered per user by mcp_global_access.
  • Per-user runtime — the connectors a user has activated, run inside their container, started at first login from that user's owner mcp_user_servers and living until restart (§9; the docker exec -i children die via kill_on_drop when the UserContext drops).

McpProvider (mcp/provider.rs) is the trait the session code talks to, so all_tool_defs / render_mcp_list / ActivateTools never learn which runtime owns a server. McpManager implements it directly (used for the inert ownerless bundle, §19); UserMcpView implements it as global user, where accessible_global is a snapshot of mcp_global_access captured when the UserContext is built (like fs membership). Both runtimes share McpManager::connect_all(specs, boot); McpServerSpec + global_row_spec/user_row_spec turn a DB row into a connectable spec (a per-user local_script spec targets the user's container).

Authorization is a capability on the role, not if role==admin (§0.1/§14 — db/role_capabilities.rs): mcp.register_remote + mcp.register_local_from_catalog are self-service (seeded on every new role by roles::create via seed_defaults); mcp.register_local_script + mcp.manage_catalog are admin-only. admin holds every capability by construction (short-circuit in has()). API handlers gate through require_cap.

Tables (see DB section) — registry: mcp_catalog (admin-vetted templates; holds only the schema of what an activation must supply, never live creds — plus, for OAuth, oauth_provider + oauth_scopes_json + deliver_json), mcp_global_servers + mcp_global_access, oauth_providers (per-provider client creds), role_capabilities. Owner: mcp_user_servers (per-user activations; api_key encrypted at rest — the refresh token for an OAuth one — catalog_name/oauth_provider/deliver_json bare TEXT snapshots).

Endpoints (src/frontend/api/mcp.rs, mounted in api/mod.rs) — admin: /mcp/catalog (GET/POST/DELETE), /mcp/global (list/enable/delete + /{id}/access GET/PUT), /mcp/providers (GET/POST + DELETE /{name} — OAuth provider creds, secret never returned to the browser). User: /mcp/available, /mcp/activate, /mcp/activated (+ DELETE /{id} to deactivate), /mcp/oauth/start + /mcp/oauth/complete (the §15 OAuth login), /mcp/login/status + /mcp/login/reset (the §15 QR/device login — see below). connectors.js (<connectors-page>) is the single Connectors surface — a row list, one row per connector (there is no separate catalog page): the user view (activate/deactivate + granted globals) always, plus the admin affordances when role_id === 'admin' — the Add connector dropdown (from the Marketplace, or manually via the #connectors/new sub-page), per-row removal from the catalog, and the Sign-in providers modal. The Marketplace stays its own page (marketplace.js), reached from that dropdown and linking back to #connectors. connector-detail.js (<connector-detail-page>) is a connector's own page and hosts both the OAuth login panel and the QR login panel.

Dependency reconciler (mcp::install::ensure_installed). Copying a local-script connector's files into a container never installed its deps. ensure_installed closes that: a content-hash reconciler keyed on the connector's source files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — npm ci --omit=dev (node, from package.json) and/or pip install --target .pydeps (python, from requirements.txt, put on the server's PYTHONPATH by user_row_spec). Runs at activation and on every per-user startup path (UserContext build, remount) via mcp::prepare_local_connector, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore never vendored — connectors ship package.json/requirements.txt, not node_modules/. Authoring contract for connectors lives in CONNECTOR_MANIFEST_GUIDE.md (repo root).

Connector versioning. mcp_catalog carries version (INTEGER — the update-comparison key), version_string (semver, display) and version_release_date (ISO, display), snapshotted from the feed on install. The marketplace list computes update_available = feed version > installed version (strict) and surfaces it as an "Update" button (marketplace.js). The integer is the UI signal; the actual re-install trigger is the reconciler's content-hash.

OAuth per-user connectors (blueprint §15 — copy-paste flow)

OAuth2 authorization-code + PKCE is wired for per-user connectors (Gmail is the first). The consent is a human copy-paste, not a headless action: no callback route into the (NAT'd, hostname-less) box, and no client secret on the public feed.

  • Providers, not per-connector URLs. The client is per-provider (one Google app covers Gmail/Calendar/Drive): oauth_providers holds auth_url/token_url/client_id/client_secret/redirect_uri/extra_params, admin-entered via the Sign-in-providers modal (Google preset fills all but the two secrets; redirect_uri = the static oauth/show.html page, extra_params = access_type=offline+prompt=consent so Google returns a refresh token). The manifest only names auth.provider + auth.scopes + auth.deliver — never URLs or secrets (feed is remote data, §14).
  • Flow (mcp/oauth.rs): activate on an OAuth catalog entry persists a pending mcp_user_servers row (files installed, command wired, no token) and returns needs_oauth — it does not start the server. /mcp/oauth/start builds the consent URL (PKCE S256 + opaque state) and stashes the verifier in a RAM-only, TTL'd flow store keyed by state; the user approves in a browser, the provider lands the code on oauth/show.html, they paste it back. /mcp/oauth/complete exchanges code+verifier for a refresh token (client_secret sent server-side), stores it in the row's api_key, flips to ready, and starts the server. PKCE makes an intercepted code worthless; a restart drops in-flight flows (mirrors the RAM-only session model).
  • Credential delivery = env, nothing on disk. The manifest's deliver ({as,format,env}, parsed as mcp::DeliverSpec) says how the token reaches the server. user_row_spec_resolved assembles the credential (google_authorized_user JSON = client creds from the provider + refresh token) and injects it as an env var (GMAIL_CREDS_JSON) on the docker exec — never a file, coherent with §2 (the tempted admin doesn't read /proc). The server reads it via Credentials.from_authorized_user_info. Ran both at OAuth-complete and at login-time per-user startup.
  • Google needs a Web-application client: a Desktop client rejects an https:// redirect (loopback only), so the oauth/show.html redirect must be registered on a Web app OAuth client, and exact-match under Authorized redirect URIs — redirect_uri_mismatch otherwise.

QR / interactive device login (blueprint §15 — polling flow)

For a per-user connector whose credential is produced by pairing (auth.type: "qr"; WhatsApp is the first, on Baileys — the slim skald-runtime image has no Chromium, so a browser-based client is out), there is no code to paste and the server must run to produce the QR. The seam is a generic tool contract, reusable for future device kinds (SSH…):

  • login_status tool contract. A connector needing an interactive login exposes one tool, login_status, returning JSON {state, qr?, message} (state: connecting|need_scan|ready|logged_out; qr is a data-URL PNG only while need_scan). Skald calls it directly, never the agent.
  • Flow. activate on a qr entry inserts a pending mcp_user_servers row and starts the server (unlike OAuth, which defers), returning needs_login/login_kind:"qr". /mcp/login/status ensures the server is running (restarts a pending one), calls login_status, and returns its state; on ready it flips the row's auth_state so all_startable picks it up next login. /mcp/login/reset calls the connector's logout tool to re-arm (link a different device). The connector-detail.js QR panel polls login/status and renders the QR.
  • Credential = on-disk session, not a token. The connector persists its session inside its own dir (e.g. ./auth/), under the bind-mounted home so it survives a container recreate — the honest §4 gap (admin-root-readable), not memory_docs.
  • Node 18 gotcha: the container ships Node 18; Baileys uses the Web Crypto global, so the server must globalThis.crypto ??= require('crypto').webcrypto or it dies pre-QR with "crypto is not defined".

Deferred: SSH and other §15 device kinds (would reuse the login_status contract), deliver.as=file, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.

Default access — the grant tables are deny-by-default, but the rows are written for you

plugin_access, mcp_global_access and mcp_catalog_access still mean exactly what they meant: a row is access, its absence is none, every read fails closed. What changed is who writes the rows. Installing something used to leave it granted to nobody, so the admin then walked the user list; now db::access_defaults grants it to the household at the moment of installation and the admin's remaining job is removal.

The default is materialized, never evaluated. The tempting alternative — leave the junctions lazy and answer each check as COALESCE(grant.allowed, object.grant_by_default) with signed rows for exceptions — needs no seeding but costs two things worth more. The checkbox loses a state (an unticked box would mean either "denied" or "inheriting", indistinguishable to the admin), and "who has what" stops being one query: the gate, the plugin roster and the user checklist all read the same junction today, and plugin_access.plugin_id is bare TEXT with no plugins row to join a default against. So the default is applied at exactly two moments and never again:

moment seam what fires
an object is created access_defaults::seed_new_object PluginManager::update_config (first toggle — the plugins row's birth), mcp::global_enable, mcp::catalog_upsert, marketplace install
a user is created access_defaults::seed_new_user UserManager::register_user — in the core, so no future user-creation endpoint can forget it

Not on enable/disable, and that is the load-bearing part: re-enabling a plugin must never resurrect a grant the admin took away, so the trigger is the row's birth, not its flag. Every call site therefore checks existence before its upsert (is_new_row / is_new_server / is_new_entry) — a re-install or an edit seeds nothing. Seeding is additive-only and idempotent on the PK, which is why every call site is best-effort (a warn!, never a failed request): a grant that did not get written is fixable from the user's page, and nothing here can ever widen further than the two moments allow.

Who is included is a role attribute, not a role id (§0.1): roles.attrs.auto_grant, parsed by RoleAttrs like everything else there. It defaults to true — hence the hand-written impl Default for RoleAttrs, since a derived one would give false and silently invert the feature for every role predating the attribute. The seeded children preset sets it to false, which is the whole reason the attribute exists. admin answers false too, but as a skip, not a denial: admins hold everything implicitly (plugin_access::effective_access short-circuits), so rows for them would only be noise in every roster. Editable in the role editor (roles-page.js, which persists only the opt-out).

Per-object opt-out is grant_by_default on plugins / mcp_global_servers / mcp_catalog (additive via ensure_column, default 1). One thing sets it today: a binding-managed plugin (Plugin::manages_own_access, mobile-connector) is marked 0 at row creation, because it never reads plugin_access and rows for it would make its roster claim an audience that means nothing. There is no UI for the flag yet — access_defaults::set_grant_by_default is the seam when one is wanted. Changing it is deliberately not retroactive in either direction.

A role change does not re-seed. Promoting a child to an adult role leaves their grants as they were; the admin ticks the boxes once on that person's page. Deliberate: the reverse (demotion) would then have to revoke, and a revocation that fires as a side effect of an unrelated edit is exactly the class of surprise the two-moment rule exists to avoid.

System agents (event triage, memory lints)

A system agent runs on a user's behalf without being asked. There are three — event triage (the background event processor) and the two memory lints — behind one scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry.

The unit of work is one agent for one user, and every part of the design falls out of that. the triage agent's events (mcp_events) are in the caller's own encrypted database, pushed there by connectors in the caller's container; the notification goes to the caller's hub; the trace (system_agent_runs) is in that same file. So an agent owns no timer and no user list: it implements SystemAgent (crates/skald-core/src/system_agents/) — has_work + run over an AgentRunCtx unpacked from that user's UserContext — and skald::wiring::spawn_system_agents decides who and when. Building it against the ownerless Conversation bundle was exactly what made the pre-multi-user version inert: it wrote sessions into system.db, notified a hub with no subscribers, and resolved tool paths against a container that does not exist.

One loop for cadences three orders of magnitude apart. Event triage runs every few minutes, a lint weekly — the case that tempts a second loop. It stays one because the wake-up decides nothing: base_tick (min enabled interval, clamped to [60s, 15min]) only picks how often to look, and whether an agent runs for a given user is system_agents::is_due against persisted state. A second scheduler would be a fourth global bus in disguise.

Due-ness is persisted, not counted from boot — the new owner table system_agent_state(agent_id, last_attempt_at) (accessor db/system_agent_state.rs). It is deliberately not system_agent_runs: the run log is a history for the human and skips idle ticks, while scheduling needs every attempt, so reading due-ness off the log would re-run an idle agent every tick and never bring a weekly one due once its last productive run aged out. Persisting it is also what makes a long interval survive a restart — an in-memory deadline is fine at event triage's scale but a weekly agent on a box rebooted every few days would have it re-armed before it ever fired, and would simply never run. Side benefit: a user who logs in after a long absence is picked up on the next pass.

run_and_record orders the three steps, once, for everybody: mark the attempt (always, even for an idle pass) → has_work (false writes nothing at all, or the run log becomes a heartbeat) → open the run row, then work. The start/finish split (unlike job_runs, written once at the end) leaves a visible running row when the process dies mid-pass, swept to failed by the next start for that agent — safe precisely because the scheduler is sequential and single-instance, at both levels (agents in order, then users in order).

AgentScope::PerSubject is the scope where "whose data" and "whose runtime" come apart — the conversation review (system_agents/conversation_review.rs, wiring subject_pass) is the first and the reason it exists. The pass reads the subject's database and runs inside a supervisor's runtime, so everything it leaves behind (ephemeral session, run row) lands in the watcher's file and nothing in the watched one's; the report crosses between them via system.db. Three things fall out and each is load-bearing: (a) iteration is over subjects, not supervisors — two parents watching one child must yield one review, so whichever of them is unlocked lends a runtime and the report is filed against the subject; (b) is_due is not consulted — it keys state by agent within one file, which would collapse every subject sharing a supervisor into one clock, so due-ness lives in system_agent_coverage and is answered inside has_work (and run_and_record skips mark_attempt for this scope for the same reason); (c) the subject need not be logged in, via the new UserManager::open_unencrypted — for a user with no key the password guards the session, not the data, so this makes that explicit in one place and refuses an encrypted user, not as policy but because there is no key to be had. The rule that falls out is neutral by construction and worth quoting: work over somebody else's history runs unattended for a user who is not encrypted, and only while they are logged in for one who is. The returned pool is deliberately not registered as unlocked (that map is what "logged in" means to everything else). Authorization is the caller's: subject_pass is behind the supervision edge, never a role check.

meta.json: "allow_tools": false empties the turn's tool set (AgentMeta::allow_toolsloop_adapters/runtime.rs::turn_params swaps in an empty ToolRegistry): built-ins, MCP, plugin and interface tools alike, notify included. Distinct from a restrictive security group — a group decides whether a call is allowed, this decides whether the model is shown anything to call. For an agent whose input is other people's text, that is also the prompt-injection answer: the round an injected instruction would act in has no tools in it. The conversation review declares it, and consequently produces its report as the turn's final assistant message (read back with chat_history::last_assistant_for_session, parsed shallowly by parse_report: leading # heading → title, opening paragraph → summary, NOTHING_TO_REPORT sentinel → no row) rather than through a save_report tool, which would have needed whitelisting past the approval gate that an unattended pass auto-denies. The cost is that severity cannot come from the model; every report it files is notice.

Per-pass prompt substitutions. run_ephemeral_turn takes a system_substitutions map. The two the system context resolves by itself (__USER_PROFILE__, __SHARED_FOLDERS__) describe the session owner, which for a pass about somebody else is the wrong person — so the review passes the subject's profile under its own <!-- SUBJECT_PROFILE --> key (rendered by the shared loop_adapters::system::render_user_profile_section). It goes in the system prompt rather than the trigger message because age, name and sex change what counts as worth reporting, and the model needs them before it reads a word of the transcript.

A locked user is skipped, and that is the normal case, not an error. The pool is the unlock token (§9): a user who has not logged in since the last restart has no readable events, no session store — and no place to record the skip, since the only file that could hold it is the one we cannot open. Hence system_agent_runs has no skipped status: the skip is an INFO log line and nothing else.

AgentScope::Instance is the ownerless-work escape hatch, and there is exactly one user of it. The shared memory store belongs to nobody, but a pass over it still has to run somewhere: an ownerless run would write its trace into system.db, which GET /api/system-agents/runs shows to nobody (scoped on the caller's own pool, by design), and its notify() would have no recipient. So instance_pass runs it as the first active unlocked admin (users::list order, so the choice is stable across passes), and the whole per-user surface keeps working unchanged. Cost: it needs an admin who has logged in since the restart.

The run log is theirs, not the admin's (db/system_agent_runs.rs, owner table, no user_id column — the file is the owner). GET /api/system-agents/runs is scoped through require_context with no admin override: everyone, admin included, sees their own runs. stats is a JSON blob of the agent's own counters, never contents.

The configured security group is not applied verbatim. <agent>.security_group is an instance-wide admin setting; handing it to a restricted member's run would give their background agent a tool set their role never granted. system_agents::configured_run_context puts it through run_context::reconcile_group_for_user — the same seam a persisted group takes — degrading to the role default when the role disallows it. With nothing configured the run still starts from role_default_run_context, never None, because None means the catch-all group, which is wider.

The conversation review

system_agents/conversation_review.rs — nightly, one report per supervised subject, covering every conversation in the window rather than one report per session (the useful signal is often across conversations). The window is [covered_through, now) and due-ness is "the watermark stops before the most recent occurrence of run_at_hour local" (default 4am), which is also why downtime needs no catch-up mechanism: a machine off for three days finds a three-day-old watermark and covers it in one pass. most_recent_occurrence is generic over the timezone so it is testable without depending on where the box is, and resolves through the timezone (not UTC arithmetic) so a DST-skipped hour is handled.

chat_history::conversation_window is the transcript query, and its four filters each exist because of a specific way the result would otherwise be wrong: is_ephemeral = 0 (or a pass reads the transcript its previous pass was given and reports on itself), depth = 0 (sub-agent frames are machine-to-machine), is_synthetic = 0 (machinery-injected turns are not things the person said), content <> '' (an assistant row that was only a tool call). Tool calls are absent by construction, not by filter — they live in chat_llm_tools — so the review sees what was said, never what was done, and the prompt says so plainly because a model shown a gap narrates over it. Rendering is prose grouped by conversation, never JSON: a dialogue read as a dialogue is what models are best at, and nothing machine-readable comes back this way — the structured artefact is the report at the other end.

The memory lints

system_agents/memory_lint.rs — one struct, two instances differing only by fields: MemoryLintAgent::private (PerUser, over user-memory/ in the caller's pool) and ::shared (Instance, over shared-memory/ in the system pool — the same routing classify_memory gives the fs-tools). Prompts are two AGENT.mds sharing agents/common/memory-lint.md; the shared one additionally hunts table-rule violations and is told to report which note and what kind of problem without repeating the sensitive line, since restating it is the harm being flagged.

Read-only, enforced twice. The prompt says report-never-repair, and shared-memory/* writes are already @fs_write require — so an agent that tried to fix something would raise an approval card from an unattended pass, which run_ephemeral_turn auto-denies. Read-only is not a convention here, it is the only thing that works. has_work is "the store is non-empty", so a member who never uses memory collects no weekly row and no weekly notification.

Interval units are per-agent: event triage in minutes, the lints in days (interval_from_config takes the unit). Asking an admin to type 10080 for "weekly" would be a worse version of the same field.

Where the settings live

ConfigSet gained owner: Option<String> (core-api): None renders on the general Config page, Some(agent_id) is claimed by the surface that owns it. Placement is data on the set, not a filter that knows set names, so a new owned set lands in the right place without touching either page. system_agents::registry() and ::config_sets() are the single enumeration of the agents — registry_and_config_sets_agree is the test that stops the scheduler's list and the settings surface from drifting.

/api/config serves only owner-less sets and is now admin-gated (caps::require_admin), read and write: before this, both handlers ignored the caller entirely, so any authenticated session could read and change instance config — the sidebar hiding the page is presentation, not authorization. GET /api/system-agents lists the agents, with config resolved (via the shared config::render_sets) only for an admin and Value::Null for everyone else; writes still go through PUT /api/config/{key}, so the gate and the known-key check exist in one place.

UI: #system-agents (web/components/system-agents.js, sidebar group extensions, visible to everyone — the run log is the caller's own). One tab per agent, plus "All", each tab holding that agent's description, its settings (admin only) and its runs — the tab is the agent, not the kind of information, because "why did this do nothing last night?" is half a schedule question and half a log question. The settings form is web/components/shared/config-form.js (ConfigFormController), shared with config-page.js so an owned set renders identically wherever it is edited. It replaced a since-removed debug page (#tic, from when the triage agent was called TIC), which listed chat_sessions WHERE source='tic' and so inferred runs from leftover ephemeral sessions rather than recording them.

Multimodal attachments

Uploads go through one centralized seamChatHub::save_upload (behind ChatHubApi::save_upload, backed by skald_core::uploads::save_to_home) — so every surface persists identically and no two callers can drift on placement (the class of bug where the agent was handed a path it couldn't reach). The seam writes into the caller's container home under uploads/{session_id}/ (agent path uploads/{session}/{name}, the UPLOADS_SUBDIR const in core-api/user_fs.rs), collision-dedupes the name, and prefers the sniffed magic-byte MIME over the client claim. The web handler (POST /api/{source}/uploads) buffers each field with a 256 MiB cap then calls the seam; the Telegram plugin downloads bytes then calls the same seam via handle.chat_hub().save_upload("telegram", …). Because the file lands in the home (bind-mounted at /root), it is reachable by the fs-tools, execute_cmd, and the file viewer (GET /api/file, per-user via resolve_view_path) — there is no /data static route anymore (removed: it was require_auth-only, not ownership-scoped, and also exposed internal server state under data/). Attachment metadata travels as structured JSON in chat_history.metadata — never as persisted text.

At context-build time (the crate's projection), attachments of the current turn (the user/agent rows following the last completed assistant reply, including across in-flight tool rounds) are partitioned by agent_loop::projection::media, with loop_adapters/media_source.rs deciding which files may be handed over (§6 containment): when the resolved model's LlmEntry.capabilities include the modality (visionimage_url parts, videovideo_url parts), the file is inlined as a base64 data-URL content part — but only if it resolves (through the caller's UserFs, via resolve_host_path) under the home's uploads/ dir, its sniffed MIME is in the allowlist, and it fits the budgets (4 files / 10 MiB image / 32 MiB video / 48 MiB total per turn). Everything else — older turns, other kinds, any failed check — keeps the textual <system-extra> path block (built by core_api::message_meta::attachments_block / system_extra; the tag name is the single SYSTEM_EXTRA_TAG constant), so a non-vision model produces a byte-identical payload to before. OpenAiClient forwards parts verbatim; AnthropicClient translates image_url data URLs to image blocks (video unsupported; Anthropic models get vision by editing the model row's capabilities — no catalog refresh writes them). On LLM fallback mid-round, messages are rebuilt with the replacement model's capabilities.

Token streaming & reasoning display

The chat streams tokens live, as a parallel best-effort side-channel that never alters the turn's authoritative flow: the final Done (or Thinking) event still carries the complete content and the frontend treats it as truth.

  • Client seam (core-api::chatbot): ChatbotClient::chat_with_tools_raw_streaming(..., delta_tx: mpsc::Sender<StreamDelta>) — default impl ignores the channel and calls the buffered chat_with_tools_raw, so providers without streaming (Ollama, LM Studio) are untouched. StreamDelta::{Text, Reasoning} splits visible answer from chain-of-thought. Senders use try_send (deltas drop when the channel is full) — streaming must never backpressure the HTTP read.
  • SSE implementations (crates/llm-client): OpenAiClient (stream:true + stream_options.include_usage, reasoning_content/reasoning deltas, index-based tool_calls accumulation, usage from the final chunk) and AnthropicClient (stream:true; message_start/content_block_*/message_delta events; thinking_delta → reasoning, input_json_delta → tool input). Both reassemble the same LlmTurn + LlmRawMeta the buffered path returns (the payload log stores a synthesized buffered-shaped body). Failure policy: if the stream dies before any delta the client retries buffered on the same model (providers rejecting stream keep working); a mid-stream failure propagates to the normal model-fallback logic. Framing is shared (llm_client::SseDecoder). Anthropic's buffered path now also parses thinking blocks into reasoning_content (previously discarded).
  • Loop wiring: call_llm_round creates the delta channel per attempt and a forwarder task maps deltas to ServerEvent::TokenDelta { kind: content|reasoning, delta } on the turn's event channel (drained before the round's outcome events, so ordering holds); cancellation drops the in-flight future as before. A mid-stream fallback is handled client-side: the frontend clears its pending bubble on model_fallback.
  • Reasoning surfacing: reasoning_content rides Done/Thinking events (so buffered providers show it live too) and is projected as reasoning on assistant/thinking history items (build_items); persistence in chat_history.reasoning_content and the echo back into context predate this feature.
  • Frontend (chat-session.js + copilot-render.js, shared by desktop copilot and mobile chat-page): token_delta accumulates into a pending assistant bubble (in-place mutation + ~15 Hz flush, blinking caret); done/thinking finalize it in place, error/llm_failed/model_fallback drop it, tool_start/agent_done finalize orphan bubbles (reasoning-only rounds, sub-agent final rounds that emit no Done). The reasoning block is a muted, collapsed-by-default native <details> (renderReasoning, .reasoning-block in copilot-messages.css, i18n key chat.reasoning) — open state survives re-renders, and it renders identically from live events and from history.

The LLM loop (agent-loop)

The loop is a standalone crate (crates/agent-loop/) that knows nothing about Skald: it owns control flow (rounds, model fallback, tool fan-out, recording), the projection of history into wire messages, sub-agent delegation, restart recovery and compaction. Skald supplies content through the traits in crates/skald-core/src/loop_adapters/. Nothing in session/handler/ shapes a Value anymore — there is exactly one projection in the workspace.

One LoopManager per user (UserLoopRuntime, loop_adapters/runtime.rs, blueprint D12), built by ChatSessionManager: it owns the event bus, the live-loop registry (which conversations are running, /stop, recovery, shutdown), the store, the approval gate, the hooks, the agent catalog and the delegate tool. A turn contributes only what is its own — the agent's prompt, its tool set, its model pin — via turn_params.

Per-turn state rides the Extensions type-map (loop_adapters/scope.rs::TurnScope): the gate and the catalog live as long as the user, so they cannot capture a session id or a permission group — they read the turn's scope from the call's extensions. A call with no scope is denied, never run with permissive defaults.

Three entry points, all in session/handler/kernel_turn.rs:

entry when what it does
run_kernel_turn a user message repairs a dangling call from a crashed turn, then manager.start_turn
recover_turn WS connect, async result delivery, background wake-up Recovery::run — no new message, continue what was interrupted
resolve_pending_call an approval answered after a restart run the call with the gate skipped, then continue

The event translator (loop_adapters/translate.rs) is the ONE bus subscriber turning LoopEvents into the session's ServerEvents; byte-parity with the pre-kernel event sequence is its contract.

Sub-agents

  • A sub-agent is a tool, not an interception: DelegateTool (registered under the legacy names execute_task / execute_subtask, D11, each keeping its exact legacy schema) opens a child frame and runs a normal loop in it. The parent simply awaits a slow tool call. Max depth MAX_AGENT_DEPTH = 5.
  • Parallel batches are the kernel's generic fan-out: a round whose calls are all concurrency_safe (a sync delegate is) runs concurrently, bounded by max_parallel_calls. The ordering invariant is unchanged — ids allocated in call order (phase 1) → concurrent execution (phase 2) → recording in call order (phase 3) — so the model reconstructs results by id. Any mixed batch stays sequential. Siblings share the session scratchpad; concurrent writes to the same key are last-writer-wins by design.
  • mode: "async" submits a durable scheduled_jobs row through loop_adapters/async_task.rs::CronExecutor and returns a receipt immediately; when the job finishes, DurableSink writes the result into the parent conversation (synthetic assistant + a completed task_completed call) and resumes it. mode: "cron" is scheduling, not delegation, and stays on the cron interface tool.
  • An async task ends in the conversation that started it, whatever happened to it — and cron::run_job is shaped so it cannot do otherwise: one JobOutcome classification, then one match job.kind delivery site for every ending. It used to branch on Ok/Err first and route by kind only inside Ok, so a failure or a kill went out as a "Cron job … failed" notification to the home source (/sethome) while the parent sat waiting for a task_completed that never came — the wrong chat and a wedged conversation. The sink has a single channel by design: to the model, "it broke" is a result like any other and must not be overlookable, so the failure is delivered as prose (with whatever partial output the run produced). A cron job has no parent conversation and keeps the home notification — the future plan is to let its creator name a destination. Cancellation is a third outcome, not a flavour of failure: job_runs.status always had 'cancelled' in its CHECK and nothing wrote it, and the classifier keys on the typed session::handler::TurnCancelled error, never on the message text.
  • The chat shows what it started. ServerEvent::TaskUpdate announces an async task's state to the source of its parent conversation only (a cron job belongs to nobody's chat), and GET /api/{source}/tasks (db::scheduled_jobs::list_for_parent_session) answers the same question at load time — running tasks plus failures from the last 30 minutes, because the event is a broadcast with no replay and a browser reload would otherwise empty a chat that still has work under it. Successes are absent from that query on purpose: a finished task's result is already a message in the conversation. The strip itself is web/components/shared/agent-tasks.js (renderTaskStrip), rendered above the composer on desktop and mobile from state owned by ChatSession; the drill-in is #session/{id}, gated on _canOpenTaskSession because the mobile shell routes a fixed set of sections and would silently swallow that hash.
  • A child's model is never inherited from the parent: passing a concrete name would bypass AUTO selection, so sub-agents auto-select unless explicitly overridden (args.clientmeta.json client → AUTO by strength).
  • list_agents returns task agents only (never chat/system ones like the entry agent).

Restart recovery (agent_loop::recovery)

A crash loses RAM (the approval oneshot, the cancellation token), never truth: every state transition is a store write. So recovery does not have a mode of its own — it makes the history well-formed and then runs a normal loop on it:

  1. Reap an interrupted parallel batch (≥2 active frames at one depth is impossible for a linear stack): fail their spawning calls, close the frames. Deliberately lossy.
  2. Resolve the deepest frame's non-terminal calls. A Running one is re-gated and re-executed unless the tool says otherwiseexecute_cmd declares RestartHint::MarkInterrupted (D7), because a command may already have had its effect. An AwaitingHuman one is re-asked (the card reappears).
  3. Un-wedge: a child that finished but whose result never reached its parent propagates without calling the model again.
  4. Cascade to the root, resolving each parent call with its child's result — every frame running as its own agent, from the catalog, never the root's (B3).

Cancelled and Rejected are terminal and are never re-executed. Anti-double-driving goes through the manager's registry (a recovery claims the conversation like a live turn), not a host-side flag.

Cancellation (stop)

  • The turn's CancellationToken is minted by LoopManager::start_turn and cloned by value down the whole call tree; a delegate passes ctx.cancel.child_token(). It is never re-read from a field mid-turn, which is what makes /stop sticky across sub-agent recursion.
  • ChatSessionHandler::cancel()manager.cancel(&conversation). The token is checked at each round boundary and before each tool call, wrapped around the in-flight LLM call (tokio::select!, aborting the request), and around execute_cmd (dropping the future → kill_on_drop). Parent and child share the tree, so a cancelled child stops the parent by construction.

Compaction

agent_loop::compaction owns the mechanics: split point (never between an assistant turn and its tool results), transcript, prompt (SUMMARY_PREFIX / preamble / template live there now), the single no-tools model call, the saved summary row. skald-core/src/compactor.rs owns the policy: the token threshold, the ephemeral guard, which model summarises (compaction_model from Settings, else AUTO by compaction.strength), and publishing CompactionEvent on the chat bus. The DTL re-anchor is the on_compacted hook (loop_adapters/hooks.rs::DtlReanchorHook). The next turn needs nothing: the assembler reads the latest summary from the store.

Context size: both automatic guards are off by default

Nothing shrinks a conversation unless a human asks. llm.max_history_messages and llm.compaction.threshold_tokens are both Option, both unset in default.config.yaml, and the only remaining reducer is the user typing /compact. The reason is the prompt cache: every provider that caches (Anthropic breakpoints, OpenAI automatic prefix caching) keys on the longest common prefix, so anything that rewrites history mid-conversation costs a full miss on the next request.

The two guards are not equally bad at that, and the difference is why one is merely off and the other is close to a trap. max_history_messages is a sliding tail window (agent_loop::projection::windowdrain(..len - max)): past the cap it drops from the head on every turn, so it is a cache miss per request, forever, and it drops messages with no summary standing in for them — silent amnesia. Compaction rewrites the prefix once per compaction and leaves a summary behind. So the previous default — window on, compaction off — was the worse of the two in both dimensions, and the window's own doc-comment already said the two were mutually exclusive.

Three consequences worth not re-deriving:

  • The compactor is built unconditionally, in both bundles.rs and user_context.rs. It used to be Option<Arc<ContextCompactor>>, keyed on the config section existing — which meant that commenting out compaction: also silently disabled manual /compact (force_compact returned Ok(false) and the chat answered "compaction disabled"). Manual compaction is a command a user types; it must not depend on an admin having filled in a token threshold. try_compact early-returns on threshold_tokens: None; force_compact deliberately does not consult it — the human is the trigger.
  • The projection yields to the automatic pass, not to the compactor's existence: LoopConfig.auto_compaction_enabled (= ContextCompactor::auto_enabled()), so a configured message cap is not silently voided by the mere availability of /compact. Expressed as max_history_messages.filter(|_| !auto_compaction_enabled) in projection_cfg.rs.
  • CompactionConfig's Default is hand-written, same trap as RoleAttrs: a derived one gives keep_recent: 0, which would compact away every recent message on any box omitting the section — now the shipped default.

The future automatic pass should trigger off the resolved model's own context window, not a hand-tuned threshold_tokens that has no idea which model is answering.

The system prefix is frozen per conversation

Same economics, other end of the request. AgentSystemContext::system_context is called once per round, and it reassembled base from disk and SQLite every time — so an agent writing user-memory/index.md in round 3 made round 4, seconds later and with the cache certainly warm, a full miss. Since base is the head of every provider's cache key, that is the most expensive string in the request to touch. loop_adapters/prefix_cache.rs::PrefixCache builds it once per (conversation, agent) — the agent is in the key because a sub-agent shares its parent's conversation but has a prompt of its own — and holds it on UserLoopRuntime, so it outlives the turn.

The refresh rule is the only one that is free: rebuild once the conversation has been idle longer than a provider's cache could survive (PREFIX_TTL, 20 min). The clock is therefore idle time of this conversation, not time since a file changed, and reading restarts it — every get is a request about to go out. The asymmetry that sets the constant: below a provider's window you pay misses that buy nothing, above it you only pay freshness.

Writes are deliberately not reacted to, and there is no bus variant for this. When the agent itself edits an injected file the content is already in the context — its tool call and result sit two messages downstream — so refreshing would repeat what the model just said. A write from elsewhere (the same user's Telegram session, a cron job, another member editing shared-memory/) is genuinely invisible until the TTL: that is the case where an immediate rebuild costs the most, since a conversation that would notice is by definition a warm one, and the cheaper freshness path already exists — the agent can read_file, and a tool result appends, which invalidates nothing. The injection header says so in words. Cross-user invalidation would need a SystemEventBus variant plus a subscriber per user (the writer lives in a different UserContext); it is future work, and this type's key is the seam for it. Note base is frozen whole: freezing the memory files while letting __USER_PROFILE__ move would invalidate just as much. The cost is that an AGENT.md edit lands at the next rebuild rather than the next round.

Approval gate

The rule engine ApprovalManager::check returns Allow/Deny/Require per tool call (default rules seeded on first boot; the catch-all * require @999999 gates anything not explicitly allowed — e.g. execute_cmd, execute_task, writes outside whitelisted paths). It is wired to the loop as loop_adapters/gate.rs::ApprovalGate (agent_loop::gate::Gate). A Require registers a oneshot in the in-memory pending map keyed by request_id and emits an approval event over WS.

Resolution is source-agnostic: the WS + Inbox paths resolve by request_id; the inline chat card resolves by the durable tool_call_id via POST /api/tools/:tool_call_id/resolve (resolve_tool in src/frontend/api/sessions.rs), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the oneshot. Post-restart there is one path for every tool, LoopManager::resolve_pending: the call runs with the gate skipped (the human just decided) but with the session's real ToolContext — owner pool, per-user container — so a resolved write_file/execute_cmd acts on the user's workspace, never the server cwd/host (this was a §6 escape); then the conversation continues, including a sub-agent dispatch, which simply opens its child frame like any other call. The endpoint returns as soon as the work is scheduled and the result streams over the bus.

The diff preview in a PendingWrite event (loop_adapters/preview.rs::read_current_content, driven by the SkaldWritePreviewHook) routes exactly like the fs-tools: user-memory//shared-memory/memory_docs on the right pool, every other agent path → the caller's host workspace via resolve_host_path(&self.fs, …). It must never use the cwd-relative fs::resolve — that showed a bogus "new file" on overwrites (or the diff of a same-named cwd file), so the user would approve the wrong diff.

Tool visibility in the Security-groups UI (GET /api/approval/tools): tools injected outside the ToolRegistry (interface/plugin/provider tools) would otherwise be un-configurable. ToolCatalog::list_all() covers registry tools + a static synthetic_tools() list of core interface tools; everything else is captured by crates/skald-core/src/tool_discovery.rs (ToolDiscovery), which taps the tool set the loop offers each round (SkaldToolSet::defs) and upserts every offered tool into the known_tools table (in-memory seen-set guard → background DB write). list_tools merges known_tools (deduped, category: "dynamic") so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names.

Restart

There is no in-app restart anymore. The agent-callable restart tool and its set_restart_handler seam were removed (blast radius = the whole box: it dropped every user's session and in-RAM DEK from one user's chat — a power-user leftover, out of place in the multi-user model). Nothing in the process now calls libc::_exit(-1).

The supervisor protocol survives but is currently unreachable in-app: run.sh still re-executes the binary by path when it exits 255, but no code produces that exit code. Restarting is therefore a manual/admin operation.

To pick up config.yml / providers.yaml / database changes (read only at startup), or to load new code (./build.sh installs the new binary via atomic rename): stop the server and let run.sh loop, or re-run ./run.sh. A future admin-only restart action (endpoint/button gated by an admin capability) would re-use the 255 ⇒ re-exec seam — it is intentionally kept for that.

run.bat is still stale (cargo run) and must be fixed.

Build & run

./build.sh      # release build → bin/skald and bin/skald-setup (atomic install)
./build.sh -d   # debug profile; extra args are forwarded to the server build
./run.sh        # first-run setup, then the supervisor loop — never compiles

build.sh builds and installs both binaries; any forwarded args go to the server only.

run.sh resolves the server binary as $SKALD_BINbin/skaldtarget/release/skald, and warns when sources are newer than it. Before the loop it runs skald-setup (found next to the server, or $SKALD_SETUP_BIN); a non-zero exit there — a failed or cancelled wizard — stops run.sh before the server starts. Server exit 0 stops the loop, 255 re-executes, anything else propagates.

In a debug build, Argon2id at 256 MiB is unoptimised and takes far longer than the ~1s of a release build — skald-setup -d will feel stuck at the password step. Use the release binary for anything interactive.

Tracing filter: RUST_LOG=skald=debug,info

Adding an agent

Create agents/<id>/meta.json and agents/<id>/AGENT.md. The agent is discovered at runtime (no restart needed for prompt edits). Optionally set "client": "<name>" in meta.json to pin a specific LLM.

Documentation

docs/ is not developer documentation — it's written for the in-app LLM, not for a human reading the repo, and is mounted read-only into every user's container at ~/docs/ (see the Filesystem & containers section: docs_host on UserFs, DOCS_DIR in container/mod.rs). It explains the software's UX (plugins, and eventually agents/connectors/memory/roles/…) in plain terms, in English, so the assistant can help a non-technical user configure things instead of guessing. docs/index.md is the entry point (general index of feature pages); docs/plugins/<plugin id>.md covers each built-in plugin. The three type: chat agents (assistant, kid, project-coordinator) are told in their AGENT.md to read docs/index.md when a user asks how the software works. Standing rule: every change that impacts the UX must update docs/ in the same change — a new/renamed feature page plus the docs/index.md index entry. It goes stale like any other doc, except users actually see this one.

Config

Copy default.config.yamlconfig.yml. Never commit config.yml (contains API keys).

providers.yaml (repo root, cwd-relative like config.yml) declares the OpenAI-compatible LLM provider types — endpoints, UI metadata, per-model JSON field mapping, id-glob enrichment rules, reasoning knobs. Loaded at boot by llm::providers::declared; edit + restart the process, no rebuild. An invalid entry is logged and skipped, never fatal; an id colliding with a native provider is skipped. Adding a new OpenAI-compatible provider is a YAML edit, not a Rust file. The shipped file is validated by a unit test (declared::tests::shipped_providers_yaml_is_valid).

Python environment

Host-side Python runs from a local virtualenv at .venv/ in the project root. run.sh creates it on first launch (using uv if available, otherwise python3 -m venv), installs requirements.txt, and prepends .venv/bin to PATH before starting the app, so every child process resolves python3 to the venv. No manual activation needed.

requirements.txt is for the two TTS plugins, and nothing else. plugin-tts-kokoro and plugin-tts-orpheus-3b write an embedded server script to disk and spawn a bare python3 on it — they have no dependency reconciler of their own, so their imports must be satisfied in the venv. The GPU/ML half of Orpheus (torch, transformers, snac, bitsandbytes, huggingface_hub) is split into requirements-optional.txt, installed by hand.

A connector's deps never go in requirements.txt. A connector ships its own requirements.txt/package.json and mcp::install::ensure_installed installs it into .pydeps/node_modules — inside the user's container for a per-user connector, beside the connector's files on the host for a global one (ensure_installed_host). Putting them in the root file would install them on every box for a connector nobody activated; this is what the file used to do for the since-deleted scripts/ MCP servers.

Python is optional: with neither uv nor python3 present the app starts normally; the TTS plugins fail to start and a host-run global connector has no interpreter to install its deps with. Per-user connectors are unaffected — they run in the container, which ships its own Python.

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. <app-copilot> 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.

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.<id>.<key>) 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<LocaleBundle> (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<dyn I18nApi>. 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.<id>. 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::createrole_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 <app-copilot> 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 <chat-page> Mobile chat (_wsSource='mobile')
copilot-render.js (helpers) renderMsg, renderTool, renderDiff, etc. — shared by copilot and chat-page
sidebar.js <app-sidebar> 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 <app-topbar> Top nav bar; per-user avatar color hashed from the username
dashboard-page.js <dashboard-page> #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 <file-viewer-page> Desktop file viewer: FileViewerBase + hash routing via window.openFile(path)#file_viewer?path=...
shared/file-viewer-mobile.js <mobile-file-viewer-page> Mobile file viewer: FileViewerBase + prop-driven (visible/path), full-screen with back button
agents.js <agents-page> Agent discovery and config
agent-inbox.js <agent-inbox-page> Pending approvals + clarifications from background sessions
approval-rules.js <approval-rules-page> Approval rule management
cron-jobs.js <cron-jobs-page> Scheduled job management
connectors.js <connectors-page> 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 <plugin-catalog> #plugins — admin status board: one card per plugin (enable toggle + health dot + Configure → #plugin-detail)
plugin-detail.js <plugin-detail> #plugin-detail?id=<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-page> #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 <plugin-page-host> Host for plugin-contributed pages (#plugin/<plugin_id>/<page_id>): dynamic-imports the fragment module, registers its element, mounts it with plugin-id
system-agents.js <system-agents-page> #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-page> #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-page> #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
connector-detail.js <connector-detail-page> 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-providers-page> LLM provider management
models-hub.js <models-hub-page> Models hub landing (LLM / Transcription / Image)
models-llm.js <models-llm-section> LLM model CRUD + drag-and-drop priority
models-transcribe.js <models-transcribe-section> Transcription model CRUD
models-image.js <models-image-section> Image generation model CRUD
mobile-app.js <mobile-app> Mobile app shell
shared/settings-page.js <settings-page> Mobile settings: per-user avatar, locale picker (I18nMixin), profile/preferences