NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Routes and backend proxy
Developer reference

Routes and backend proxy

Routes and application structure

The web plugin capability DTO and PluginPermissionsPanel use erased imports of PluginCapabilities from src/plugins/capabilities.ts, so the browser does not load the daemon schema. The panel's mutation-tone map is exhaustive over the canonical union: turnContext uses warning and all other enforced mutations, including users, use danger. A detail response with capabilities: { mutates: ['users'] } renders a danger badge in the Capabilities tab. An absent declaration hides capability rows. This read-only presentation of declared authority adds no request or enforcement and is consumed by Settings' PluginDetail.

GET /projects/summary consumes ProjectService.authorizedRows so its rows and order match the authorized project listing without repeating filesystem projection. A plugin registry load that never settles expires after its existing five-second budget; the register still receives its authorized cards without unavailable indicators or uncached managed branches. Existing member visibility and branch caching are unchanged.

accountProjectRefusal(error) validates structured account/project refusals at the browser boundary. It first recognizes the code through accountProjectRefusalSchema.shape.code.safeParse, then strictly parses the full body. Unknown codes and non-API errors return undefined; a malformed recognized refusal throws its validation error. ProjectsView consumes last_active_project to list affected accounts in the removal confirmation. The helper adds no network request and does not change server refusal codes.

UserDetailPane.ProjectChips supplies disabled: project.lifecycle === 'deleting' and a localized disabledHint through ManageSelectionItem. Deleting projects remain visible, and existing assignments remain selected when the picker opens so their members can still observe cleanup. Active projects retain normal selection behavior. The save diff excludes projects already marked deleting, including a project whose deletion began after its local selection changed; active replacements are granted before old active memberships are revoked. This adds no request or shared picker option. Server authorization remains authoritative for deletion races after submission.

Project removal in ProjectsView uses one local openRemove(project) entry from the action menu and the edit form. Each opening clears the previous structured account refusal before selecting its target. A last_active_project refusal keeps the confirmation open and lists the affected accounts; without a refusal no account warning appears. ConfirmDialog owns pending submission and dismissal protection, while ProjectsView retains its in-flight removal guard. The host metadata removal and managed teardown routes remain unchanged.

The memory module resolves category colors through memoryMeta.categorySwatch(color), including grouped headers in MemoryRow and graph colors in brainLayout. Pass the stored category color or undefined for the uncategorized group. The helper trims the value and returns the muted foreground token for an absent or blank color. It runs synchronously when the consumer builds its display data, with no requests or persistence; category icons and labels remain the consumer's responsibility.

memoryMeta.vitalityTone(value) returns only danger below 30, warning from 30 to below 65, and success from 65 upward. MemoryRow maps those tones to background classes, while MemoryVitalityChart maps them to SVG stroke colors; both private maps are keyed by the helper's return type. Use the server-computed vitality score when rendering a memory. The helper is constant-time and adds no retention logic, history fetches, or plugin UI exports.

The route tree is under web/app/:

  • / redirects to /dash.
  • /dash is the workspace home.
  • /chat is the full-page advisor.
  • /memory is the account memory register. Importance is shown and edited on a 1 to 10 scale (default 5, web/modules/memory/memoryMeta.ts) through the memory module's RankSlider in web/modules/memory/MemoryFields.tsx, shared by the create and edit surfaces, and the vitality chart reports the eviction date, however far ahead, or nothing while retention is paused: no level is "never deleted". MemoryView owns filters, paging, selection and the detail rail; its private module components are MemoryRow (including grouped category headers and vitality cells), MemoryModals (create and merge), and CategoryModal (category create/edit, used by both the view and CategoryManager). The moves preserve row keyboard traversal and selectors, modal fields, and create-then-category write order. MemoryAuditFeed shares the existing useNow heartbeat with list rows: relative timestamps advance without new HTTP data, sleep while the tab is hidden and catch up on visibility. brainLayout uses memoryMeta.categorySwatch for category and uncategorized colors, and MemoryBrainMap reads its core color from the graph; uncategorized grouped headers retain the muted Hash icon. No plugin UI exports or persistence contracts are added.

