Navigation
ELOWEN · Documentation

Last updated: October 20, 2026

Browse documentation · Account defaults and the Account page
Developer reference

Account defaults and the Account page

Every defaultable personal setting resolves through one chain: the account's own stored value, else its account preset under sentinel -presetId, else the instance's account default under sentinel 0, else the code default. Presets exclude the instance conversation-model role and personal plugin settings. The daemon owns that chain and the routes that read and write it; this page is the browser half.

Directory month spend

GET /users carries monthSpentMicrousd: number | null on each UserDirectoryRow, including chatbot accounts. AccountSpendGate.directory() reads the directory's saved baselines and one grouped usage.providers.spentByUser(fromDay, toDayExclusive) aggregate in a repeatable-read snapshot. The UTC month bounds and accountMonthSpentMicrousd calculation are shared with headroom(), the source of GET /users/:id/spend: round USD to micro-USD once, subtract only this month's administrator reset baseline, and clamp to zero. The read is two queries, not one spend request per account; baselines never leave the server.

An account whose usage accounting is latched unhealthy (a failed usage write, until restart) reports null rather than a misleading zero; a failed database read fails the directory request as before. UsersView renders the existing spendLimit.spent label below web activity (below creation for chatbots), using the drawer's two-decimal money format. Null renders a dash with the existing read-error tooltip. Resetting monthly spend invalidates both the drawer reading and the directory query.

Administration project lists

The Users register and account-preset editor call useProjects('assignable'), backed by GET /projects?view=assignable. Core filters this list through the membership repository's canManage; a shared computer owned by another account therefore remains available for execution without appearing in administration pickers. GET /users/:id/projects returns only assignments the caller may manage, so the drawer's Project count and rows use the same bounded set. Query keys separate this administration projection from the ordinary Project register and invalidate together after assignments change.

The defaults window

web/modules/users/AccountDefaultsWindow.tsx is what the Users page opens from the toolbar's Options panel (UsersView passes filterActions to ControlSurfaceToolbar, so the panel's trigger and sheet are named "Options"). It is drawn in the shared PageOverlay reading frame, the same window Settings and Account are read in (icon tile and title only), around a SectionDeck whose content is a SettingsPage hero — eyebrow, the open section's own name and the intro as the line under it — over that section's panel. Its column is the shared DeckNavigation with the same searchable field and group caption Account's has: the search filters the sections by name (diacritics-insensitive; there is no record index for defaults) and says users.defaults.navNoMatches when none match; the phone strip has no search, as everywhere. Eight sections: Models, Memory, Skills, Style and instructions, Language, Rules and YOLO, Plugins and the new-account preset. The window is opened from a button rather than addressed, so it passes onClose (Escape or the header close control, which is blocked while the autosave is writing) instead of the history step; there is no separate Done button. The default language is the shared ChoiceField dropdown. The "Apply to everyone" confirmation and the selection pickers open above it.

The window reads GET /users/defaults once and keeps its own draft of settings and newAccountPresetId; its autosave diffs that draft against the view it was seeded from and PATCHes only the fields that moved, with the revision it was read at. A draft that moved on while a write was in flight is the reader's and is never overwritten by the answer, and the value the daemon ANSWERS for a field that was written is adopted, so a canonicalized value cannot read as a further difference and bounce back. A stale revision is a 409: the window keeps the draft and offers a reload, which is the one answer that cannot lose a newer write.

The new-account control is a ChoiceField selecting no preset or one named preset. The configured newAccountPresetId seeds human accounts created through Users and Microsoft SSO; an explicit no-preset choice in the creation form starts the account closed. The first administrator keeps its bootstrap full grant and chatbot accounts keep their fixed grant.

Every record states how many human accounts hold their own value for its field, and carries the action that clears them (POST /users/defaults/apply). That action's ConfirmDialog is rendered BESIDE the dialog rather than inside it, because the deck under it is the scroller and a question must not be reachable only by scrolling back to the control that asked it.

