This file documents the Devii assistant subsystem (devplacepy/services/devii/ and all its subdirectories: virtual_tools, behavior, email, telegram, container, client, customization, tasks, rsearch, agentic, actions). Claude Code loads it automatically whenever any file under devplacepy/services/devii/ is read or edited.

## Overview and account scope

`DeviiService` is the in-platform agentic assistant (a relocated standalone agent), exposed as a WebSocket terminal at `/devii/ws` (router `routers/devii.py`, prefix `/devii`) and reachable from the **Devii** user-dropdown item (`data-devii-open`) and auto-opened on `/docs` (`data-devii-autoopen`). Opt-in (`default_enabled=False`). Frontend is `static/js/DeviiTerminal.js` (an `Application.js`-instantiated controller, `app.devii`) plus the `devii-terminal`/`devii-avatar` custom elements and ported renderers under `static/js/devii/`, the vendored md-clippy avatar under `static/vendor/md-clippy/`, and `static/css/devii.css`.

A signed-in user's Devii drives **their own** account: the agent's `PlatformClient` uses this instance as base URL and the user's `api_key` as Bearer auth, so admins get admin-level tools. The **LLM** calls use the same `api_key` too: the hub threads `owner_kind` into `_build_settings` -> `build_settings(cfg, base_url, api_key, owner_kind)`, which sets `Settings.ai_key = api_key` for `owner_kind == "user"` (else the configured/internal gateway key). So a user's Devii spend is attributed to them at the gateway (`user:<uid>`) and counts toward any per-user gateway limit, while guests fall back to the internal key. Guests get an unauthenticated sandbox client. Every turn is audited in `devii_turns`.

## Sessions, hub, and channels

`DeviiHub` keeps one `DeviiSession` per owner-and-**channel** `(owner_kind, owner_id, channel)` - `user`/uid or `guest`/`devii_guest` cookie, channel `main` (default, the floating terminal) or `docs` (the in-page docs chat). A session holds a `set` of WebSockets and **broadcasts** every frame to all of that owner-channel's tabs/devices. After a turn it persists `agent._messages` to `devii_conversations` (users only; rehydrated on reconnect -> survives restarts), appends a `devii_usage_ledger` row, and a `devii_turns` audit row. On `attach` it sends a `{"type":"history"}` snapshot so a new tab renders the prior conversation. Tasks persist in `devii_tasks` (owner-scoped; guests use an in-memory db via `tasks.store.memory_db()`).

**Channels are an independent conversation thread only.** A `channel` separates the floating terminal (`main`) from the in-page docs chat (`docs`) so each has its own conversation thread for the same owner. The WS reads `?channel=` (`routers/devii.py`, allowlisted to `{"main","docs"}`, falls back to `main`) and threads it `get_or_create(..., channel=)` -> `DeviiSession(channel=)`. **Only** `devii_conversations` is keyed `(owner_kind, owner_id, channel)` (column added + NULL->`main` backfilled + index extended in `init_db`); lessons, behavior, virtual tools, tasks, and the `devii_usage_ledger`/`devii_turns` 24h quota stay owner-scoped (deliberately shared across channels). `find(...)`/`get_or_create(...)` default `channel="main"` so existing callers (e.g. `/devii/adopt`) are unchanged. The `docs` channel is driven only by `<dp-docs-chat>` (`static/js/components/AppDocsChat.js`): a light-DOM component that calls `/devii/session` (guest cookie), opens `DeviiSocket(handlers, "docs")`, renders user/agent bubbles in docs style (`static/css/docs-chat.css`) via the devii markdown renderer, stubs any `avatar`/`client` request so the agent never hangs, and seeds its first question from the `seed` attribute (the sidebar search box, intercepted). It is mounted on `/docs/search.html` (`templates/docs/_chat.html`); the docs pages no longer carry `data-devii-autoopen`. The docs page no longer auto-opens the floating terminal.

## The Docii docs channel

The `docs` channel is branded **Docii**, a documentation-only assistant, built by specialising the same `DeviiSession` on `self.channel == "docs"` (no separate service):

- **Prompt.** `session.py` sets `self._system_prompt = DOCS_SYSTEM_PROMPT` (instead of `_system_prompt_for`) - it forces the agent to call `search_docs` first for every question, to recursively refine-and-search (reading the returned section `content`, then searching again with new keywords) until grounded, to answer ONLY from retrieved docs, and to do no platform actions. `_compose_system_prompt()` returns it verbatim for docs (the owner `BEHAVIOR_HEADER` is NOT appended, so a user's general Devii behavior rules can't derail it).
- **Tools.** `_builtin_tools()` filters `CATALOG.tool_schemas_for(...)` to the `DOCS_TOOLS` allowlist (`{"search_docs"}`); `_refresh_tools()` for docs sets `self.tools[:] = _builtin_tools()` with NO virtual tools. So the docs agent has exactly one tool and cannot wander.
- **Greeting.** `bootstrap_greeting()` returns `DOCS_GREETING` for docs.
- **search_docs.** `services/devii/docs/controller.py` delegates to the in-process page index `docs_search.search_pages(...)` (no HTTP), returning per result `title`, `url` (`/docs/{slug}.html`), `score` (BM25), and `content` (page text truncated to `CONTENT_CHARS`), admin-filtered by the dispatcher's `is_admin`. So "visiting the search results" is repeated `search_docs` calls; the default 40 `max_tool_iterations` leaves ample room to recurse.
- **References and inline linking.** Because results carry real `url`s and `score`s, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to their `url` and (b) end with a `## References` list of the pages used as clickable links with their score. The devii markdown renderer makes `/docs/...` links clickable client-side.
- **Topic gate (follow-up vs new).** `_run_turn` runs `_docs_topic_gate(text)` before the main loop (docs channel only). When there is prior history it calls `_classify_topic` (a no-tools `LLMClient.complete_text` with `TOPIC_CLASSIFIER_PROMPT`, parsed by `_parse_topic`, defaulting to `follow_up` on any failure). On `new` it resets `agent._messages` to `[system]`, clears the persisted docs conversation, and emits `{type:"topic", decision, reason}`; the frontend clears the log, re-shows the current question, and prints a reasoning note. On `follow_up` it keeps context and shows the note.
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. **Scheduled tasks only ever run in the `main` channel**: there is one global scheduler and `DeviiService._resolve_task_owner` always resolves the owner's session with `channel="main"`, so a due task can never be handed to a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
- The `main` channel is untouched (full 90-tool catalog + behavior header + Devii greeting).

