<div class="docs-content" data-render>
# Architecture and sessions
How Devii is wired at runtime: the hub, per-owner sessions, the websocket, persistence, and the
multi-worker model. See also [Devii internals](/docs/devii-internals.html).
## Owner model
Every session is keyed by an **owner** tuple `(owner_kind, owner_id)`:
- `user` / user uid - a signed-in DevPlace account. The agent authenticates to this instance with
that user's API key, so it operates the user's own account.
- `guest` / `devii_guest` cookie - an anonymous sandbox session.
Owner scoping is the spine of Devii: conversations, tasks, lessons, the usage ledger, and the audit
log are all filtered by owner, so nothing is shared between accounts.
## DeviiHub
`DeviiHub` holds the process-wide state: one `DeviiSession` per owner (`get_or_create`), the shared
`LLMClient`, the `ConversationStore`/`UsageLedger`/`TurnAudit`, and housekeeping (`gc_idle`, ledger
prune). For a signed-in user it builds a `PlatformClient` with the user's API key and lazily
**rehydrates** the saved conversation; for a guest it builds an unauthenticated client with no
persistence.
## DeviiSession
A session owns the agent, dispatcher, scheduler, and a **set** of websockets. Key behaviors:
- **Multi-tab broadcast.** Every frame (trace, reply, task, status) is sent to all of an owner's
connected sockets, so the same content shows on every tab, window, and device.
- **History snapshot.** On connect, the session sends a `history` frame so a new tab renders the
prior conversation; live frames then stream.
- **Turn serialization.** A per-session lock serializes turns; a new tab joining mid-turn still
receives live frames.
- **Reload-resilient browser calls.** Avatar and client tool requests round-trip to the browser;
if the page reloads mid-request, the call waits for reconnection and re-sends, so a turn survives
a refresh.
## Persistence and rehydration
After each turn the session writes the (compacted) message list to `devii_conversations` (users
only). On reconnect (or after a restart) the saved messages are loaded back into the agent, so a
signed-in conversation survives reloads and reboots. Guests are never persisted. See
[Data and persistence](/docs/devii-data.html).
## Multi-worker model
`make prod` runs multiple uvicorn workers, but only one holds the background-service lock. Because
hubs are per-process, `/devii/ws` is served **only** by the lock-owning worker
(`service_manager.owns_lock()`); other workers close the socket with code 1013 and the
auto-reconnecting client lands on the owner. This guarantees a single hub, one broadcast source, and
one cost tracker per user. The 24h spend cap is always read from the ledger DB, so it stays correct
regardless. See [Multi-worker and concurrency](/docs/production-concurrency.html) for the shared
service-lock model this builds on.
## CLI parity
The `devii` console script reuses the same agent, tools, and stores. When it runs against the same
database, it resolves the logged-in account from its API key and uses that owner, so the CLI shares
the same per-account lesson memory and tasks as the web session. See
[Configuration and CLI](/docs/devii-config.html).
## Shared browser session
The web terminal and the browser are one session. The websocket resolves its owner from the
browser's `session` cookie at connect, so the terminal is always whoever the browser is logged in
as. When the agent logs in via the terminal, it mints a real platform session and hands the token to
the browser through a single-use `GET /devii/adopt` redirect (the `session` cookie is httpOnly, so
only an HTTP endpoint can set it); when it logs out, the browser is sent to `/auth/logout`. Both are
full navigations, so the page refreshes and the terminal reconnects as the new identity. In the
other direction, a login or logout in the browser (including another tab) is detected by a focus
check against `/devii/session` and triggers a reload, keeping both sides in lock-step.
## Stopping a turn: stop and reset
Each user message runs as its own cancellable asyncio task (`spawn_turn`), tracked in `_turns`. The
websocket receive loop keeps reading while a turn runs, so an interrupt is honoured immediately even
mid-task. Typing `stop` or `reset` (or sending a `stop` / `reset` frame) is intercepted server-side
in the websocket loop before anything else, so the interrupt is authoritative and works regardless of
the client.
- `stop` cancels the running turn and keeps the conversation.
- `reset` cancels the running turn and clears the conversation.
Cancellation is bulletproof by construction. Tool calls run as awaited coroutines (the react loop
gathers them; sub-agents are awaited inline), so cancelling the turn task propagates `CancelledError`
through every await with no orphaned work. On top of that, `cancel_turns` first increments a
`_turn_epoch`; a turn only emits its reply or records its turn while its captured epoch still matches
the session's, so a turn that survives cancellation for a moment, or finishes the instant after an
interrupt, can never deliver output or persist anything afterward. A cancelled turn never runs
`_record_turn`, so it cannot save a half-finished message list. `stop` then repairs the kept history
(`_repair_history` trims any trailing assistant tool-call block whose tool responses are incomplete)
so the next turn always starts from a valid conversation.
</div>