Memory maintenance uses the existing useMemoryMaintenance query. Its durable state is also available to shell consumers. The server owns durable progress, UI initiation, terminal counts and acknowledgement. The runs projection includes only the caller's hand-started work and results within a 24-hour window, bounded to twenty receipts; the latest reindex and recategorize slots remain available to the maintenance view. The query polls once per second only while either operation runs. After daemon recovery an unfinished run displays "Interrupted by a restart" with its recorded success and failure counts. Navigate to /memory?maintenance=1 to open the existing maintenance modal. Its "Dismiss result" action uses useAcknowledgeMemoryMaintenance and the shared JSON client, addressing the receipt by id so it cannot acknowledge a newer run accidentally. Acknowledgement changes feedback visibility only. No configured embedding or categorization model means the matching start controls stay disabled.

  • /projects manages registered Projects and their plugin panels. Its GET /projects/summary read uses the core projectSummary projection only after account authorization and ordering. Project cards retain admin-only member totals/samples, granted plugin indicators and budgeted host/managed branch reads; unavailable indicators or branches do not remove the register. Empty authorized rows produce no cards.
  • /settings is the administrator settings surface.
  • /users is the administrator account and access surface.
  • /account is the signed-in account's settings surface.
  • /p/<plugin>/<...rest> hosts an enabled plugin page or plugin settings section. The rest segment is optional (app/p/[plugin]/[[...rest]]/page.tsx).
  • Editor and statistics pages use the enabled plugins' /p/editor and /p/stats routes. The old /editor and /stats bookmark redirects have been removed.

Settings and Account use the parallel @pageOverlay slot. Client navigation presents the destination over the current page, while a hard load, refresh, or external link renders the same surface for the canonical route. The canonical settings/page.tsx and account/page.tsx routes own the addresses; the overlay slot owns their presentation. The catch-all slot clears an overlay when navigation leaves the intercepted route.

SettingsView composes the core administrator deck. useSettingsSection(pluginEntries) owns remembered-section hydration, explicit URL precedence, history listeners, row-anchor consumption and legacy plugin-section forwarding. Its mount effects retain their order: localStorage is read before the actual address, and canonicalization waits one commit. It rewrites only cat, preserving unrelated query parameters and fragments; legacy plugin ids wait for the live listing, then use pluginSectionHref or retire to System. Listener cleanup follows the deck's lifetime.

settingsMetrics renders readings from the deck's existing config, system, model, plugin and Data-only log queries without fetching. Missing readings retain their existing placeholders and Voice has no metric rail. ModelCatalogSection owns provider grouping, filtering and fold presentation, while SystemSettingsSection renders instance records and lazily loaded diagnostics. Both receive the deck's existing queries and drafts; one-time seeding, autosave groups, restart confirmation and the model-limit modal stay in SettingsView, outside retained panels. PageToolbarScope and React Activity retain visited forms while only the visible panel contributes toolbar controls. Sections without controls still pass no toolbar props and keep the empty portal slot.

web/modules/settings/usePluginDetailRoute.ts owns PluginsSection's ?plugin= selection, initial hydration and popstate listener. It waits for the installed catalogue before replacing a missing, removed or disabled deep link, preserving unrelated query parameters and clearing the old section hash. Opening and closing a detail replace the same history entry; no plugin parameter keeps the list selected. The hook performs no catalogue fetch and removes its listener on teardown.

web/modules/account/apiTokenPresentation.ts owns limitValueText and sliderRange for token usage and the limits modal. Counts use the active locale, costs retain the existing micro-USD conversion, and null reads as unlimited. Stored values widen the slider bounds rather than changing the value. Usage totals use the existing interpolate helper with all six formatted fields; the byte-pinned web/lib/apiTokens.ts contract remains presentation-free.