## Docs search mode and BM25 fallback

The docs search surface is admin-configurable via the `docs_search_mode` site setting (`agent` | `bm25`, default `agent`, on `/admin/settings`, seeded in `init_db` `operational_defaults`, field in `AdminSettingsForm`). `routers/docs/views.py` `docs_page` (slug `search`) resolves the effective mode: `_agent_search_state(request, user, is_admin)` returns `disabled` (Devii off, or guests-disabled for a guest), `quota` (viewer over their `daily_limit_for`/`spent_24h`), or `ok`. Agent renders **only** when `mode=="agent"` and state `ok`; otherwise it runs the classic `docs_search.search(...)` (BM25, `docs_search.py`) and renders `templates/docs/_search.html`. `docs_base.html` branches the `kind=="search"` include on `search_mode`; on a quota fallback (`quota_fallback`) the BM25 page shows a "reached your daily AI assistant limit" hint. So `docs_search.py`/`_search.html` are live again (the bm25 path), not orphaned.

## Conversation, lesson, and task persistence

Conversation history persists to `devii_conversations` (rehydrated on reconnect, surviving restarts); per-turn cost goes to `devii_usage_ledger` (the authoritative 24h-spend source - the in-memory `CostTracker` is display-only) and an audit row to `devii_turns`; tasks live in `devii_tasks` (guests use an in-memory store).

**Per-owner self-learning memory is privacy-critical.** The `LessonStore` (reflect/recall) is **owner-scoped, never shared**: `LessonStore(db, owner_kind, owner_id)` filters every read/write by owner. The hub builds one per session over the same `owned_db` as the task store - the main `db` (table `devii_lessons`) for signed-in users (persistent, isolated, survives restarts) and a fresh `memory_db()` for guests (ephemeral, scoped to that web session). `forget_lessons` (agentic tool -> `LessonStore.clear()`/`delete()`) lets the user purge them; the system prompt forbids storing credentials/secrets in lessons. **Regression to avoid: do NOT share one `LessonStore` across sessions** - that leaked one user's reflected lessons (including credentials) into every other user's recall.

**Lesson retention and deduplication.** Every `add()` deduplicates against existing active lessons via Jaccard similarity (threshold 0.70): a near-duplicate bumps the original's `hits` counter and refreshes `created_at` instead of inserting a new row. A per-owner cap (`devii_lessons_max_per_owner`, default 500, configurable on `/admin/services` and `site_settings`) is enforced on every insert: when exceeded, the oldest lessons are soft-deleted (`deleted_by="retention"`). Age-based pruning runs on `DeviiService.run_once()` (every 60s on the lock-owner worker): lessons older than `devii_lessons_max_age_days` (default 90, configurable) are soft-deleted across all owners. Both settings persist in `site_settings` and are seeded in `init_db`. The `devii_lessons` table is in `SOFT_DELETE_TABLES` for admin Trash restore/purge.

**Quality signals.** Each lesson has a `rating` column (integer, default 0). The `lesson_rate(uid, value)` agentic tool (value 1 for useful, -1 for unhelpful) lets the agent self-rate lessons. In `_rebuild()`, lessons with `rating <= -3` are excluded from the BM25 index and therefore never returned by `recall()`. The `lesson_count()` tool reports active lesson count.

**CLI:** `devplace devii lessons count` reports active/soft-deleted totals; `devplace devii lessons prune --all-owners|--username USER` soft-deletes old lessons; `devplace devii lessons clear --force` hard-deletes all rows. Rate-limiting guard: `run_once` swallows all exceptions so a bad schema never stops housekeeping.

## Reminders and scheduled tasks (persistent across reboot)

`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.

**Any signed-in account may schedule; two rolling 24-hour quotas bound it, and role decides the size.** Guests never can (`automation_allowed` requires `owner_kind == "user"`). Defaults: a member may CREATE `devii_task_member_create_24h` (5) tasks and EXECUTE `devii_task_member_runs_24h` (10) runs per 24h; an administrator gets `devii_task_admin_create_24h` (5) and `devii_task_admin_runs_24h` (100). All four are admin-editable on the Devii service; `0` means unlimited.

**Each quota is enforced by a single atomic conditional INSERT, never a check-then-act** (`tasks/limits.py`):

- `reserve_run(...)` -> `INSERT INTO devii_task_runs ... SELECT ... WHERE (SELECT COUNT(*) ... created_at >= :cutoff) < :limit`, decided on the driver's real `rowcount`. The scheduler calls it AFTER `claim` and BEFORE dispatch; a refusal releases the claim via `defer`.
- `insert_task_within_quota(...)` -> the same shape for the task row itself, so two concurrent `create_task` calls (main + telegram channel, or two workers) can never both slip past a check. `TaskStore.create` pre-checks for a good error message, then relies on this insert for the actual guarantee.

Both are proven with real multi-process races (16 processes -> exactly 10 runs for a member, 12 processes -> exactly 5 creations). **Never replace either with a count-then-insert.**

**Creation counts the task row itself, so deleting a task does NOT give the slot back** - the window counts `devii_tasks.created_at` regardless of `deleted_at`. Runs are counted in the dedicated `devii_task_runs` ledger (a derived counter table, NOT in `SOFT_DELETE_TABLES`; hard-pruned to 48h by `run_once`).

**A quota refusal postpones, it never disables.** `retire_reason` (terminal: guest owner, expiry, `max_runs`, failure streak) disables the task; `run_deferral`/`budget_deferral` push `next_run_at` to when the slot frees (`Quota.free_at` = the oldest run in the window + 24h) and leave the task enabled, audited `devii.task.deferred`. Keep that split: a rate limit that disabled tasks would silently destroy a user's automation.

**The task-run context is the unhackable part** (`tasks/context.py`). `task_run_scope()` sets a `contextvars.ContextVar` around the executor inside `run_row`, so:

- Everything the scheduled agent does - including subagents, `delegate`, and virtual tools, which all share the dispatcher - runs inside that context.
- A concurrent interactive turn runs in its own asyncio Task context and can never see or clear it (asyncio copies the context at task creation).
- **There is no tool, argument, or prompt that can write it.** The model only ever influences tool arguments; the flag lives in process state set by the scheduler. This is why the nesting rule is enforced on the ContextVar rather than on anything the model can say.

`nesting_allowed(owner_id)` = `not in_task_run() or owner_is_admin(owner_id)`. `TaskStore.create` and `TaskStore.update`-when-enabling both consult it, so a member's task run cannot create a task, re-enable one, or `run_task_now` - while an administrator's task may schedule follow-up work. The session also TELLS the agent where it is: `_compose_system_prompt()` injects the `# TASK RUN ENVIRONMENT` block (`TASK_RUN_MEMBER`/`TASK_RUN_ADMIN`) only when `in_task_run()`.