web/modules/users/AccountDefaultsPlugins.tsx renders each plugin's default per-account form with the same PluginConfigEditor and usePluginConfigDraft the account page uses, bound to the defaults route through the draft's save option; secret fields have no default and are absent from the schema the daemon sends there. The save adapter maps the route's values to the shared draft's canonical config; a stale plugin revision includes current: { config, secretsSet: [], revision }, so Reload and Merge work through the same conflict handling.

Account presets

Users opens the account-presets window from Options beside the defaults action. The window uses PageOverlay, SectionDeck and searchable DeckNavigation for the preset list and the new-preset entry. Each preset edits its name, grants, behavior and minimum Project memberships. Grant editors share the account detail's presentation controls in GrantSelections, ToolSelection, VoicePermissionRows, ProjectPermissionRows and ProjectSelection. Their containers own mutations; presentation takes a value and onChange. Behavior uses AccountBehaviorPanel for the defaults rows and their apply-to-everyone confirmation, scoped to attached human accounts. Its preset target exposes stored-field inheritance actions and hides the conversation role through ModelRoleRows.hideChatModel. SpendLimitSlider reports a committed microusd value or null; every stop is a storable limit, so the preset window has no invalid spend state, and the account container commits the value through its existing mutation.

The browser uses web/lib/accountPresets.ts for the admin-only /users/presets DTO. A preset has one optimistic revision and reports its attached-account count, effective settings, stored fields and attached accounts' own-value counts. A null behavior value clears that preset field to inheritance. The conversation-model role and personal plugin settings remain in the instance defaults window.

The account detail drawer (web/modules/users/UserDrawer.tsx) and human-account creation select a preset with ChoiceField. Before the first manual grant edit, the drawer's GrantChangeGate (keyed by the preset id) confirms detachment from the named preset; the server detaches atomically with that edit and reports presetDetached. Personal /auth/me/* saves preserve attachment. The account row and the drawer's header meta line show quiet role and preset metadata, with the same text treatment for chatbot accounts. Deleting a preset confirms its account count, detaches those accounts and preserves their grant columns, own settings and memberships. They subsequently inherit instance behavior for fields without a personal value.

Shared editors

The rows, pickers and cards the window edits are the account page's own, taken from web/modules/account/editors/: ModelRoleRows, MemoryAutomationRows, MemoryMaintenanceRows, PermissionRulesEditor with PermissionRulesPreview and StyleAndInstructionsRows. Each takes a value and hands back a change; the containers (CliSection, AccountMemorySection, MemoryDreamingCard, PersonalitySection, PermissionRulesCard) keep the reads, the seeds and the writes. A second surface editing the same records therefore cannot drift on what a switch is called or which models a role may pick.

The Account page sections

web/modules/account/sections.ts is the one list of what the Account page is made of, addressed as /account?cat=<id> (accountSectionHref). CORE_ACCOUNT_SECTIONS holds the core ids, and accountSections draws core and plugin sections in ONE sort by a numeric order: profile 100, plugin panels without a manifest order 200, Sandbox's Desktops 250, elowenCli 260 (labelled Computers; not cli, which is Models), plugin configuration forms 300, then cli 400, voice 500, memory 600, personality 700, notifications 800, security 900, apiTokens 1000 and terminal 1100. Equal orders put core first, then plugin name, then section id, compared by code point, so the menu is identical on every load. A plugin panel's order is its manifest web.account[].order (integer), carried through GET /plugins/ui unchanged. The caller passes plugin contributions in rather than the module resolving them: pluginAccountSectionEntries reads the plugin listing, and userPluginConfigSectionEntries adds the per-account configuration forms of plugins that do not place them on their own page. A plugin section with placement: 'linkedAccount' opens in the profile's Linked accounts drawer instead of claiming a section. isAccountSection validates a stored or linked id by shape only; whether a plugin's section still exists is checked against the listing separately.

Account > Security and two-step verification

The existing AccountPanel id="security" owns the password form followed by AccountTotpSection and LoginSessionsSection; no new route, navigation entry or account section exists. Unlike ordinary drafts retained by React Activity, the TOTP subtree unmounts on every section exit. The one-time enrollment response lives only in component state, never a TanStack mutation/cache, browser storage, URL or telemetry. Cancel, confirmation, expiry and unmount discard the seed; pending metadata after a remount offers cancellation, never readback.