Feature views live in web/modules/. Shared application components live in web/components/, the browser client and cross-cutting helpers live in web/lib/, and browser tests live in web/tests/. Do not import daemon runtime modules into the Next.js bundle. Import wire contracts only as types through src/shared/wireContract.ts; it has no module import declarations or runtime exports. Its sole external type query is import('@earendil-works/pi-tui').ProgramStatus, used by BrainStreamControl to select PI's public state and kind without copying the native vocabulary. Both toolchains resolve the installed PI declaration; the query erases completely, so it loads no PI runtime in the browser. Other type queries, module imports, runtime loads and re-exports remain forbidden by the syntax-based wire contract guard. web/lib/types.ts re-exports shared wire shapes such as brain cards and REST/SSE rows. Plugin configuration declarations remain owned by src/plugins/manifest.ts; the browser may import that declaration only as a type, while the manifest's one field-type list drives runtime validation. Browser implementations that cannot share Node.js runtime code must be kept in parity tests. Runtime-mirror example: formatCompactCount in src/shared/displayFormat.ts is the canonical compact count (nonfinite/negative renders '0'), used by every CLI surface; formatTokens in web/lib/format.ts delegates to the byte-identical web/lib/displayFormat.ts browser mirror (tests/contract/webMirrorContracts.test.ts enforces both contracts). Exact localized counts are formatUsageCount in web/lib/format.ts, used by the account chart and the dashboard pulse cards. web/lib/runtimeLimits.ts is the web mirror of the daemon's runtime-limit defaults and clamp bounds (src/store/configStore.ts), pinned as text by runtimeLimitsParity.test.ts. The Runtime settings modal, the Tool loading threshold slider and the app-wide Toast duration all read it there, and a new runtime knob is added on both sides.

The shell-wide BrainChatProvider retains conversation state across route changes. brainChatUsage.ts owns its usage state, ordered stream epoch, latest-started REST read fence and live status sampling. Each REST fence captures the bound session and connection generation as well as the stream epoch; a late sample cannot publish after navigation, a newer read, snapshot or compaction. A same-stream session rebind clears the previous conversation's usage. Snapshots take the last usage-bearing step or idle in daemon order; a snapshot with no usage-bearing event fences older reads and refreshes usage from the newly bound conversation. Read-only child views use their stream alone. The live /brain/status sample runs every three seconds only during a generating turn with a mounted chat surface in a visible tab. A tab returning to the foreground refreshes immediately. Sampling is paused when no chat surface or generating turn is active, when the tab is hidden, and while viewing another session. BrainChatSurface registers the visible surface, while stream step and idle events still own usage at request boundaries. The statusline is the live-rate consumer.

The hook exposes refreshUsage(session = sessionRef.current): Promise<void> for usage-only REST refreshes. It captures the stream epoch, latest-started read id, connection generation and requested session synchronously before calling the current status reader, then publishes usage only through the existing freshness check. BrainChatProvider uses it after a snapshot without usage, sub-agent settlement, compaction and opening Statistics; the hook uses it for live polling. Call refreshUsage() and handle its rejected promise at the caller. A failed read retains the last usage value; it never substitutes zero or retries on its own. The injected status reader retains its existing unknown-control recovery. Visibility gates only polling, so event-triggered refreshes still run in hidden tabs. Each invocation performs one status read without caching or cancellation. Initial hydration and settled-state refreshes keep their existing combined reads because they also publish non-usage fields.

useBrainChatLifecycle.ts owns the provider-lifetime open/compose bridge, unload, visibility, wake-up, silence watchdog and teardown subscriptions. It uses the provider's existing binding and stream refs without reconnecting on route changes or streamed tokens. A persisted pagehide only reports hidden visibility; a real unload sends one detachOnly stop. Revive clears that unload latch and recovers through the existing serialized stream reconnect controller using the current silence limit. Watchdog configuration changes re-arm only the watchdog; provider teardown stops the stream and removes all browser subscriptions. Its silence floor comes directly from the guarded RUNTIME_LIMIT_BOUNDS browser table, with daemon parity still tested. History and stream setup both import CHAT_HISTORY_PAGE_SIZE directly from its browser owner.