Beyond the quotas, three chokepoints still gate scheduling (one alone is not enough - the tool gate is a UI control, the store gate covers every writer, and the claim gate is the only one that also retires rows already in the database):

1. **Tool visibility and dispatch.** `create_task`/`update_task`/`run_task_now` are `requires_auth=True`, so guests are never offered them. `list_tasks`/`get_task`/`delete_task` stay open.
2. **`TaskStore.create` / `TaskStore.update`.** Raises `AutomationDenied` (with `retry_at` when a quota is the cause) for a guest owner, a nested member creation, or a spent creation quota. Enabling a task takes the same nesting check; disabling is always allowed, which is what lets the scheduler and admins stop things. This sits below the dispatcher, so it also covers the standalone `devii` CLI - whose store passes `operator=True`, the one deliberate exemption for the local operator - and anything added later.
3. **`Scheduler`/`GlobalScheduler` claim.** `retire_reason` runs before every execution and re-checks owner kind, expiry, run limit, and failure streak, so a task that must stop is retired at execution time with no migration and no manual step.

**`TaskStore._ensure_schema` declares the FULL column set** (`TASK_COLUMNS`) rather than leaning on dataset's lazy schema - the atomic INSERT names columns explicitly, and dataset skips a `None`-valued key when creating columns, so a lazily-built table would be missing `run_at`/`cron`/`start_at`. Both `_ensure_schema` and `_ensure_indexes` tolerate a concurrent `duplicate column` / `already exists` error: several processes construct a `TaskStore` at once and SQLite DDL is not idempotent (this was a real crash, found by racing 12 processes).

**Role is live, never frozen into the session.** `DeviiSession.is_admin`/`is_primary_admin` are properties resolving `get_admin_uids()`/`get_primary_admin_uid()` (both TTL-cached and cross-worker invalidated). `_refresh_privileges()` runs at the top of every turn AND inside the scheduled executor: it rewrites the base system prompt when the role changed and pushes the flags into the dispatcher via `Dispatcher.set_admin`. Regression to avoid: the old code captured `is_admin` as a bool at session construction, and a session with pending tasks was exempt from `gc_idle`, so a **demoted admin kept admin tools indefinitely**.

