The file existed here and at the root of ~/projects/marketplace, byte for byte identical — two copies of one contract, which is a drift waiting to happen. The one an author reads is the one sitting next to the connectors, so this repo keeps a pointer instead of a copy: CLAUDE.md now names the marketplace checkout and says that CONNECTOR_MANIFEST_GUIDE.md there is the file to consult, and to edit, if the specification changes.
148 KiB
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 commitunless 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 throughdb::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}.dbis 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
rolestable 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 UserContext → UserManager::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: SessionStore — login/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.
Boot unlocks the databases that have no key, and starts their runtimes. §9 ties readability to a login, and for an encrypted file that is the mechanism — the key only exists once the password has been typed. For an unencrypted one it was a rule with nothing behind it: the data is already readable by anything in this process, so the only thing the login gated was the runtime. The cost was user-visible and looked like a bug — after every restart the Telegram bot answered "your account is locked, log in via the web app", cron fired nothing and no background agent ran, until a human opened the SPA. So Skald::new calls UserManager::unlock_all_unencrypted (which registers the pools exactly as a login would, refusing an encrypted or inactive user), and wiring::spawn_unlocked_user_runtimes then builds a UserContext for each — unlocking only makes the data readable; cron, the notify queue, the hub and the per-user MCP runtime all hang off the context, so an instance is working only once those exist. That build is a background supervisor task, not part of new(): it starts every member's MCP servers inside their container, and the HTTP listener must not wait behind that. The same two steps run per user off the lifecycle bus (UserCreated, UserActiveChanged{active:true}, after the container ensure) so a member created at runtime does not wait for the next restart. Two boundaries are untouched and worth stating: authentication is unaffected (SessionStore sits above UserManager; no HTTP request authenticates as anyone because of this), and open_unencrypted still exists for the supervision path, still deliberately not registering its pool. The auto-unlock is deliberately not on a lazy path (e.g. inside Skald::user_context): revoke_user_runtime locks a pool synchronously and expects nothing to re-open it, so the writers of that map stay boot, login, and the lifecycle bus.
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 crate — PluginManager 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 ofhttp_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 viaplugin-transcribe-whisper-local. - The core never learns about the process shell. There is no in-core restart hook — the former
restarttool and itstools::restart::set_restart_handlerseam were removed. The only coupling to the supervisor is now therun.shexit-code protocol (exit255⇒ 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 isskald_core::boot, which emits startup lines each shell renders (src/boot_format.rshere).
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::new → WebFrontend::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 requirement — check_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. A login is what unlocks an encrypted file only — see the boot-unlock section below |
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 |
crates/skald-core/src/skills/ |
The skills index: pure functions over the two read-only trees (enumerate → parse frontmatter → render → digest). No state, no watcher — see the skills paragraphs in Filesystem & containers |
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,system_agent_user_settings. The MCP tables back the Connectors model (§7/§14/§15 — see its own section);oauth_providers(accessordb/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(accessordb/shared_folders.rs) for the on-disk shared folders (§6),project_members(accessordb/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,user_config,llm_request_payloads,memory_docs(+ FTS5memory_docs_fts),reports.user_configis the per-user twin of the registryconfigtable and deliberately does not share its name: the two hold different namespaces (instance settings the admin owns vs. one member's own preferences, the notification home being the first), and a same-named table in both files would turn a wrong-pool call into a silent read of the other scope — instead of the "no such table: config" that revealed/sethomewriting owner state throughdb::configagainst a{userid}.db, which also had the notification consumer dropping every batch it ever built.mcp_user_servers(a user's activated per-user connectors) carriescatalog_nameas a bareTEXTsnapshot ofmcp_catalog.name, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshotsoauth_provider+deliver_json, and itsapi_keycolumn holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Becausememory_docsis an owner table, one definition backs private memory in each{userid}.dband shared memory insystem.db(the household owner) — see the memory namespace note below. (projects/project_ticketswere owner tables in the single-user past: projects are shareable now, soprojects+project_membersare registry tables andproject_ticketsis gone.)
The schema is no longer greenfield (see the production note at the top): a full recreate is not an option anymore. db::ensure_column — ALTER 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.rs — get/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 (PerUser → ctx.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 ChatSessionManager → UserLoopRuntime → AgentSystemContext. 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. Several are resolved by the system-context source itself (loop_adapters/system.rs) from the session owner (user_id) + registry (shared_pool) + their UserFs, so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: __SKILLS_LIST__ (the generated skills index — see Filesystem & containers), __SANDBOX_COMMANDS__ (the sandbox command hint — see below), __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/member → assistant, children → kid (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_memory → memory_docs |
shared-memory/… |
SQLite system.db |
classify_memory → memory_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 |
skills/shared/{id}/… |
host {WD}/skills/{id} — read-only |
UserFs::host_base_and_tail |
skills/{username}/{id}/… |
host {WD}/skills-users/{userid}/{id} — read-only |
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_target → container::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.
Skills are a read-only tree with two scopes, and the space between them is closed too. skills/shared/{id} is the group's, skills/{username}/{id} is one member's own (core-api's SkillMounts; agent path on the username like projects/, host path on the stable userid). Everything under skills/ is read-only in both directions — :ro bind mounts and can_write_to → false — because these hold installed artefacts, not working files: a skill body is read as instruction by whoever it is visible to, so writing one is a decision that must pass a gate, not a file write — the one door is skill_register (with skill_delete and list_items(type="skills")), called from the chat and gated require; a public repo is fetched with fetch_repo and then registered. Two traps, both closed together and neither covering the other's half. Host-side, the skills arm of can_write_to/host_base_and_tail spans the whole root, not the two known scopes: the fallthrough answers true/home, so an invented scope segment (skills/pippo/SKILL.md — the likely guess, not the lucky one) would land in a physical directory under the home that no indexer ever reads. That is the memory-signpost failure exactly. In-container, the defect is structural rather than name-dependent: the scope mounts nest inside container_home, so /root/skills would be a real directory inside the writable home mount and mkdir -p ~/skills/pippo would succeed. Hence a third mount: {WD}/.skills-root/{userid} → {container_home}/skills:ro, holding the signpost README plus the two scope mountpoints. That root is per-user and materialized whole (container::ensure_skills_root) because Docker refuses to create a mountpoint inside a :ro mount — shared/ and {username}/ must already exist in the root's own source, and one of those names is the member's — which is also why the three host paths are one SkillMounts field rather than three Option<PathBuf>. mounts() emits them root-first; skills_mounted is the fifth self-heal axis, for the signposts' reason. A stale scope dir left by a rename is pruned at each ensure. The bare-id alias skills/{id} (the shortest spelling, so the one a model writes unprompted) resolves in resolve_skill_alias — only when the id is unique across the two trees, failing loudly with both full paths otherwise, since a personal skill silently shadowing a group one is a divergence nobody chose. UserFs stays pure: it returns RouteError::SkillAlias and skald-core does the probe.
The skills index is generated, and the sentinel is the knob. What reaches the model is not a file anyone maintains but a function of the two trees (crates/skald-core/src/skills/, pure functions in the shape of LlmCommandManager): each skill's SKILL.md path plus its frontmatter description, truncated to 200 chars, under an imperative header ("you MUST read its SKILL.md") — the countermeasure to the real failure mode, which is the model under-triggering. Printing the full path rather than an id plus a composition rule is what makes a read tool unnecessary: read_file on the printed path is one call, and there is no step left for the model to get wrong. Injection is the placeholder <!-- SKILLS_LIST --> (normally <!-- INCLUDE: common/skills.md -->, a fragment that holds only the sentinel), substituted in AgentSystemContext::build_base beside __MCP_LIST__; resolve_includes needs no branch, its generic <!-- KEY --> → __KEY__ arm already covers it. There is no meta.json flag — the sentinel is the switch, so the four type: system agents opt out by not including the fragment (an imperative "read it with read_file" is exactly wrong in an unattended turn, and some of those run with allow_tools: false). All eleven chat/task agents carry the include, sub-agents included: in a delegation the one doing the work is the child. Three rendering rules are load-bearing and each closes a specific failure: a stable order (scope, then id) because the index sits inside the provider's cache key; a deterministic tail cut at an 8 KB budget, announced by a [N more skills omitted] line, because a silently truncated index has the model conclude in good faith that a skill does not exist; and empty in, empty out — every word of prose lives inside the render, so an instance with no skills spends zero tokens and leaves no orphan sentence (the MCP list is the counter-example: its prose sits around the placeholder, and the empty state once had the model inventing a discovery tool). A colliding id is marked [name collision] on both lines, never shadowed. A malformed skill is skipped with a warn!, never fatal — the index is built while assembling a prompt. Freshness has two doors, one per writer. The in-process tools invalidate directly (Skald::invalidate_prompt_prefix, called by skill_register/skill_delete); a hand edit on the box is caught by the skills watcher (skills/watch.rs, spawned from spawn_background): a recursive notify on {WD}/skills + {WD}/skills-users, debounced ~800 ms, that re-digests each touched tree (skills::tree_digest — the (id, description) pairs the index is made of) and emits SystemEvent::SkillsChanged { scope } only when the digest moved. The subscriber spawn_skills_freshness (next to spawn_user_lifecycle, same Weak shape) maps the scope and calls the same invalidate accessor. Editing a script leaves the digest byte-identical and announces nothing — which is exactly the §6 rule, so an invisible change costs nobody a cache miss. Two gotchas the code carries comments for: the watcher canonicalizes {WD} (FSEvents reports real paths, and /var is a symlink on macOS), and it creates the two trees if absent (a box before its first user has neither).
The sandbox command list is a discovery hint, and the tool — not the sentinel — is the knob. container/commands.rs probes the user's container at login (UserContextFactory::build, right after ensure(), non-fatal) with one docker exec running command -v over a curated ~35-entry PROBE_ALLOWLIST, and the result rides LoopConfig.sandbox_commands → AgentSystemContext → __SANDBOX_COMMANDS__. Three decisions carry it and each is the answer to an obvious-looking alternative. The allowlist is the curation, and the probe is there so the list cannot lie — not the other way round: a full PATH dump is 800 entries of coreutils noise, so what is worth tokens is decided by hand, and command -v exists only so we never announce something a container recreate threw away. A tool outside the list therefore never appears, which is fine because the rendered prose says the list is partial and names command -v — an inventory the model reads as exhaustive is the failure this shape avoids, the same one the skills index's [N more skills omitted] line closes. Order is the allowlist's own (grouped by kind of work), never sorted: the grouping is the curation, and the reader is a model, not a grep. Staleness is cheap in both directions, which is why there is no refresh machinery at all: a mid-session install is known to the agent that ran it, and a container recreate costs one not found plus the apt-get install the agent was already able to do. Gating is the one part that is not the skills pattern: every AGENT.md carries <!-- INCLUDE: common/sandbox.md -->, including the four type: system ones, and the section is emitted iff the turn's model is shown execute_cmd — computed from allow_tools plus the security group's visibility filter (session/handler/config.rs) for a root turn, and from child_defs for a sub-agent, i.e. always from the same definitions the model will see. Hence has_execute_cmd is in the PrefixCache key: the group is switchable mid-conversation from the chat's shield pill, and keying on it costs nothing because that switch already rewrites the tool payload sitting in the same provider cache. The fragment holds only the heading and one stable sentence; every conditional claim lives in the renderer (a departure from the __MCP_LIST__ shape it otherwise follows), because prose promising sudo apt-get install is not the renderer's to retract when the tool is absent. Three rendered cases, and the middle one is why this is not a one-liner: the list, the unreadable-probe line (empty ≠ bare sandbox — rendering nothing under a heading that promises a list is how the MCP section once had a model invent a discovery tool), and the no-execute_cmd line. execute_cmd's own description deliberately carries no capability advertisement — its (python + node available) was removed when this landed, since its job is steering the model away from the shell for work a file tool does better, and the two messages dilute each other.
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) → ChatSessionManager → ChatSessionHandler.fs → ToolContext.fs. Admin CRUD is wired (src/frontend/api/shared_folders.rs — GET/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_source → skald_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, the Files tab being the shared <file-explorer> pointed at the project folder). The mobile app has its own read-only shared/projects-page.js (list → open project chat).
The explorer (web/components/shared/file-explorer.js, <file-explorer>): not a project component — it browses one subtree of the caller's namespace, given a root agent path (a project folder, a shared folder, the home, a memory store) and a rootLabel for the first crumb; projects are one caller of it. One directory at a time via GET /api/files/dir?path=… (src/frontend/api/files.rs: { path, can_write, entries }, each entry name/path/is_dir/size/created_at/modified_at, dirs-first; same resolve_view_path scoping as /api/file, except a memory path, classified before it and listed from memory_docs — see the memory-namespace note). can_write is read from that listing, never passed in: it changes per branch (a shared folder without the flag, skills/, docs/, a memory store) and comes from the same UserFs::can_write_to the server rejects writes with, so the buttons offered and the writes accepted cannot disagree — a caller that thought it knew better would be the one place they could. Breadcrumb rooted at root; 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).
Files (#files)
The general file section: one page over everything the caller can reach, and the second consumer of <file-explorer> (see the Projects section for the component itself).
The root is virtual, and that is the whole design. Anchoring at ~ was the obvious move and is wrong: the explorer reads host-side, where the home is {WD}/homes/{userid} while shared/{X}, projects/{O}/{S}, skills/ and docs/ are bind mounts inside the container — so a page rooted at the home would show less than the user has, with no way to reach the rest, and on native Linux would show the mountpoint stubs Docker creates in the bind source: shared/, docs/, skills/ present and empty. That is the memory-signpost failure exactly — a door that appears to work and leads nowhere. So level 0 is a synthetic list from GET /api/files/roots, serialized from the caller's UserFs (plus the two memory roots, which are virtual and so are not in it): FsRoot { kind, path, name, owner, can_write }, with no label — the server sends the discriminant, the frontend maps kind → label + icon, because labels are copy and get translated. Seven kinds, not six: user-memory and shared-memory are separate rather than one memory with a scope, since they are two stores with two names and a scope field would be a discriminant inside a discriminant. skills/docs appear only if the UserFs has them.
The URL carries the agent path of the open folder — one path parameter (#files?path=shared/casa/foto), the same vocabulary the assistant uses, so a link is shareable and pasteable into a conversation. Which root it belongs to is derived (FilesPage._resolve, longest-prefix over the roots list), never stored beside it: two values that can disagree are two chances to be wrong. A path under no root — a hand-edited URL, or a container-only /tmp/…, which this page does not serve — falls back to the root list with an error, rather than to an explorer that cannot explain itself.
Deep-linking needed the explorer to be steerable without a two-way binding, hence rel in + explorer-navigate out. The loop those two would form is cut by what the event means: it fires only for a click (_navigate), never for a rel the host set (_open), so echoing the event back as a property is a no-op — and a host that ignores the event entirely (the project board) still gets a working explorer.
Memory is read-only here, and it is scope rather than a property (blueprint dir-explorer.md task 5): every writer in files.rs routes through resolve_view_path, which refuses memory paths, and shared-memory/* is @fs_write require for the agent — giving a user a button that walks past that rule is a decision of its own. The listing side is wired: list_dir classifies memory before resolve_view_path and derives one level from the flat key space via memory_docs::immediate_children.
Naming trap in the sidebar: the workspace group already holds "Shared folders", which is the admin's CRUD over one kind of root — not this. The two entries must stay obviously different in copy.
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_serversbyMcpManager::initialize. Filtered per user bymcp_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_serversand living until restart (§9; thedocker exec -ichildren die viakill_on_dropwhen theUserContextdrops).
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 the marketplace repository — see below.
The host half has no reconciler, so its call sites are the contract. A global connector runs in the Skald process, not a container, and ensure_installed_host is not hash-guarded — it leans on pip/npm being idempotent, which is only safe as long as every path that lands new files also calls it. There are two: global_enable (the admin saving a connector's config) and, since it was missing, the global branch of Skald::refresh_connector_after_reinstall. Without the second, a marketplace Update that adds a requirements.txt copied the file and restarted the server without installing anything — the connector came back exactly as broken, and the only cure was re-saving its config. Note what that asymmetry cost: the per-user branch of the same function had always reinstalled (prepare_local_connector), so the bug was invisible on anything scope: user.
The verify runs with .pydeps on PYTHONPATH, and must (mcp::verify::verify_env). Only the server launch used to get that path (global_row_spec / user_row_spec); the verify is a bare sh -c inheriting nothing, so a python connector was rejected by its own verify for a dependency sitting installed one directory away — and global_enable installs before it verifies, so the deps were provably there at the moment the check denied them. The failure selected for well-written connectors: declaring no verify meant never meeting it. The workdir is the connector dir in both targets, so the path is derived, not plumbed, and set with or_insert — an explicit PYTHONPATH from the form is the author's. One gap left deliberately: POST /api/mcp/test (the Test button) shares run_verify but not ensure_installed_host, so testing a python connector that was never enabled on this box still fails on the missing deps. Making a "try it" button write to disk for minutes is the worse trade; enable first.
The connector specs live in the marketplace repository, not here. The feed and everything that authors for it are a separate repo, checked out at ~/projects/marketplace, and the authoring contract is CONNECTOR_MANIFEST_GUIDE.md at its root — same filename this repo used to carry a copy of, which is exactly why the copy is gone: two files with one name drift, and the one next to the connectors is the one an author reads. If the connector specification ever has to change, that is the file to consult and to edit — nothing in this repo restates it.
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_providersholdsauth_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 staticoauth/show.htmlpage,extra_params=access_type=offline+prompt=consentso Google returns a refresh token). The manifest only namesauth.provider+auth.scopes+auth.deliver— never URLs or secrets (feed is remote data, §14). - Flow (
mcp/oauth.rs):activateon an OAuth catalog entry persists a pendingmcp_user_serversrow (files installed, command wired, no token) and returnsneeds_oauth— it does not start the server./mcp/oauth/startbuilds the consent URL (PKCE S256 + opaquestate) and stashes the verifier in a RAM-only, TTL'd flow store keyed bystate; the user approves in a browser, the provider lands the code onoauth/show.html, they paste it back./mcp/oauth/completeexchanges code+verifier for a refresh token (client_secretsent server-side), stores it in the row'sapi_key, flips toready, 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 asmcp::DeliverSpec) says how the token reaches the server.user_row_spec_resolvedassembles the credential (google_authorized_userJSON = client creds from the provider + refresh token) and injects it as an env var (GMAIL_CREDS_JSON) on thedocker exec— never a file, coherent with §2 (the tempted admin doesn't read/proc). The server reads it viaCredentials.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 theoauth/show.htmlredirect must be registered on a Web app OAuth client, and exact-match under Authorized redirect URIs —redirect_uri_mismatchotherwise.
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_statustool contract. A connector needing an interactive login exposes one tool,login_status, returning JSON{state, qr?, message}(state:connecting|need_scan|ready|logged_out;qris a data-URL PNG only whileneed_scan). Skald calls it directly, never the agent.- Flow.
activateon aqrentry inserts a pendingmcp_user_serversrow and starts the server (unlike OAuth, which defers), returningneeds_login/login_kind:"qr"./mcp/login/statusensures the server is running (restarts a pending one), callslogin_status, and returns its state; onreadyit flips the row'sauth_statesoall_startablepicks it up next login./mcp/login/resetcalls the connector'slogouttool to re-arm (link a different device). Theconnector-detail.jsQR panel pollslogin/statusand 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), notmemory_docs. - Node 18 gotcha: the container ships Node 18; Baileys uses the Web Crypto global, so the server must
globalThis.crypto ??= require('crypto').webcryptoor 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_tools → loop_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.
The cadence is per user for exactly one agent, and the trait says so in two methods, not one. Event triage fires on inbound events, so how often it has work is a property of the person — someone on a dozen mailing lists triggers it on nearly every tick from the same setting that leaves a quiet account idle for a day. So SystemAgent gained interval_secs_for(user_id) (what is_due measures against) beside the instance-wide interval_secs, both defaulting to the latter so every other agent implements nothing. The second method is the non-obvious half: base_tick sleeps for the shortest interval any enabled agent asks for, so an agent whose overrides can go below its instance value must also implement shortest_interval_secs — without it the wake-up never comes round often enough and the override works when it lengthens and silently does nothing when it shortens. Storage is the registry table system_agent_user_settings(agent_id, user_id, interval_secs) (accessor + interval_for_user/shortest_interval_for helpers in system_agents/mod.rs, both failing open onto the instance value): a row is an override, its absence is inheritance — no sentinel value, no row written at user creation, and clearing the field deletes the row. Registry rather than the user's own user_config for a reason that is not about scope: the writer is the admin, on #users/{id}, and a member's file is unreadable unless they happen to be logged in (§9) — a setting that could only be changed while its subject has a live session would not be a setting. Endpoints GET/PUT /api/users/{id}/event-triage (admin-gated, minutes on the wire, null = inherit), rendered as one section on that person's page next to the grants. Nothing rides the bus: the scheduler re-reads the interval every tick and due-ness is measured from the user's own last attempt, so a change lands on the next wake-up with no push and no subscriber — the ConfigKeyUpdated reschedule stays for the instance key only. Keyed by agent_id though only one agent uses it, because the alternative is a column per agent on users and "a fourth agent is a trait impl plus one registry line" would stop being true the moment its schedule needed a schema change.
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 seam — ChatHub::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 (vision → image_url parts, video → video_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 bufferedchat_with_tools_raw, so providers without streaming (Ollama, LM Studio) are untouched.StreamDelta::{Text, Reasoning}splits visible answer from chain-of-thought. Senders usetry_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/reasoningdeltas, index-basedtool_callsaccumulation, usage from the final chunk) andAnthropicClient(stream:true;message_start/content_block_*/message_deltaevents;thinking_delta→ reasoning,input_json_delta→ tool input). Both reassemble the sameLlmTurn+LlmRawMetathe 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 rejectingstreamkeep working); a mid-stream failure propagates to the normal model-fallback logic. Framing is shared (llm_client::SseDecoder). Anthropic's buffered path now also parsesthinkingblocks intoreasoning_content(previously discarded). - Loop wiring:
call_llm_roundcreates the delta channel per attempt and a forwarder task maps deltas toServerEvent::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 onmodel_fallback. - Reasoning surfacing:
reasoning_contentridesDone/Thinkingevents (so buffered providers show it live too) and is projected asreasoningon assistant/thinking history items (build_items); persistence inchat_history.reasoning_contentand the echo back into context predate this feature. - Frontend (
chat-session.js+copilot-render.js, shared by desktop copilot and mobile chat-page):token_deltaaccumulates into a pending assistant bubble (in-place mutation + ~15 Hz flush, blinking caret);done/thinkingfinalize it in place,error/llm_failed/model_fallbackdrop it,tool_start/agent_donefinalize orphan bubbles (reasoning-only rounds, sub-agent final rounds that emit noDone). The reasoning block is a muted, collapsed-by-default native<details>(renderReasoning,.reasoning-blockincopilot-messages.css, i18n keychat.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 namesexecute_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 depthMAX_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 bymax_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 durablescheduled_jobsrow throughloop_adapters/async_task.rs::CronExecutorand returns a receipt immediately; when the job finishes,DurableSinkwrites the result into the parent conversation (synthetic assistant + a completedtask_completedcall) 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_jobis shaped so it cannot do otherwise: oneJobOutcomeclassification, then onematch job.kinddelivery site for every ending. It used to branch onOk/Errfirst and route by kind only insideOk, 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 atask_completedthat 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.statusalways had'cancelled'in its CHECK and nothing wrote it, and the classifier keys on the typedsession::handler::TurnCancellederror, never on the message text. - The chat shows what it started.
ServerEvent::TaskUpdateannounces an async task's state to the source of its parent conversation only (a cron job belongs to nobody's chat), andGET /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 isweb/components/shared/agent-tasks.js(renderTaskStrip), rendered above the composer on desktop and mobile from state owned byChatSession; the drill-in is#session/{id}, gated on_canOpenTaskSessionbecause 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.client→meta.json client→ AUTO by strength). list_agentsreturns task agents only (neverchat/systemones 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:
- 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.
- Resolve the deepest frame's non-terminal calls. A
Runningone is re-gated and re-executed unless the tool says otherwise —execute_cmddeclaresRestartHint::MarkInterrupted(D7), because a command may already have had its effect. AnAwaitingHumanone is re-asked (the card reappears). - Un-wedge: a child that finished but whose result never reached its parent propagates without calling the model again.
- 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
CancellationTokenis minted byLoopManager::start_turnand cloned by value down the whole call tree; a delegate passesctx.cancel.child_token(). It is never re-read from a field mid-turn, which is what makes/stopsticky 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 aroundexecute_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::window — drain(..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.rsanduser_context.rs. It used to beOption<Arc<ContextCompactor>>, keyed on the config section existing — which meant that commenting outcompaction:also silently disabled manual/compact(force_compactreturnedOk(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_compactearly-returns onthreshold_tokens: None;force_compactdeliberately 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 asmax_history_messages.filter(|_| !auto_compaction_enabled)inprojection_cfg.rs. CompactionConfig'sDefaultis hand-written, same trap asRoleAttrs: a derived one giveskeep_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 of a file write 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.
What is invalidated eagerly: the two generated lists, because a stale one makes the model deny a tool it has. The TTL is right for injected content the agent can re-read on demand and wrong for an inventory — a model that reads "no such connector" in the ## MCP servers table does not go looking, it answers the question. So Skald::invalidate_prompt_prefix (the skills door, called straight from skill_register/skill_delete) has two MCP siblings, both looping the all_live() they already had: refresh_global_mcp_access — the admin enabling or re-granting a global connector, where refreshing the access snapshot alone fixed what mcp.tools() offers while leaving the table describing the world before it — and refresh_connector_after_reinstall, where a reinstall's new llm_short_description reached the runtime but not the prompt. Order is load-bearing and opposite to the intuition: render_mcp_list renders the live runtime's in-RAM state, not the DB, so the invalidation goes last, after the snapshot refresh and after the servers restart — rebuild the prefix first and it is repopulated from the very descriptions being replaced, with nothing left to invalidate it again. In the reinstall that means waiting out a global dependency install that can take minutes; correct anyway, since those users were already reading a stale table and an early rebuild would only freeze the stale one in place. The price is a provider cache miss on the next turn of every open conversation of every live user — cross-user by nature, since one admin is changing something for other people, and there is no cheaper direct path the way there is for a user editing their own memory. It buys back the failure the skills doc-comment already describes word for word.
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.batis 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_BIN → bin/skald → target/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 -dwill 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.
The changelog
CHANGELOG.md (repo root) is the release history, and it carries the twin standing rule: every change a user or an operator would notice must add a bullet under ## [Unreleased] in the same change — a feature, a behaviour change, a bug fix, a new config key, an image-tag bump. Same reason as docs/: written after the fact it is written from the diff, which is exactly the version nobody can use.
Format is Keep a Changelog: newest first, one ## [x.y.z] - YYYY-MM-DD section per released version, bullets grouped under Added / Changed / Fixed / Removed / Security. The versions are the workspace Cargo.toml version — the same string ci/verify-version.sh gates a release PR on — so cutting a release is two edits in one commit: bump version in Cargo.toml, and rename ## [Unreleased] to the version with today's date, leaving a fresh empty Unreleased above it. There are no git tags on this repo; the changelog is the record of what a given v{version} tarball contains.
Entries are written for the person reading the release, not for the person who wrote the code: say what changed for them, not which module moved — the commit message and the diff already hold that. Which is also the test for whether a bullet is owed at all: a refactor with no observable effect gets none, however large. Keep one bullet per user-visible thing, not one per commit, and fold a fix-on-top-of-an-unreleased-feature into that feature's bullet rather than listing a bug that never shipped. History before 0.2.0 is not covered — git is the record for it.
Config
Copy default.config.yaml → config.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.
Two kinds of tab, and the difference is what a tab names. A primary tab is a source: it shows whatever web / project-7 currently points at (sources.active_session_id), which is also where background delivery lands — notify, a finished async task, an inbound Telegram message — and what a /new moves to a fresh row. At most one per source; a project's Open chat always lands on it and never mints a conversation (provision_session(reset:false)). A secondary tab is one specific conversation, opened with +: its source points elsewhere, so it is unreachable by source name and is addressed by id everywhere — REST, WebSocket, event filtering. Nothing is delivered to it from outside. POST /api/sessions/new creates one without touching sources, which is the entire difference from POST /api/sessions (a reset). Its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context.
The queue and the model pin are keyed by conversation, not by source (ChatHub.inboxes: HashMap<i64, ConversationInbox>, selected_clients: HashMap<i64, String>). This is the load-bearing half: two tabs on one source would otherwise serialize into one queue and one turn, and share a /model pin — while the security group was already per-session and persisted, so the pin was the odd one out. The source-taking methods survive as one-line resolvers (send_message → send_message_to_session, and _for_session twins for context/cost/compact/mcp/model/cancel/resume/upload), so Telegram, mobile and cron are untouched. Cost of the rekey: queues now grow with conversations-talked-to-since-boot rather than with the four-or-five sources, so a reset retires the queue it replaces (retire_inbox → ConversationInbox::close, consumer breaks) instead of leaving a parked task forever.
Events are filtered per conversation (ge.session_id == Some(session_id)), which is why anything a chat must see has to carry a session id — an untagged GlobalEvent now reaches nobody. Two emitters had to be fixed for exactly that: show_file_to_user's OpenFile (the tool takes a session_id from handler.session_id via the interface-tools builder) and revalidate_security_groups, which now returns (session_id, source, group). The inbox lifecycle events (Approval*/Clarification*/Elicitation*) stay the deliberate exception and go to every connection, since they carry ids only and drive the sidebar badge. A primary WS connection additionally follows NewSession for its source — re-binding session_id and its handler mid-loop — so a second window doesn't keep talking to a conversation another window just reset; a session-addressed one ignores it, having been pinned on purpose.
The tab bar is server-side state; the selection is not. Which conversations the copilot shows survives a reload through chat_sessions.is_open (owner table, additive via ensure_column) — GET /api/sessions/open restores them (computing primary per row, since only sources knows), PUT /api/sessions/{id}/open opens/closes one, PUT /api/sessions/{id}/title renames one (title predated all this and was dead; an empty title stores NULL, so the rename box is also the undo). It is deliberately not localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. Which tab is selected stays in sessionStorage (copilot-active-tab), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) is_open defaults to 0 and chat_sessions::create never sets it — every /new leaves its predecessor behind and every system-agent pass mints a row, so DEFAULT 1 would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset moves the flag: provision_session(reset) mints a new row, so POST /api/sessions returns the new id and the new_session event carries it, and _bindTabSession closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens before super.connectedCallback() (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS.
Theme (web/css/variables.css): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (--radius-sm/md/lg), 16px-base chat type, WCAG-fixed contrasts, global :focus-visible ring and prefers-reduced-motion support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet.
i18n (web/lib/i18n.js + web/i18n/{en,it,fr}.js): t(key) helper, I18nMixin re-renders on locale-changed. Resolution order: user preference (users.locale, editable on the profile page) → instance default (registry config key ui_locale, editable by the admin in Settings — declared in skald_core::i18n::config_set) → English. Server-side, never re-implement that chain: skald_core::i18n::resolve_locale(pool, user_locale) is the one function (with default_locale(pool) and language_name(locale) for prompt rendering); they read through db::config because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes ui_locale via skald_core::i18n::set_default_locale (no system bus exists there), the web setup page sends locale to POST /api/setup/user, which writes it through GlobalConfigManager::set. Supported locales are centralized in skald_core::i18n::SUPPORTED_LOCALES and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).
Plugin & backend i18n — two seams, both keyed the same way. A plugin page fragment (served from its own router) localizes client-side: it ships a web/i18n.js module (export default { en, it, fr }, keys namespaced plugin.<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::create → role_default_run_context). The chat surface switches it at runtime like the model pill: copilot.js renders a shield pill (hidden when ≤1 group) fed by GET /api/my/security-groups (the caller's role set, joined with group names; admin → all); selecting one sends the WS control message {type:"select_security_group", group} (chat-session.js::_selectGroup, twin of select_client). The server (ws.rs::handle_select_security_group_msg) validates against the role, persists it on chat_sessions.run_context, updates the live handler, and broadcasts ServerEvent::SecurityGroupSelected so every open tab re-syncs (the initial state is sent on WS connect). Enforcement is server-side via the shared run_context::validate_run_context_for_role (used by both the WS path and the REST set_session_run_context): a non-admin may only pick a group in its role's effective set (else 403), and every other RunContext field (system_prompt, allow_fs_writes/allow_fs_reads, working_directory) is discarded — closing an fs-escalation hole; admin passes through unchanged.
Selection is gated once; the persisted group is re-checked on every load. validate_run_context_for_role runs at selection time, and the result is persisted on chat_sessions.run_context — so on its own it let a group survive the role that granted it, indefinitely and across restarts (revoke ops from a role, and every session that had already picked it kept running on it). The fix is a second, narrower seam: run_context::reconcile_group_for_user, run by ChatSessionManager::get_or_create_handler on every handler build, which treats the stored group as advisory and degrades it when the owner's current role no longer allows it. Three properties are load-bearing: (a) it degrades to the role's default group (role_default_group, the same seam sessions.rs uses for a new session, so start-group and fallback-group cannot drift) — never to None, because a missing group means the catch-all default, whose rules are the fallback tier under every other group, so clearing widens; (b) it touches only security_group, unlike the selection path, so a project session's server-built project_root/system_prompt survive a permissions edit; (c) on uncertainty (unknown user, unreadable role, DB error) it leaves the stored group alone — guessing could only widen. The liveness half is Skald::revalidate_security_groups_for_{user,role}, called synchronously from the roles API (update) and the users API (role reassignment), which reconciles already-open handlers, persists, and emits SecurityGroupSelected so the pill re-syncs. Same rule as revocation: authorization is pushed, never left to the bus.
The role editor (roles-page.js) sets the default group + an allowed-groups checklist (→ attrs.permission_groups) + a default-assistant select (→ attrs.chat_agent) fed by GET /api/agents filtered to type:chat minus project-coordinator (source-driven); the same exclusion is enforced server-side in the roles API (validate_chat_agent).
| File | Element | Notes |
|---|---|---|
copilot.js |
<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 |
files-page.js |
<files-page> |
#files — the caller's whole space. Level 0 is the virtual root (GET /api/files/roots), level 1 the shared <file-explorer>. See the Files section |
shared/file-explorer.js |
<file-explorer> |
The explorer itself, host-agnostic: root + rootLabel + optional rel, can_write read from the listing. Used by #files and the project board |
connector-detail.js |
<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 |