QR rendering uses the already installed qrcode@1.5.4 browser entry, declared explicitly by the web package. web/modules/account/qrcode.d.ts describes only the consumed Promise overload toDataURL(text, { errorCorrectionLevel: 'M', width, margin }), returning Promise<string>. The package ships no declarations; this narrow surface was checked against its upstream README and installed lib/browser.js/lib/renderer/canvas.js, without a new type package or unchecked casts. The component renders a local PNG data URL with a four-module quiet zone and the library's default opaque black/white colors independent of theme. Failure leaves the manual setup key available; there is no external QR service, HTML injection or persisted image.

The browser consumes the shared AccountSecurityStatus, TotpEnrollment and LoginSessionsPage DTOs through the existing client/query owner. Eligibility is the server's password evidence, not a password-hash or external-identity guess. Enrollment asks for the current password and confirms an unused six-digit string; disabling requires an unused code and ends the current session as well as other browser sessions. The server owns revisions, replay prevention, absolute expiry and all authorization. API tokens and paired computers are separate authorities.

LoginSessionsSection uses cursor pages and CardGrid layout="list"; a row is named by the session's own device — Browser · OS, or the parser's mobile model — projected once by packages/db/src/users/sessionDevice.ts from the User-Agent kept at sign-in, with a localized Unknown device for rows that predate it. The row also shows reliable start/expiry dates and the authentication method, and the current session carries its marker as the row's status. The name is self-reported identification, never an authorization signal, and the raw header never reaches the browser. Revoke uses ConfirmDialog and explains the account-wide browser reconnect. Session trust uses the existing security.tokenTtlDays lifetime, not a device cookie or another setting.

LoginForm keeps the same card at the requested address, including CLI approval queries. totp_required never invokes onAuthed; only final authentication opens the gate. The code is text with numeric input mode, one-time-code autocomplete, leading-zero support and outer-only clipboard trimming. Back cancels the pending BFF-owned challenge and clears the password/code. Wrong-code errors remain local preauthentication errors, not session expiry.

The existing Users Account tab reads totpEnabled/securityRevision from its human directory row. Reset targets another human account only, requires explicit confirmation and a closed password-plus-optional-actor-TOTP or recent-Microsoft proof. Actor factor state comes from accountSecurity, not the target or a stored hash. Success refreshes the directory; stale state and uncertain writes never show a success toast. The daemon remains the final authority for current admin role, actor/target separation and impersonation restrictions. No recovery-code, CLI-recovery or hidden-reset path exists.

A security write can commit before live transport cleanup fails. The daemon then returns 503 { error: 'auth_security_cleanup_failed', committed: true }. The three UI consumers distinguish that exact typed marker from an ordinary refusal: they warn that the change was saved but closing sessions could not be verified, discard provisioning/actor proof state and never repeat the OTP write automatically. Enrollment confirmation and disable end the local session through clearToken; session revocation does so only when the selected session was current, otherwise it refreshes the session list. An administrator resetting another account remains signed in, closes the proof dialog and invalidates the directory and actor security status. This outcome is neither a rollback nor a fully verified success.

Account > Computers