The dashboard HomeComposer calls the same BrainChatProvider.startNewConversation(opts?) action as chat's New chat controls. Called without options (New chat) it opens the project question as before. The dashboard instead asks the question up front: a compact destination picker in the composer offers newConversationDestinations (the question's own list from GET /brain/execution, projects only: no destination runs without a project, an administrator's included), preselects the last choice remembered on this device (usePersistentState, key elowen:home-destination) or else what the daemon picks for a new conversation (the first project of the account's order), and passes it as target for both Send and Call. startNewConversation({ message, target }) creates the conversation without raising the question, moves it to target through selectProjectExecution (the authorized POST /brain/execution the chat pickers use) before anything else, then sends through the controller's shared elowenClient.brainSend path before the caller navigates to /chat. A refused move rejects like a failed send and sends nothing. Until the targets load, or if they fail, the picker is absent and the daemon's default stays. No previous-conversation draft is written. The dashboard has no attachment control. Creation captures its confirmed session/client/generation binding and checks that binding after asynchronous status hydration, cache invalidation and send completion. A superseding conversation rejects the older operation explicitly; its initial message never uses the newer binding, and its completion cannot consume the dashboard draft or close the newer project question. Optional status hydration failure still permits sending through a current confirmed binding. Creation and send failures reject to the caller, which keeps its text and displays the localized send error; a disabled submit control and in-flight guard prevent duplicate creation. Apart from the cached execution-target read for the picker, nothing is requested until a message or call is started.

Terminal settings, their palette and permission snapshots are type-only imports from wireContract.ts, also used by their daemon store owners. showThoughtsCli is required because sanitized settings always include it. Permission revisions belong to the HTTP snapshot, not stored permission maps. The skin catalogue likewise uses the same runtime-free wire types on both sides; runtime parsing remains in the skin owner. Plugin config drafts use the importless, byte-pinned pluginNumber mirror for step alignment, while min/max/reset and visible validation errors retain their existing behavior. Asset names and problem reports share the single browser nameGrammar mirror. webMirrorContracts guards these copies, and dtoMirror checks shared type identity instead of parsing obsolete local interface copies.

Backend-for-frontend proxy

Browser requests use the same-origin /api base through web/lib/elowenClient.ts. The catch-all route web/app/api/[...path]/route.ts is the BFF for REST and SSE:

  1. It reads the httpOnly session cookie on the server: elowen_session, or __Host-elowen_session when the request arrived over HTTPS.
  2. When a personal API token is presented as a Bearer authorization header, it is forwarded as the daemon bearer and wins over the cookie. Otherwise, when a cookie is present, it adds the daemon bearer authorization server-side.
  3. It forwards the request body as a stream, preserving binary uploads and long-lived SSE responses.
  4. It forwards only the allow-listed content and range headers. A browser-supplied Authorization header is forwarded only when it carries a personal API token; a login token presented by a client is never forwarded.
  5. It rejects unsafe decoded path segments and re-encodes each segment before forwarding.
  6. It checks same-origin policy for mutating requests.
  7. It propagates client cancellation upstream. Ordinary JSON reads get a 12 s browser deadline and an independent 20 s upstream deadline, returning 504 on timeout without logging the caller out. The browser deadline covers both headers and JSON body consumption, including error bodies: expiry remains 504 with code timeout, while caller cancellation preserves its original abort error. Timeouts are not retried by React Query. Mutations have no automatic deadline or ordinary retry, so a timed-out send cannot be replayed accidentally. The only safe replay exception is the BFF proving its daemon connection was refused before delivering the write. Explicit read-like POSTs can choose their own bound (search rank 12 s; search Ask 18 s; provider test 30 s). SSE (/events, /brain/conversations, /brain/stream and requests accepting event streams), binary uploads/downloads and long-running agent runs retain only the caller signal. A closed caller returns 499 upstream. An absent plugin stream has no event channel to proxy.
  8. It removes upstream Set-Cookie headers because the BFF owns the browser cookie.
  9. It clears the session cookie when a request authenticated by that cookie receives 401. A 401 on a request that carried a presented API token, or a tokenless 401, does not clear it.
  10. A connection-level refusal or reset returns 503 with { error: 'daemon_restarting', replaySafe }; replaySafe is true only for ECONNREFUSED. A real daemon 5xx passes through unchanged. The proxy continues forwarding only nginx's x-real-ip; address trust stays in src/api/clientIp.ts.

elowenClient is the single JSON/raw-fetch recovery seam, including ElowenUiRuntime.api through elowenClient.api. Typed restart responses share one health reconnect controller, with jittered backoff capped at 2 seconds and a 20-second recovery bound. The existing read deadline and caller cancellation still apply. Reads and demonstrably undelivered writes replay after health returns; reset writes are never replayed because they may already have committed. They reject as daemon_restarting after reconnection, leaving drafts intact. Providers invalidates queries after recovery. Without this typed response no reconnect work or new feedback is added.

