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.pysetsself._system_prompt = DOCS_SYSTEM_PROMPT(instead of_system_prompt_for) - it forces the agent to callsearch_docsfirst for every question, to recursively refine-and-search (reading the returned sectioncontent, 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 ownerBEHAVIOR_HEADERis NOT appended, so a user's general Devii behavior rules can't derail it). - Tools.
_builtin_tools()filtersCATALOG.tool_schemas_for(...)to theDOCS_TOOLSallowlist ({"search_docs"});_refresh_tools()for docs setsself.tools[:] = _builtin_tools()with NO virtual tools. So the docs agent has exactly one tool and cannot wander. - Greeting.
bootstrap_greeting()returnsDOCS_GREETINGfor docs. - search_docs.
services/devii/docs/controller.pydelegates to the in-process page indexdocs_search.search_pages(...)(no HTTP), returning per resulttitle,url(/docs/{slug}.html),score(BM25), andcontent(page text truncated toCONTENT_CHARS), admin-filtered by the dispatcher'sis_admin. So "visiting the search results" is repeatedsearch_docscalls; the default 40max_tool_iterationsleaves ample room to recurse. - References and inline linking. Because results carry real
urls andscores, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to theirurland (b) end with a## Referenceslist 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_turnruns_docs_topic_gate(text)before the main loop (docs channel only). When there is prior history it calls_classify_topic(a no-toolsLLMClient.complete_textwithTOPIC_CLASSIFIER_PROMPT, parsed by_parse_topic, defaulting tofollow_upon any failure). Onnewit resetsagent._messagesto[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. Onfollow_upit 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 themainchannel: there is one global scheduler andDeviiService._resolve_task_owneralways resolves the owner's session withchannel="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 shareddevii_taskstable, and the search-only Docii agent tried to fulfil arun_jstask by spammingsearch_docs- tasks are amain-only feature.) - The
mainchannel 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 realrowcount. The scheduler calls it AFTERclaimand BEFORE dispatch; a refusal releases the claim viadefer.insert_task_within_quota(...)-> the same shape for the task row itself, so two concurrentcreate_taskcalls (main + telegram channel, or two workers) can never both slip past a check.TaskStore.createpre-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):
- Tool visibility and dispatch.
create_task/update_task/run_task_nowarerequires_auth=True, so guests are never offered them.list_tasks/get_task/delete_taskstay open. TaskStore.create/TaskStore.update. RaisesAutomationDenied(withretry_atwhen 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 standalonedeviiCLI - whose store passesoperator=True, the one deliberate exemption for the local operator - and anything added later.Scheduler/GlobalSchedulerclaim.retire_reasonruns 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.
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.pyself.tools = CATALOG.tool_schemas_for(...), handed by reference toAgent,AgenticController.bind, and read live byreact_loopeachllm.complete).DeviiSession._refresh_tools()runs at the top of every_run_turn(insideself._lock, beforeagent.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 reassignself.tools, always mutate in place. - Self-eval engine (
agentic/controller.py)._delegatewas refactored onto_spawn(prompt, tools, system_prompt), which runsreact_loopwith a freshmessages/AgentStateunder a depth guard (agentic/state.pyget/set/reset_eval_depth,MAX_EVAL_DEPTH=2) so delegate/eval/virtual tools cannot self-call infinitely.run_subagent(prompt)(tools minusdelegate) is the public engine; theevalagentic tool wraps it;_delegatekeeps its richer JSON report. - Store (
services/devii/virtual_tools/store.py).VirtualToolStore(db, owner_kind, owner_id)overdevii_virtual_tools(uid, owner_kind, owner_id, name, description, prompt, input_description, enabled, created_at, updated_at; indexesidx_devii_vtools_owner/idx_devii_vtools_name).tool_schemas()builds, for each enabled row, an LLM schema with a single free-forminputstring. Persistent for users,memory_db()for guests (built inhub.get_or_createfrom the sharedowned_db). - Controller (
virtual_tools/controller.py).VirtualToolController(store, evaluator, builtin_names), evaluator =agentic.run_subagent,builtin_names = set(CATALOG.by_name()). CRUDtool_create/list/get/update/delete(handler="virtual_tool");tool_createvalidates name^[a-zA-Z][a-zA-Z0-9_]{1,40}$, rejects built-in collisions and duplicates, requiresdescription+prompt.has(name)/run(name, args)composef"{prompt}\n\nUser input: {input}"and call the evaluator. - Dispatch (
actions/dispatcher.py). Constructed withvirtual_tools=...;_runrouteshandler="virtual_tool"to CRUD; the unknown-name branch (if action is None) resolves a virtual tool viaself._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_actionsstays built-in only. - Registered via
VIRTUAL_TOOL_ACTIONS(registry.py); theevaltool viaAGENTIC_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)overdevii_behavior(one upserted row per owner keyed onowner_kind/owner_id;text()reads,set()upserts; indexidx_devii_behavior_owner). Persistent for users,memory_db()for guests (built inhub.get_or_createfrom the sharedowned_db, like the other owner stores). - Controller (
behavior/controller.py).BehaviorController(store),dispatch("update_behavior", args)->store.set(behavior). Built inDeviiSessionand passed toDispatcher(behavior=...); the dispatcher routeshandler="behavior"to it and degrades gracefully ("not available in this context") when unwired (e.g. the standalone CLI, same asvirtual_tools/avatar). - Injection and refresh (
session.py)._compose_system_prompt()= base prompt (_system_prompt_for(is_admin)) + CA-IWP fragment + liveCHANNELblock +\n\n+BEHAVIOR_HEADER(+\n+ body when non-empty; just the header when empty). Used to seed theAgentand the scheduler executor worker, and_refresh_system_prompt()rewritesagent._messages[0]["content"]at the top of every_run_turn(next to_refresh_tools), so a mid-conversationupdate_behaviortakes 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 inagent.pysteers 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 acceptsenabledorreset=trueto 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, formInteractionsForm), profile card +InteractionsPref.js,docs_apiendpoint, auditprofile.interactions, profile JSON keys onProfileOut. - When effective is off,
ui_prompt/ui_notifyare 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(allrequires_auth=False) pluscustomize_set_enabled(requires_auth=True, the suppression toggles).CustomizationController(owner_kind, owner_id)is built inDispatcher.__init__; set/reset are inCONFIRM_REQUIREDsoconfirmation_errorforces Devii to ask page-type vs global beforeconfirm=true. Devii previews live withrun_js(idsdevii-preview-css/devii-preview-js) andreload_pageafter saving. For the storage model, injection pipeline, and per-user suppression flags, seedevplacepy/customization.pyand its own documentation. - Container tools (
services/devii/container/ContainerController,handler="container"): wrapsservices/containers/api.py, the same operations shared byrouters/containers.py. For the reconciler, backend ABC, ingress proxy, and workspace sync mechanism, seedevplacepy/services/containers/CLAUDE.md.