ElowenCliSection (web/modules/account/ElowenCliSection.tsx) is the browser half of the core CLI module, built from the same parts as API tokens (SettingsGroup, SettingsRow, the shared states, ConfirmDialog, toasts). It holds three groups, top to bottom — the approval card, the install row and the account's own computers, which are Projects (executionKind 'local') rather than a second list of devices:

  • The approval card, only while the address carries code= (the verificationUriComplete that elowen login prints, /account?cat=elowenCli&code=<userCode>). useCliDeviceCode reads GET /cli/device; Approve and Deny post through useDecideCliDeviceCode. The request carries its kind, and the card follows it: an account sign-in for the daemon on this host is titled Approve server CLI sign-in, says the terminal gets a personal API token and that no computer is paired, labels its first row Terminal and toasts the sign-in as approved; a computer pairing keeps Approve sign-in, the Device row and the device-approved toast. A 410 (unknown or expired) and a 409 (already decided), from the lookup or the decision, are outcomes with a Close action, not errors. After a decision, and on Close, code is removed with announceLocation, so the section stays and a reload asks nothing again. The section reads code from window.location on arrival and on popstate, like the deck reads cat: announceLocation keeps Next's history state, so useSearchParams does not see the change. A signed-out visitor gets the login form at the same address and lands on the card after signing in.
  • Install: one SettingsRow in a compact SettingsGroup, like the API tokens "call" row: the label and a one-line description, and a single Copy install command button (variant="default" size="sm"). Nothing is minted on render and the command is never shown. A click runs useCreateCliEnrollment (POST /cli/enrollments, at most one live ticket per account) with the button disabled until it settles, then writes the command with copyText (web/lib/clipboard.ts) and toasts Copied. useCliEnrollment reads the server-owned lock through GET /cli/enrollments on mount and every 10 seconds. It samples the server's remainingMs against performance.now(), avoiding browser clock skew, and restores the disabled tabular formatClock countdown after F5. Changing the installer keeps this ticket's lock. At expiry or after a status read reports redemption, the normal label and action return. Tickets and locks disappear on daemon restart. The accessible name gives the local availability time without announcing every second. A failed mint toasts installError; a concurrent mint's typed 409 refreshes the lock. A refused clipboard write cancels that exact mint through DELETE /cli/enrollments/:id before showing common.copyFailed, so another attempt is available. Failed status reads show the shared retryable error and disable minting. The write follows an awaited fetch: Chromium and Firefox accept it, Safari may refuse it and then shows the copy-failed toast. The installer follows the browser's system, read once after mount by browserOs (Chromium's userAgentData.platform, else the user agent string with phones, tablets, ChromeOS and iPadOS's Mac claim ruled out): Windows mints powershell and its description names PowerShell, macOS and Linux mint sh and name Terminal or a terminal, and no choice is shown. Only an unknown system (a phone) gets the choice, a Segmented of two icon-only options (iconOnly): Windows, and macOS with Linux, which share the sh installer. Their names (installerWindows, installerUnix) are the radios' accessible names and tooltips, sh is preselected, and the description follows the pick. The marks are web/public/os/{windows,apple,linux}.svg, single-path monochrome SVGs drawn through AdaptiveBrandMark monochrome, so the CSS mask takes the skin's text colour; each file names its source (Microsoft's VS Code Windows mark, apple.com's navigation mark, and Simple Icons' CC0 Tux, which cites the Linux Foundation's Linux mark page).
  • Computers: the list is the Project register's own read. useProjects() (GET /projects, re-read every 10 s while the list holds a computer, which is also how the live load stays current) is narrowed to the computers this account PAIRED — executionKind === 'local' with a computer and creatorUserId equal to the reader's account id. A computer somebody shared with the account is worked in from the register, where its members manage it; it is not listed here. ONE My computers card holds them: while the list loads, fails or is empty it carries the loading, retryable error or empty state; otherwise one folding item per computer, the shared ComputerUsageItem (web/modules/computers/ComputerUsage.tsx), separated by hairlines and folded by default. That item is the SAME component the projects/ register's computer card and drawer draw their facts from, so the two surfaces cannot disagree about a state, a reading or a wording. The items are the host's collapsible SettingsGroup in its unframed form, the one Skills, MCP and Agents fold their groups with (collapsible with the item's own open state, no storageKey), so the chevron, fold button and keyboard behaviour are the shared ones. The section keeps ONE range for every item, remembered under elowen.account.cliDevices.range (default 7 days, capped at API_TOKEN_STATS_MAX_DAYS) and read as UTC days through utcDayWindow. It is shown only inside an unfolded item, as a DateRangeFilter on the chart's legend row (TimeSeriesChart's host-only legendEnd), and a change in one item applies to all; nothing sits in the card header while everything is folded. While an item's statistics load or fail there is no legend, so the filter stands alone, right-aligned, above that state. API tokens is built the same way (elowen.account.apiTokens.range; its header keeps only New token), from the same parts and no new chart or wrapper component:
    • Folded row: one line beside the name in the heading, at text-meta muted with 12 px lucide icons: the 6 px status dot and Connected or Offline (DetailMeta), and last online (clock; only while offline, lastSeenAt or Not connected yet, since Connected already says now). Each fact keeps its label as screen-reader text; on a phone they wrap under the name. Beneath sits the muted line <OS> <arch> · client <version> (each part omitted while unknown), and on the right Disconnect (outline-danger). Between them, centred, the live load uses ComputerLoadRings and the shared compact ResourceMeters track for CPU, memory and disk only, showing their shares and compact used capacity. IO and network stay in the expanded detail. Every reading opens the shared history window. It shows only while the item is folded (unfolded, the big rings close it), only with a load reading, and only from the header's @2xl container width, so a phone shows none. Disconnect opens the section's ConfirmDialog in the computer's own words and deletes its Project through useRemoveProject (DELETE /projects/:id); the daemon revokes the device token in the same act, and the register is refetched. The projects/ drawer asks the register for the same removal instead of owning a second confirmation.
    • Unfolded: ComputerUsageChart over GET /cli/devices/:targetId/stats for the section's range, enabled only while the item is open, so a folded computer costs no request. TimeSeriesChart draws turns and commands as bars and failures and cost as lines, cost on the right axis (--color-chart-1..4), one zero-filled point per day of the window; an unpriced day stays unknown rather than zero. One totals line follows: turns, commands, files edited, tokens and cost, with an unknown cost said as unknown.
    • Load now: the computer's latest reading (computer.load) as five ResourceMeters rings for CPU (detail: cores), memory and disk (detail: used / total), IO and network (absolute speeds), centred at the foot of the unfolded item in the host-only size="large" (76 px rings) under a hairline and a centred caption (ComputerLoadRings). Without a reading (offline, or connected before its first sample) the rings keep their unknown state and a muted line says why; a CPU share not measured yet keeps the core count.

The register's card and drawer are documented in "Computer pieces" in docs/modules/web/components.md.

The Playwright fake daemon serves these routes from web/tests/e2e/fake-daemon/handlers/cliDevices.ts: one computer is one Project, so POST /__test/cli-devices pairs BOTH — it appends the Project to the projects snapshot AND records the device its statistics are keyed on (each seed optionally with connected, platform, arch, version, lastSeenAt, a compact load ({ cpu, cores, mem, disk }) and usage days given as daysAgo offsets; a device without days answers an empty window). DELETE /projects/:id revokes the device, as the daemon does. Faults are armed through the override keys: projects/read-state ('slow', 'error') holds or fails the list, and cli/devices/stats ({ delay }, { status }) does the same for one computer's chart. web/tests/e2e/specs/account.elowenCli.e2e.ts covers the flows.

What the account page says about a field

The Account page does not mark which fields are inherited and has no "revert to default" action: a self route answers with the resolved values and the revision only. An account's own value is cleared by an administrator through Apply to everyone, never from the Account page.

Every account save diffs against the last canonical answer for the same reason the window does. A client that sends its whole form on any edit stores inherited values as the account's own and silently ends their inheritance.

DTOs and hooks

web/lib/accountDefaults.ts states the field list, the wire shapes of /users/defaults, /users/defaults/apply and /users/defaults/plugins. The self routes use the plain CliSettings, PermissionSettings and MemoryDreamingSettingsPatch types. The authoritative field vocabulary and stored-key mapping live in packages/db/src/config/accountDefaults.ts; the config repositories resolve the inheritance chain. The browser mirrors the field list in web/lib/accountDefaults.ts without importing the persistence implementation. useUsersDefaults and useUsersDefaultsPlugins read the two listings, useSaveUsersDefaults and useApplyUsersDefaults write them, and useSaveMyCliSettings, useSaveMyPermissions and useSaveMyMemoryDreaming take those plain patches.