elowenClient.previewToolDeferral(runtime) is the Settings tool-loading editor's read-like POST consumer: it sends the exact draft to /config/tool-deferral with the shared 12-second JSON deadline and validates every catalog group and tool before rendering. The response type and schema live beside this client method; reason strings remain open so unknown server explanations are shown verbatim. Empty catalogs stay empty. A malformed or refused preview clears the catalog and disables Save; first loading also disables Save, and the modal's generation guard prevents older replies replacing the current draft's answer. No local policy calculation or extra polling is added. The global disabled explanation renders once as visible text.

Resumable chat attachments keep their sequential open/part/commit upload protocol and bounded part retries. brainChatAttachments.readRefusal parses each refused response once for all three phases, trims and bounds the server reason to 300 characters, and validates a nonnegative safe-integer received offset for part resumption. Non-JSON refusals carry no reason; their HTTP status still decides whether the existing part retry applies. This consolidation adds no requests and changes no upload admission or cancellation behavior.

The sole ToastProvider subscribes to this recovery state and displays one polite, non-error loading card until connectivity returns. Pass a caught error as toast(message, 'error', { error }) to get a neutral verify-before-retry message for an unconfirmed reset write; toast.promise passes the cause itself. Plugin toggle/install/update use this shared adapter. Shared autosaves use unconfirmed, not saved or error, and AutoSaveStatus offers a neutral verification note and Retry. Closing or completing a draft remains blocked until its unconfirmed save is resolved, just as for a refused save. A reset write stays unconfirmed even when health recovery itself expires. Login, logout and local or sibling-tab identity transitions advance the existing auth generation, so an old waiting request never replays under a changed cookie identity. Plugin bundles obtain these hooks, components and types only through ElowenUiRuntime and elowen-plugin-ui-kit; they must not import host web/ files. No success is claimed for an unconfirmed write.

web/tests/e2e/specs/restart.calm.e2e.ts verifies this seam with a real fake-daemon listener outage in Czech, Slovak and English at 320 and 1440 px. It follows the named plugin switch when disabling closes the detail, asserts the refused-write 503 and successful 202 replay, and waits for finite toast entrance animations before checking the translated text, containment and screenshot. Only browser resource diagnostics for the deliberately broken SSE transport and the typed refusal are expected; application console errors and page exceptions still fail the test.

Login, impersonation, impersonation return, and Microsoft SSO callbacks mint session cookies only from a daemon response with a 64-character hexadecimal token and a positive whole-day tokenTtlDays value whose seconds conversion remains a safe integer. readDaemonSession in web/lib/proxy.ts is the single parse-and-validate boundary for that upstream response, shared by all four routes. Invalid successful payloads fail closed with 502 for POST routes or the existing SSO failure redirect; they never mint or clear an authenticated session cookie. The daemon token lifetime is authority, so the BFF does not invent a fallback. The Microsoft callback also accepts only a same-origin relative next path.

During an identity switch, LoginGate keeps the existing loading state and closes account streams until the requested landing pathname is committed by the router. A successful /auth/me read alone does not reopen the shell: doing so before asynchronous navigation completes remounts the previous page under the new identity and triggers its queries and stream-open invalidations. The same landing gate applies to commit and rollback, including a custom landing path.

Requests without a cookie remain tokenless so first-run onboarding can reach the daemon. The daemon is the authorization authority. Client code must render server decisions rather than reproduce account, Project, plugin, or tool-policy checks.

The authenticated core /api/events stream stays open in hidden tabs so plugin operations and other pushed updates can settle there. useElowenEvents swaps that same stream to /api/events?presence=0 when hidden and back to the default when visible: the opt-out stream receives events but does not count as online or update web activity. Each swap's normal reconnect invalidates core caches; the shared reconnect controller cancels an older pending retry once the new stream opens, and the separate conversations stream keeps its own lifecycle. The admin-only /users response supplies the web-activity timestamp and online flag, invalidated by user-presence and on reconnect. Without a presence-counting stream, the user is offline; a chatbot has no applicable web activity. This directory never relies on /activity/presence, which describes work on a turn.