**One global scheduler, not one per owner.** `DeviiService.scheduler()` owns a single `GlobalScheduler` started in `on_enable` on the lock-owner worker. Each 1s tick issues ONE indexed query (`tasks.store.due_rows`, served by `idx_devii_tasks_due` on `(enabled, status, next_run_at)`) instead of one query per owner per second, and hands out at most `devii_task_max_concurrent` slots **round-robin, one at a time per owner**, so no owner can monopolise the scheduler. A row is taken with an atomic conditional claim (`tasks.store.claim` -> `UPDATE ... WHERE uid=? AND status='pending' AND enabled=1 AND deleted_at IS NULL`, decided on the driver's real `rowcount`), which closes the TOCTOU between a tick and `run_task_now`. Sessions are materialized lazily by `_resolve_task_owner` only when a task actually fires, so `gc_idle` now reaps on `session.is_busy()` (a turn in flight) rather than exempting every task-owning session forever. The per-store `Scheduler` class remains for the standalone `devii` CLI; both share `run_row`/`retire`/`refusal`, so there is one implementation of the guard logic.

**Every recurring task stops by itself.** `Schedule` enforces `MIN_INTERVAL_SECONDS` (900) on `every_seconds` and on cron spacing - the cron check walks a full day of fires from a canonical midnight reference and rejects the smallest gap, so `*/18` is refused for its 6-minute hop at the hour boundary and the verdict never depends on the current minute. `tasks.guards.task_columns` defaults `max_runs` to `DEFAULT_MAX_RUNS` for recurring kinds and stamps `expires_at`, capped at `MAX_LIFETIME_DAYS` (30) past the first run. `guards.expiry_of` falls back to `created_at + MAX_LIFETIME_DAYS` when the column is absent, so a legacy or hand-written row is bounded too. A run that raises increments `failure_count` and reschedules; `devii_task_max_failures` consecutive failures disable the task (previously a single failure left it stuck in `status="error"` forever, silently). `run_once` housekeeping also disables tasks whose owner has not been seen for `devii_task_owner_idle_days`. Every automatic stop writes `devii.task.blocked` with its reason.

**Automation has its own budget.** `quota_exceeded` guards only interactive turns, and `devii_admin_daily_usd` defaults to 0 (unlimited) - which after the admin-only rule is exactly the population that schedules. `devii_task_daily_usd` (`DeviiService.automation_budget_exceeded`) is a separate rolling-24h cap checked in the claim path; spend itself was always ledgered by `_record_spend` in the executor's `finally`.

Admin surface: `/admin/devii-tasks` (`routers/admin/devii_tasks.py`) lists every task across owners with its bounds and per-row disable/delete; `devplace devii tasks list|disable|prune` is the CLI counterpart (`prune` disables every task whose owner may no longer schedule).

A task created as a reminder carries `notify=1`: when it finishes, `session._deliver_reminder` sends a `utils.create_notification(owner, "reminder", result, target_url="/devii")` (the `reminder` notification type), so the in-app notification + live toast reach the user regardless of the terminal.

**Timezone awareness.** The terminal sends a `clientinfo` frame (`Intl...timeZone` + UTC offset) on connect; `session.set_clientinfo` stores it and persists `users.timezone` (`database.set_user_timezone`). `session._compose_system_prompt()` injects a `# CURRENT TIME` block (UTC now + the user's local time/timezone, falling back to the stored `users.timezone` for a headless run) so the agent converts wall-clock requests to a correct UTC `run_at`; relative requests use `delay_seconds`. The `REMINDERS AND SCHEDULED TASKS` system-prompt rule makes the agent SCHEDULE rather than fire a delayed instruction immediately, and set `notify=true` for reminders.

## Cost, quota, and the financial-data-is-admin-only rule

**Financial cap.** The rolling-24h `$` limit is read from `devii_usage_ledger` (`UsageLedger.spent_24h`), **not** the in-memory `CostTracker` (which resets on reboot and is display-only). Checked before each turn in the router; a turn already over is blocked (the over-limit WS error states only "Daily AI quota reached (100%)", never a dollar figure). The caps are `devii_user_daily_usd`, `devii_guest_daily_usd`, and `devii_admin_daily_usd` (default `0` = unlimited, admins exempt by default), resolved via `config.effective_daily_limit`. `config.effective_daily_limit(cfg, owner_kind, is_admin)` is the single source of truth, used by both `service.daily_limit_for` and `build_settings`: guests use `devii_guest_daily_usd` (default $0.05), users `devii_user_daily_usd` ($1.00), and **administrators are exempt by default** via `devii_admin_daily_usd` (default `0.0`, where **`0` = unlimited**). All three are editable on the Devii service config (`/admin/services`). The WS gate is `if limit > 0 and spent >= limit` so a `0` cap never blocks.

**Every billed turn is ledgered, not just interactive ones:** `session._record_spend` writes the usage delta in the `finally` of both `_run_turn` AND the scheduler executor, and it runs even when a turn is cancelled/superseded, so scheduled-task spend and interrupted-turn spend count against the cap rather than being forgiven. Pricing is admin-configurable - `cost/tracker.py` resolves a `Pricing` object per `CostTracker` rather than import-time env constants.

**Quotas are resettable** (clearing the owner's `devii_usage_ledger` rows): per-user via `POST /admin/users/{uid}/reset-ai-quota` (button on the admin Users page), globally via `POST /admin/ai-quota/reset-guests` and `/reset-all` (buttons on `/admin/ai-usage`), and from the CLI via `devplace devii reset-quota <username> | --guests | --all`.

**Financial data is admin-only:** any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the **percentage** of their 24h quota used. The owner's admin status is resolved server-side (`is_admin(user)`) and threaded WS -> `hub.get_or_create(is_admin=)` -> `DeviiSession(is_admin=)` -> `Dispatcher(is_admin=)`. The `Action` dataclass has a `requires_admin` flag (`cost_stats` is admin-only USD; the member-safe `usage_quota` tool returns **only** `{used_pct, turns_today, limit_reached}` with no money and is available to everyone). Gating is double: `Catalog.tool_schemas_for(authenticated, is_admin)` never hands an admin-only tool's schema to a non-admin, and the dispatcher independently raises `AuthRequiredError` for any `requires_admin` action a non-admin attempts. `usage_quota`'s data comes from `DeviiSession._quota_snapshot` (ledger `spent_24h`/`turns_24h` over `settings.daily_limit_usd`); for non-admin owners the system prompt also appends a hard rule forbidding any cost disclosure (defense in depth). `GET /devii/usage` mirrors this: it always returns `used_pct`/`turns_today` and adds `spent_24h`/`limit` only for admins. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process). The catalog's cost/analytics HTTP tools `ai_usage` (GET `/admin/ai-usage/data`, USD breakdown) and `site_analytics` (GET `/admin/analytics`) are also `requires_admin=True`, so a non-admin session is never offered them and the dispatcher blocks them even if named - the platform 403 is no longer the only guard. The profile page is correctly split too (`_ai_quota(include_cost=viewer_is_admin)` emits dollars only for admins, in both its HTML and JSON forms, so a member fetching their own profile with `Accept: application/json` never sees dollars either).

## Aggregate analytics (no pagination)

Site analytics is a **documented, admin-secured HTTP endpoint** - `GET /admin/analytics` (`routers/admin/` package, returns JSON, `is_admin` check -> 403 otherwise) backed by `database.get_platform_analytics()` (single UNION query over `posts/comments/gists/projects.created_at`, TTL-cached 60s; total members, active users 24h/7d/30d, signed-in-now, signups, content totals, top authors). Devii calls it as a normal `http` catalog action `site_analytics` (GET `/admin/analytics`, `top_n` query param) - the platform enforces admin, so it behaves identically for the web and the `devii` CLI and never touches the DB directly from the agent. It is documented in the Admin API docs group (`docs_api.py`, id `admin-analytics`). The system prompt tells the model to use it for any "how many"/"how active" question instead of paging `admin_list_users` (which caused a pagination storm), and to `search_docs` before guessing a route rather than probing URLs.

## Context-window sizing (DeepSeek V4 Flash, 1M tokens)

The upstream model `deepseek-v4-flash` (what `deepseek-chat` routes to; default `gateway_model`) has a 1,048,576-token context and 384,000-token max output. All Devii size limits are **characters**, derived in `services/devii/config.py` from one source of truth: `CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS` (600,576 tokens), and `DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN` (3 chars/token = ~1.8M chars). At the conservative 3-chars/token estimate the worst-case input at compaction plus the full max output plus the system reserve sums to exactly the 1M window, so it can never overflow. `DEFAULT_MAX_RESPONSE_CHARS` (200,000) is the master chunk knob: every non-`chunks` tool result passes through `wrap_if_large(result, max_response_chars)` in `actions/dispatcher.py`, and `read_more` slices are clamped to it, so a file up to ~200KB returns in **one** read instead of paging in 12KB slices (the old read_more storm). `OUTPUT_CAP_CHARS` (`agentic/loop.py`, 400,000) must stay **above** `max_response_chars` or it would re-truncate a full chunk envelope. `ChunkStore` caches up to `STORE_MAX_CHARS` (8MB) per entry, `STORE_MAX_ENTRIES` (16) entries; `SUMMARY_INPUT_CAP` (`agentic/compaction.py`, 600,000) bounds what the summarizer ingests when compacting. When changing the upstream model, retune from `CONTEXT_WINDOW_TOKENS`/`MAX_OUTPUT_TOKENS` - everything else derives.

## Multi-worker service-lock routing

Hubs are per-process, so `/devii/ws` is served **only** by the worker that holds the background-service lock (`service_manager.owns_lock()`, set in `main.py` startup); a non-owner worker `close(4013)` (an application code in the private 4000-4999 range, reliably delivered as the browser `CloseEvent.code`). `DeviiSocket.js` recognises 4013 as "wrong worker, not yet settled" and fast-retries in ~200ms **silently** (no "disconnected, reconnecting..." notice), so with 2 prod workers the client converges on the owner in a fraction of a second instead of bouncing every 1500ms. The visible "connected." notice is emitted on the first server frame (`onReady`), never on raw socket open, so an accepted-then-4013-closed handshake on the wrong worker is invisible. The disabled/no-service path keeps the standard `1013` (a real, user-visible disconnect). The cap stays correct regardless because it reads the DB.

## Client-side browser-automation tool channel

Beyond the avatar, Devii has a `client`/browser channel using the same request/response pattern: `services/devii/client/ClientController` is bound on `attach`, and `actions/client_actions.py` exposes `get_page_context`, `run_js`, `highlight_element`, `clear_highlights`, `show_toast`, `scroll_to_element`, `navigate_to`, `reload_page` (`handler="client"`, all `requires_auth=False`). The session's generic `_browser_request(channel, action, args)` powers both `avatar` and `client`; the router resolves both `avatar_result` and `client_result`. Frontend `static/js/devii/DeviiClient.js` executes the actions in the user's own browser and the terminal routes `type:"client"` frames to it, returning `client_result`. This powers live on-screen tutorials, page-context awareness, and navigation/refresh. `navigate_to`/`reload_page` reply *before* unloading so the turn completes, then the persistent session reconnects.

**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.

## Stale API key auto-recovery (401 self-healing)

A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.

## Shared browser session (auth adoption)

The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.

## Screen awareness

The `SYSTEM_PROMPT` instructs Devii, after a mutation, to `get_page_context` and `reload_page` when the page the user is viewing displays the data just changed, so settings/list/detail views the user is watching update live without a manual refresh.

## Project filesystem tools: line-range editing and the overwrite-protection guard

Devii's project filesystem tools include surgical **line-range editing** (`project_read_lines`, `project_replace_lines`, `project_insert_lines`, `project_delete_lines`, `project_append_file`) so it edits large text files without resending them, and `Dispatcher._run_http` enforces an **overwrite-protection guard**: `project_write_file` is rejected only when the target `(slug, path)` **already exists** (the guard probes `GET /projects/{slug}/files/raw`) and the agent has not read it this session (call `project_read_file` first); creating a new file needs no prior read. Reads are recorded in the per-session `Dispatcher._read_files` set. This steers the agent to update files rather than blindly overwrite them and is agent-scoped only (the human UI and public HTTP API are unaffected). The `WRITING AND EDITING FILES` system-prompt rule mirrors it.

## Web fetch and arbitrary HTTP (`handler="fetch"`)

`services/devii/fetch/FetchController` (a standalone `httpx.AsyncClient`, not `PlatformClient`) backs two tools: `fetch_url` (read a page as readable text, `requires_auth=False`) and `http_request` (`requires_auth=True`) - the general external HTTP client. `http_request` takes `method` (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), `headers` (object), and one body of `json` (object/array, Content-Type set automatically), `form` (url-encoded), or `body` (raw string), and returns `{http_status, headers, content_type, content}`; it does **not** raise on non-2xx so the model can read API errors. Both run through one `_stream()` (`fetch_url` via `_download` with `raise_5xx=True`; `http_request` with `raise_5xx=False`) sharing the SSRF `_guard` (refuses private/loopback unless `DEVII_FETCH_ALLOW_PRIVATE`), `fetch_max_bytes` cap, timeout, and decoding. The `object`-typed params required adding an `object` branch to `Action.tool_schema()` in `spec.py` (emits a valid open-object schema). The `SYSTEM_PROMPT` "WEB REQUESTS AND EXTERNAL APIS" rule tells the agent to call `http_request` DIRECTLY for an API and never wrap it in a self-invoking user-defined tool (a virtual tool only re-runs the agent and cannot do I/O, so wrapping the call recurses into the `MAX_EVAL_DEPTH` guard - the exact failure this fixed). To attach a remote file see `attach_url` / `store_attachment_from_url` under Attachments and media.

## Remote web tools (rsearch, non-platform)

`services/devii/rsearch/RsearchController` (`handler="rsearch"`, registered in `registry.py`, specs in `actions/rsearch_actions.py`, all `requires_auth=False`) exposes four tools that hit the EXTERNAL public service `https://rsearch.app.molodetz.nl` over a standalone `httpx.AsyncClient` (like `FetchController`, not the platform-bound `PlatformClient`): `rsearch` (web/image search, optional `content`/`deep`/`type=images`), `rsearch_answer` (AI answer grounded in fresh web search, returns answer + sources), `rsearch_chat` (direct AI chat, no search), `rsearch_describe_image` (vision describe a public image URL). These are NOT platform-specific: the `SYSTEM_PROMPT` "REMOTE WEB TOOLS" section makes the model prefer platform tools always and call `rsearch_*` only when the user explicitly asks to search the web/an outside source, never to answer questions about this instance. When adding another remote service, add a controller + handler literal in `spec.py` + a dispatcher branch rather than routing external calls through `PlatformClient` (which is hardwired to the instance base URL).

rsearch has its **own** read timeout (`rsearch_timeout_seconds`, field `devii_rsearch_timeout` / env `DEVII_RSEARCH_TIMEOUT`, default **300s** with a 30s connect cap via `httpx.Timeout`) - it does NOT share `fetch_timeout_seconds`, because web-grounded answers legitimately take minutes.

**rsearch is metered into the gateway ledger.** Because these calls never traverse the gateway upstream, `RsearchController` (constructed with `owner_kind`/`owner_id` from the dispatcher) records one `gateway_usage_ledger` row per call inside its single choke point `_request()` via `GatewayUsageLedger.record_external(...)` (backend `rsearch`, zero tokens, the flat admin-set `gateway_rsearch_cost_per_call`, success and failure both logged). The DeepSearch worker's `crawl.search_queries` emits a `{"type":"rsearch"}` NDJSON frame per search that `DeepsearchService._run_worker` ledgers in-process under the job owner. This keeps `gateway_usage_ledger` (and `/admin/ai-usage`) a complete record of platform AI spend - external AI calls included. `record_external` is the helper to reuse for any future off-gateway AI call.

## Email tools (IMAP/SMTP, non-platform)

A signed-in user's Devii can connect to their OWN external mailbox via `services/devii/email/EmailController` (`handler="email"`, specs in `actions/email_actions.py`). See `devplacepy/services/email/CLAUDE.md` for the full protocol engine, credential storage, and tool list.

## Telegram bot

Devii is reachable over Telegram via `channel="telegram"` sessions driven in-process by `services/telegram/bridge.py`. See `devplacepy/services/telegram/CLAUDE.md` for the worker/bridge architecture, pairing flow, and message-update behavior.

## Database API tools (`db_*`, primary-administrator only)

Devii exposes read-only `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query`, and `db_design_query` tools gated `requires_primary_admin=True`. See `devplacepy/services/dbapi/CLAUDE.md` for the full auth boundary, validation pipeline, and async query service - these tools are added to the LLM tool list only for the primary administrator; every other session is unaware they exist.

**`db_query` and `db_design_query` MUST stay marked `is_read_only=True`.** They are POST routes, so they carry a request body and would otherwise be classified as mutating by `method in MUTATING_METHODS` alone - but they only read. Marking them read-only is what keeps them from tripping the agentic plan gate and the verification gate, which would discard the very rows the user asked for. `tests/unit/services/devii/actions/catalog.py` guards it.

## All Devii/gateway network timeouts are admin-configurable with a five-minute (300s) floor

The Devii LLM-client and `PlatformClient` read timeout is `timeout_seconds` (field `devii_timeout`), the fetch/docs tool timeout is `fetch_timeout_seconds` (field `devii_fetch_timeout`), web search is `rsearch_timeout_seconds` (`devii_rsearch_timeout`); each defaults to 300s with `minimum=MIN_TIMEOUT_SECONDS` (300). The gateway upstream timeout is `gateway_timeout` (`minimum=TIMEOUT_MIN`=300), which also bounds the vision describe-image call (it shares the gateway's httpx client, no per-call override). A stored value below the floor fails `ConfigField.coerce()` and `read()` falls back to the default, so old sub-300 values upgrade automatically. Devii's LLM timeout was previously a hardcoded 45s while the gateway waited up to 180s x retries - large prompts aborted as `[model error] Could not reach the model endpoint:` (an httpx timeout, which stringifies to empty). `build_settings()` now reads these from config instead of hardcoding them.

## Config

Config is all `config_fields` (AI url/model/key, base url, plan/verify toggles, max iterations, JS-execution toggle, user/guest 24h caps, guests toggle, pricing). `effective_config()` falls back the AI key to `DEVII_AI_KEY` and the base url to the instance origin.

## Every controller `dispatch` MUST return a JSON string (hard rule)

`Dispatcher.dispatch` is annotated `-> str` and its result is placed verbatim into the tool message as `{"role": "tool", "content": result}` (`agentic/loop.py`). A controller that returns a raw `dict`/`list`/`None` instead of a serialized string is never caught locally - `wrap_if_large` measures `len(result)`, which on a dict is the **key count**, so a small dict sails through unchunked and untruncated - and the request dies at the upstream with `400 ... content should be a string or a list`, killing the whole conversation mid-turn with no server-side traceback. This was a real production failure: `WorkspaceController.dispatch` was annotated `-> Any` and returned raw dicts from all 20 of its handlers, so any turn that called `workspace_list` (or any other workspace tool) 400'd. The same shape also silently disabled chunking for those payloads. Serialize at the controller's single `dispatch` choke point (`json.dumps(payload, ensure_ascii=False, default=str)` - `default=str` because DB rows carry datetimes), never per handler, and keep the `-> str` annotation so the contract is visible. `tests/api/projects/workspace.py::test_workspace_controller_always_returns_a_json_string` guards it.

## Backend confidentiality

Devii must never disclose the underlying model, provider, or any upstream URL - enforced in two layers. The `SYSTEM_PROMPT` (`agent.py`) forbids it even when such values appear inside a tool result. Structurally, `text.redact_backend()` runs on every JSON HTTP tool response in `format_response()` and scrubs the values of `REDACT_FIELD_KEYS` (`gateway_upstream_url`/`gateway_model`/`gateway_vision_url`/`gateway_vision_model`) and any stat labelled in `REDACT_STAT_LABELS` (`Model`) to `[hidden]`, so the admin-services tools cannot leak the gateway's upstream even though the admin settings UI still shows the real values. Add a config key to `REDACT_FIELD_KEYS` if a new field would expose backend infrastructure to the agent.

## CLI

`pyproject.toml [project.scripts]` ships `devii = "devplacepy.services.devii.cli:main"`. No new dependency (`httpx`/`dataset` already required). The md-clippy avatar is vendored under `static/vendor/md-clippy/`; its `index.js` must not set `globalThis.app` (it would clobber the DevPlace `app`) and its AI proxy is `/devii/clippy/ai/chat`. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process) and both `is_admin`/`is_primary_admin` `True` for the trusted local operator.

## Devii user-defined ("virtual") tools

Users invent new Devii tools in natural language ("when I say woeii, do Y"); each is stored per-owner and added to Devii's live LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's single free-form `input`. Full CRUD is Devii-only (`tool_create`/`tool_list`/`tool_get`/`tool_update`/`tool_delete`, `handler="virtual_tool"`).

- **Dynamic tool list (the crux).** The session tool list is normally frozen (`session.py` `self.tools = CATALOG.tool_schemas_for(...)`, handed by reference to `Agent`, `AgenticController.bind`, and read live by `react_loop` each `llm.complete`). `DeviiSession._refresh_tools()` runs at the top of every `_run_turn` (inside `self._lock`, before `agent.respond`) and rewrites that list **in place**: `self.tools[:] = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin) + self._virtual_tool_store.tool_schemas()`. Because every holder shares the same list object, a tool created/edited/deleted (and a mid-session login) takes effect on the **next** turn with no Agent rebuild. **Never reassign `self.tools`, always mutate in place.**
- **Self-eval engine** (`agentic/controller.py`). `_delegate` was refactored onto `_spawn(prompt, tools, system_prompt)`, which runs `react_loop` with a fresh `messages`/`AgentState` under a **depth guard** (`agentic/state.py` `get/set/reset_eval_depth`, `MAX_EVAL_DEPTH=2`) so delegate/eval/virtual tools cannot self-call infinitely. `run_subagent(prompt)` (tools minus `delegate`) is the public engine; the `eval` agentic tool wraps it; `_delegate` keeps its richer JSON report.
- **Store** (`services/devii/virtual_tools/store.py`). `VirtualToolStore(db, owner_kind, owner_id)` over `devii_virtual_tools` (`uid, owner_kind, owner_id, name, description, prompt, input_description, enabled, created_at, updated_at`; indexes `idx_devii_vtools_owner` / `idx_devii_vtools_name`). `tool_schemas()` builds, for each **enabled** row, an LLM schema with a single free-form `input` string. Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`).
- **Controller** (`virtual_tools/controller.py`). `VirtualToolController(store, evaluator, builtin_names)`, evaluator = `agentic.run_subagent`, `builtin_names = set(CATALOG.by_name())`. CRUD `tool_create/list/get/update/delete` (`handler="virtual_tool"`); `tool_create` validates name `^[a-zA-Z][a-zA-Z0-9_]{1,40}$`, rejects built-in collisions and duplicates, requires `description`+`prompt`. `has(name)` / `run(name, args)` compose `f"{prompt}\n\nUser input: {input}"` and call the evaluator.
- **Dispatch** (`actions/dispatcher.py`). Constructed with `virtual_tools=...`; `_run` routes `handler="virtual_tool"` to CRUD; the **unknown-name branch** (`if action is None`) resolves a virtual tool via `self._virtual_tools.has(name)` -> `run(name, args)` before erroring. So virtual tools live only in the store (not the static catalog) and are resolved on demand; the static `_actions` stays built-in only.
- Registered via `VIRTUAL_TOOL_ACTIONS` (`registry.py`); the `eval` tool via `AGENTIC_ACTIONS`. System-prompt **USER-DEFINED TOOLS (VIBE TOOLS)** section steers creation and warns against deep self-calls.

## Devii self-configured behavior (`services/devii/behavior/`)

Devii can partially configure its OWN system message: every system prompt ends with a `# TRUTH RULES AND BEHAVIOR` section (the `BEHAVIOR_HEADER` constant in `session.py`) whose body is an owner-scoped, persistent set of behavior rules. When the user tells Devii to behave differently, says they expect different behavior, or Devii upsets them, Devii calls the **`update_behavior`** tool (`handler="behavior"`, `requires_auth=False`, single `behavior` arg = the FULL new section content) to persist the change. It is **self-prompted only**: the sole way to change the section is Devii calling that tool - there is no HTTP route or UI, like `LessonStore`/customization. Because Devii already SEES the current section in its own system message, it merges (copy current rules, apply the change, send the whole result) so prior rules are not lost; this is why the tool replaces rather than appends.

- **Store** (`behavior/store.py`). `BehaviorStore(db, owner_kind, owner_id)` over `devii_behavior` (one upserted row per owner keyed on `owner_kind`/`owner_id`; `text()` reads, `set()` upserts; index `idx_devii_behavior_owner`). Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`, like the other owner stores).
- **Controller** (`behavior/controller.py`). `BehaviorController(store)`, `dispatch("update_behavior", args)` -> `store.set(behavior)`. Built in `DeviiSession` and passed to `Dispatcher(behavior=...)`; the dispatcher routes `handler="behavior"` to it and degrades gracefully ("not available in this context") when unwired (e.g. the standalone CLI, same as `virtual_tools`/`avatar`).
- **Injection and refresh** (`session.py`). `_compose_system_prompt()` = base prompt (`_system_prompt_for(is_admin)`) + CA-IWP fragment + live `CHANNEL` block + `\n\n` + `BEHAVIOR_HEADER` (+ `\n` + body when non-empty; just the header when empty). Used to seed the `Agent` and the scheduler executor worker, and `_refresh_system_prompt()` rewrites `agent._messages[0]["content"]` **at the top of every `_run_turn`** (next to `_refresh_tools`), so a mid-conversation `update_behavior` takes effect on the following turn and the live system message is never overridden by the stale base.
- Registered via `BEHAVIOR_ACTIONS` (`registry.py`); the system-prompt **SELF-CONFIGURED BEHAVIOR (TRUTH RULES)** section in `agent.py` steers when/how to call it.

## Channel-Aware Interactive Widget Protocol (CA-IWP / Speak With Buttons)

Devii can ask the user for decisions through a gated tool surface instead of inventing HTML or channel-specific prose. Spec lives as the CA-IWP document; implementation is Devii-only under `services/devii/interaction/`.

### Layers

| Piece | Role |
|-------|------|
| `interaction/capabilities.py` | Maps session channel (`main`/`docs` -> `site-chat`, `telegram`, `cli`) to capability flags + limits; builds the compact `CHANNEL` / `CAPS` / `LIMITS` / `TOOLS` / `INTERACTION` fragment injected every turn. |
| `interaction/schema.py` | Pydantic validation for `ui_prompt` args (widget catalog: confirm, choice, choice_multi, text, number, date, select, `//`, group). Untrusted model input is sanitized and capped. |
| `interaction/broker.py` | Validates, assigns `interaction_id`, single-flight open interactions, routes to site / telegram / plain adapters, returns structured `{status, values, meta}`. |
| `interaction/controller.py` | Dispatches `ui_prompt` / `ui_cancel` / `ui_notify` (`handler="interaction"`). |
| `interaction/actions.py` | Catalog entries registered in `registry.py` as `INTERACTION_ACTIONS`. |
| `interaction/markdown.py` + `parse.py` | Dual-surface markdown projection and plain/CLI reply parsers. |

### Tool gating and preferences (admin default + user override)

`DeviiSession._builtin_tools()` filters UI tools through `channel_context().tools`. Docs channel stays search-only (no UI tools). Open interaction (single-flight) exposes only `ui_cancel` plus preference tools.

**Admin default:** Devii service ConfigField `devii_interactions_default` (bool, default on) on `/admin/services`. Guests always use this default.

**User override:** column `users.interactions_enabled` (`-1` = inherit default, `0` = off, `1` = on). New signups insert `-1`. Resolution: `interaction/prefs.py` `effective_for(owner_kind, owner_id)`.

**Surfaces (same fan-out as AI correction):**
- Devii tools `interactions_get` / `interactions_set` (`requires_auth=True`; set accepts `enabled` or `reset=true` to inherit again). Always offered to signed-in users even when widgets are off, so they can re-enable.
- HTTP `POST /profile/{username}/interactions` (owner-or-admin, form `InteractionsForm`), profile card + `InteractionsPref.js`, `docs_api` endpoint, audit `profile.interactions`, profile JSON keys on `ProfileOut`.
- When effective is off, `ui_prompt` / `ui_notify` are absent from the tool list and the controller refuses them; the model must use degraded markdown menus.

### Site-chat path

`ui_prompt` blocks like client tools: session `_interaction_wait` emits `{type:"interaction", id, args}` on the WebSocket; `devii-terminal.js` mounts an `<ai-interaction>` tree via `createElement` (never model HTML), lazy-loads widget modules through `AiAutoload` (`static/js/autoload/AiAutoload.js`, allowlisted tag -> module map only), and replies with `{type:"interaction_result"}`. Router resolves via `session.resolve_interaction`. Custom elements live under `static/js/components/Ai*.js` with per-component CSS under `static/css/components/ai-*.css`. Light DOM only; `Application` boots the shell (`AiInteraction`, `AiStatus`, `AiActions`, `AiHelp`, `AiOption`) and starts the autoloader.

### Telegram path

`TelegramConnection` handles `type:"interaction"`: sends dual-surface plain text plus inline keyboard for confirm / single choice (`callback_data` short tokens). `TelegramBridge` registers a pending future **before** the chat lock so the next message or `callback_query` can resolve mid-turn. Worker `allowed_updates` includes `callback_query`; service routes `type:"callback"` to the bridge.

### Plain / CLI path

Broker uses `present_plain` or emits a plain menu and waits; `parse_plain_reply` accepts y/n, indices, value tokens, cancel.

### System prompt

Every non-docs turn appends `CA_IWP_SYSTEM_FRAGMENT` plus the live channel fragment from `_compose_system_prompt()`. Model rules: prefer `ui_prompt` when gated on; never invent HTML/CE tags; branch on `status` (submitted / cancelled / timeout / superseded / error).

### Tests

Unit coverage under `tests/unit/services/devii/interaction/` (capabilities, schema, markdown, parse, broker, controller) plus session tool-gating assertions and telegram callback emission. Mirror this layout for any extension.

## Related Devii tool wrappers (customization, container)

Two more Devii-side controllers live under `services/devii/` but their full mechanism is documented in their owning subsystem's file, not here:

- **Per-user customization tools** (`services/devii/customization/`, `handler="customization"`): `customize_list`/`get`/`set_css`/`set_js`/`reset` (all `requires_auth=False`) plus `customize_set_enabled` (`requires_auth=True`, the suppression toggles). `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__`; set/reset are in `CONFIRM_REQUIRED` so `confirmation_error` forces Devii to ask **page-type vs global** before `confirm=true`. Devii previews live with `run_js` (ids `devii-preview-css`/`devii-preview-js`) and `reload_page` after saving. For the storage model, injection pipeline, and per-user suppression flags, see `devplacepy/customization.py` and its own documentation.
- **Container tools** (`services/devii/container/ContainerController`, `handler="container"`): wraps `services/containers/api.py`, the same operations shared by `routers/containers.py`. For the reconciler, backend ABC, ingress proxy, and workspace sync mechanism, see `devplacepy/services/containers/CLAUDE.md`.
