NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Complete seam catalog
Developer reference

Complete seam catalog

Complete seam catalog

This catalog counts contract entries, not individual overloads. Related methods are one entry only when they share the same owner, timing, consumer, and absent behavior. The authoritative list is the PluginContext interface in src/plugins/api.ts; manifest declarations are the PluginManifest interface in src/plugins/manifest.ts. A producer means a real implementation in the audited bundled or registry plugin trees, not a test fixture.

Useful consumer anchors include the GET /brain/status route in src/api/routes/brain.ts for status, LiveSessionSpawner.spawn in src/brain/service/spawner.ts for input transforms, GET /system/readiness in src/api/routes/system.ts for readiness, GET /projects/summary in src/api/routes/projects.ts for Project indicators, PluginRegistry.contextFor in src/plugins/registry.ts for registration behavior, and web/lib/pluginChatUi.tsx plus web/lib/pluginDashboardMetrics.tsx for browser slots. The tool-trace contract is the CodeModeTraceSink interface in src/plugins/api.ts and the transcript consumers are in src/brain/toolTrace/.

#Seam and minimal useWhere and when it actsProducer statusAbsent behavior, limits, and cost
1registerTool(tool, { external: true })Host-normalized origin at registration; schema caps and status fields at composition; redacted names in cache diagnosticsBundled and registry tools; the MCP bridge declares external schemasOmitted origin is false regardless of name. A non-external tool's parameters must be one top-level type: 'object' with a properties map and no root anyOf/oneOf/allOf (providers keep only the root properties/required, some reject a root union); anything else is refused with a plugin warning and the tool is absent, so model the variants as one flat object and validate the union yourself inside execute. A tool that declares no parameters is refused the same way (declare { type: 'object', properties: {} } for no arguments), and external tools are exempt. External descriptions are bounded to 1,000 UTF-8 bytes and definitions to 8,000 serialized bytes by schema replacement; the external owner still validates arguments. Metadata stays outside provider payloads and follows accepted contribution ownership. Hidden success output is normal unless manifest metadata exposes it; core owns auth, plan mode, hooks and locked deferral. Composition cost is linear in the catalog and inspected schemas
2registerSkill(skill)Skill catalog and progressive disclosureRegistry Skills, plus plugins with skillsMissing skill is omitted. Each turn announces model-invocable skills by name, description and length in lines, never by file path, so the same block works in a managed project container; the length is pinned when the skill is registered, so the block's bytes change only when the catalog is replaced. File-backed content is loaded through SkillLoad only when requested and consumes prompt tokens then
3registerCommand({ name, kind, adminOnly, cliPicker })Live shared slash-command catalog; CLI descriptors/actions use POST /brain/pickerBundled MCP/Statusline and registry Skills/Todo/LSP own their commands and terminal behavior; web Skills/Todo retain existing chatPickersPicker contributions carry no prompt. cliPicker.scope declares application/session lifetime; optional cardAction:'task' connects card rows without a command-name dispatch. render(input, { session, locale, strings, request }) returns bounded list/toggle data, notice, submit, metadata-refresh intent or openUrl. strings is the same manifest-English plus i18n/<locale>.json web.strings projection served to browser views, empty when none are declared. openUrl is a normalized HTTP(S) URL without credentials, at most 4,096 characters. The terminal launches its existing browser opener only after a picker selection/key action, never from the initial descriptor or a stale result, and keeps a manual URL in a transient sticky notice for SSH/headless use; the URL does not enter conversation history or prompts. Without openUrl no browser action occurs. String resolution is linear in declared keys; pickers add no model request. Catalogs expose only capability/scope, never callbacks. Dispatch checks the live command owner, surface, admin gate, account plugin grant and bound-session authority before calling the owner. request re-enters the existing local authenticated API with the original bearer/cookie, never a privileged credential or external URL; feature routes retain authorization, reload and mutation ownership. Invalid contributions/inputs/descriptors are refused; missing command is unavailable, not empty. Generic CLI rendering uses shared modal components, optional per-key close-value lists, serialized optimistic toggle writes and guarded rollback; late LSP installs return notice only and cannot reopen a modal. Pickers create no model turn; headless prints the initial descriptor. New consumers require core 0.29.26 or later
4registerSystemPromptFragment(text)Stable system-prompt suffix at session compositionAskUser and Skills produce fragmentsIt cannot change a core persona or per-turn state; its bytes are in the cached prefix and are paid on a cold prefix
5registerTurnContext(fn, { placement })Once during fresh-turn composition; hidden native journal records replay at their user or host anchorRuntime-contextRequires mutates:['turnContext']; before-user is the default, after-user qualifies the request. Rejected renders are isolated; no provider adds no frame. Records cost input tokens while retained, stay out of visible chat, and follow compaction and cold clearing without rewriting user text
6registerInputTransform(fn)Raw user input before PI input handlers, current turn onlyRegistry SkillsRequires mutates:['turnContext']; without it input passes through unchanged; transformed text costs the same current-turn input tokens
7registerStepContext(fn)After the last tool result, every configured number of tool callsRegistry TodoCore persists a hidden native-journal meta entry before model delivery; no human chat/platform message. Bounded and append-only in the retained prefix, which follows the active journal, compaction and recovery; costs model input tokens without rewriting the cached prefix
8registerHook(hook)Wired lifecycle points: spawn, context, tool call, run end, shutdownBundled Files, Sandbox, MCP, Subagent; registry LSPObservational by default; gated patches can only narrow. Errors/timeouts fail open. Reserved hook names do not fire
9registerPlatform(adapter)Adapter is connected at daemon startup and receives normalized messagesBundled Subagent; registry Cronjob, Codebase, Discord, Teams, Telegram, WhatsAppBad or undeclared adapters are not started; adapter owns transport auth and formatting, core owns identity, policy, session, and delivery. A platform name is routing metadata, never ownership authority: account-owned synthetic turns must enter through PlatformControlApi.relay, and ordinary listen ingress cannot claim relay provenance by setting actAsUserId
10PlatformControlApi.relay(src, text, observer?)A registered adapter starts a synthetic turn through the same identity, policy, durable channel session, locking, and event path as inbound trafficCronjob and cross-person messaging adapters; any declared platform may consume itThe source platform must match the adapter. Live events are observational and best-effort; observer detach never aborts the turn (stopping is PlatformControlApi.abort(ref), which also ends a turn still being prepared). The returned promise is the terminal result and the relay does not provide event replay. A source origin with dedicated: { title } (Cronjob's own job conversation brain-<uid>-job-<id>) is created and named by BrainService.sendOwnedConversation, the same path row 69 uses; an origin can never name a plugin-keyed conversation
11registerNotificationDestinationProvider(provider)Admin destination listing, when destinations are requestedRegistry Discord, TeamsUndeclared platform is refused; unavailable listing is explicit, not an empty valid destination. Upstream listing may cost network time
12registerHttpRoute({ path, handler, maxStreamBodyBytes?, problemReporting? })Public HTTP mount at /hooks/<plugin>/<path>, with a host-resolved ClientOrigin on every requestRegistry Teams, Sites, Chatbot and Twilio; bundled MCP publishes its credential-free OAuth client document with path: 'oauth/client-metadata'No bearer auth is supplied. A webhook must authenticate and validate the body; an explicitly public read may return only public data. MCP permits exact GET/HEAD, derives client and callback URLs from publicWebUrl(), requires HTTPS and answers 503 when it cannot publish valid metadata; no network request or model cost. An undeclared route is refused and an absent plugin provides no document. origin.trusted describes only network-origin trust under security.trustProxy, never authentication or an Origin-domain grant
13registerApiRoute({ path, access, handler }) and rootMountAuthenticated route dispatch before handlerBundled Changelog, MCP, Sandbox, Subagent; registry pluginsCore authenticates and scopes first; handler repeats domain authorization. MCP server creation/update keeps the saved HTTP row on OAuth discovery or registration failure and returns its error auth state plus a redacted phase/HTTP-status diagnostic. The diagnostic is stored with the existing encrypted, owner-scoped OAuth state under its credential lock and projected into the existing lastError field after restart; successful authorization or sign-out clears it, and another account cannot read it. Non-OAuth verification failures keep their rollback. Sign-in remains an authenticated explicit POST. Root mounts preserve absolute URLs without a core behavior fallback: an unknown root is 404, while a known disabled or unavailable plugin is 503
14registerWebSocketRoute(route) plus issueWebSocketTicket(input)Ticketed browser or plugin-authorized public upgrade at /ws/plugins/<plugin>/<path>Registry Browser, Sites and TwilioTicket is one-shot, short-lived, and redeemed with current identity/scope; no bearer token in the upgrade. Public minimal use: ctx.registerWebSocketRoute({path:'media/:token',access:'public',authorize:({params,headers})=>verify(params.token,headers),handler:conn=>bridge(conn)}). Mandatory authorize must return true before the 101; false returns HTTP 401, a throw returns HTTP 500 and a pattern-only diagnostic. Declare the path in provides.wsRoutes; missing declaration or public authorize rejects registration. Public connections omit auth and payload, skip ticket/account/grant resolution, and retain media, decoded params, query without ticket, signal and backpressure. The same-host Origin gate stays first; clients without Origin are accepted for authorization. All route refusal, transport and handler logs name the declared pattern, never the concrete credential path. Plugin owns credential/signature verification. Nothing listens without registration; cost is one route match and authorize per public handshake, no auth/account DB lookup
15registerService(service) and registerInterval(name, fn, ms)Start after boot reconciliation, stop on daemon shutdown; intervals are unref'd and single-flightRegistry GitHub, Sites, Browser and others; Sites returns its asynchronous serving refresh from ctx.registerInterval('refresh-site-serving', refresh, 5000)Return tick work so pending ticks are skipped and shutdown drains it before services stop. Each failure series reports plugin.interval_failed on its first failure, then at most once per ten minutes with its cumulative count; success logs local INFO recovery and resets the series. No registration creates no timer, and runner processes never start plugin services. A slow critical stop is abandoned after the grace period. Timer work costs host resources, not model tokens unless it starts turns
16registerControl(key, control) and control(key); lsp.shutdownSession(sessionId)Live cross-plugin capability, resolved at call timeSandbox, Subagent, Cronjob, MCP, CodeMode, GitHub, Sites, Browser, Microsoft identity, Skills; bundled Elowen docs publishes docs for the command palette's Ask AIcontrol returns undefined when absent or disabled; a new typed child is refused without the live Subagent control and its resolved profile. requiresControls does not grant access. Never cache controls. Calls are local but may perform network or process work. The lsp control releases one durable conversation slot at final onRunSettled and live-session disposal, never at beforeSettle; cancellation is synchronous and the returned promise drains diagnostics and transport closure. Runner reclamation awaits the same promise before transferring the conversation to the daemon. Automatic diagnostics and explicit tools acquire only in the owning conversation process; HTTP status is observational. The code-mode control composes gated nested tools through PI schemaToType declarations and CodemodeSandbox, excluding model-only exposure; it provides exec/wait scoped by the turn principal (request.principal()), a per-writer view of a shared room's personal tools (visibleToWriter), cell ids with a random per-session generation (an id from a session released by shutdownSession, a plugin reload or a restart reads as not found instead of reaching a newer cell), dirty JSON snapshot commits including script failures, native model notifications, prepared media through the existing provider hook, and one output budget per observation. No extra heap, cell-count or inactivity cap is imposed. With no control, ordinary direct tools remain. ctx.control(key) returns the control wrapped so each method call runs with the calling plugin appended to the control-caller chain (src/plugins/controlCallers.ts); core reads it where an effect must stay with the plugins that caused it (row 70's call card). Values and getters pass through unchanged; every call returns a fresh wrapper, so never compare controls by identity
17registerBrainStatusProvider(fn)Read-only fields merged into GET /brain/statusBundled Statusline and MCP; MCP rail consumes the existing projectionMCP rows include name, status and credential-free auth state. Host validation drops unknown fields and preserves the administrator-only gate. Absent MCP data omits the section; provider errors drop that provider. The status request pays provider work, not prompt tokens or a second rail fetch
18registerUiVisibility(fn)Synchronous per-account filtering during plugin UI listingRegistry OneDriveHides only the plugin's declared account/project panels, never API authority. A throwing probe hides panels and is warned; it must not use network
19registerNavBadge(fn)Synchronous count in the plugin's navigation listingBundled Changelog; registry Licensing (open problem reports)null or zero omits the badge; a throwing probe drops only that badge. No browser request or prompt token cost
20registerProjectIndicators(fn)One bounded server-side batch for visible Projects rowsRegistry GitHub and OneDriveCore supplies only visible Projects in canonical account order through ProjectService.authorizedRows, without filesystem projection, and validates ProjectIndicatorTone own keys. One 5 s registry lookup also supplies Sandbox for managed branches; failure does not start another lookup. Output is bounded to three indicators per plugin/project and eight per project; each provider has 2 s to answer. Failure or timeout is logged and omits its indicators. With no provider there are no indicators. No second project registry or extra browser request
21registerReadinessCheck(fn)Each GET /system/readiness, including setup/doctorBundled Sandbox; registry Browser, GitHub, and SitesDisabled checks disappear; null skips; thrown checks are dropped for that request. Keep it cheap because it rides a UI read
22registerMcpTool(tool)Per-request composition of Elowen's own /mcp tools/list and callsThe host MCP server is a live reader; no production plugin producer exists in the bundled or registry trees searched for this taskA valid contribution appears on the next live /mcp request. With no producer, plugin tools are absent today; names must be declared in provides.mcpTools. This is not the external-MCP bridge and carries no separate plugin prompt cost
23publishEvent, deleteEventsForTarget, registerEventRowResolver, subscribeEventsEvent bus, SSE/activity persistence, and target cleanupSandbox publishes events; core reads the bus and wires the cleanup path. deleteEventsForTarget and registerEventRowResolver are intentionally retained @platform-keep plugin-events seams, with no current plugin caller or resolver. No plugin subscriber exists in the bundled or registry trees searched for this taskmutates:['events'] is required. Publish/subscribe throw when unwired; deletion is a no-op without an event store. A registered resolver is consulted only for events core does not persist. Owner-scoped conversation and update invalidations and admin presence bypass resolvers entirely: they are transient read-model nudges, not activity history. Optional plugin-event userId privately routes to that verified account before Project/admin scope and skips shared activity persistence and row resolvers. Restore after reload/reconnect from the plugin's authorized durable rows; Sandbox acknowledgement and Cronjob manual-run progress are consumers. These frames cost one account-scoped broadcast and no activity-log write. Without userId existing Project/admin tenancy and persistence apply. Events are data, not prompt context
24registerPrompts({ dir, entries })Persistent plugin templates rendered through the host prompt rendererBundled CodeMode; the prompt renderer resolves its templatesRequires mutates:['prompt']; core template names cannot be shadowed. Only each entry's name is read. Template bytes affect later prompt composition, so this is not per-turn context
25registerBootReconcile(fn)Before platform serving, on every daemon startBundled Sandbox and registry pluginsMust be idempotent; exceptions are logged and later reconciles continue. No model cost unless the reconcile starts model work
26registerUserRemoved(fn) and registerProjectRemoved(fn)Deletion lifecycle while the deleted id is still knownBundled Changelog, Sandbox, Terminal, Subagent; registry Codebase, GitHub, Sites, BrowserDisabled plugins miss the live callback, so reconcile must also clean up. Cleanup runs sequentially with per-handler isolation
27requestReload(change)Ask the host to apply a change the plugin wrote to diskBundled MCP and Subagent; registry Skills{ mode: 'reload', skills } hands over the plugin's complete new skill set and replaces it in the running daemon: no restart, no interrupted turn, and every session sees it on its next turn. { mode: 'restart', reason } restarts the daemon, which joins requests in a 2.5-second quiet window from the last request, then checkpoints resumable turns and waits only for unparkable turns; nothing else changes in the current process, so never assume a restart's new state is live after the call. From a sub-agent runner the change is handed to the daemon and applied there. Returns a promise: it resolves once the daemon took the change and rejects, with an error log, when it did not (a malformed change, no way to apply or reach the daemon, a daemon that cannot restart). Await it and report a rejected change as saved but not applied
28config, userConfig(), secrets, PluginSecretBag.withLock(key, operation), dataDir()Manifest-backed settings, encrypted credentials and plugin-owned persistent filesMany plugins; MCP uses secret-bag withLock for OAuth refreshHost owns masking, revisions, account scope and paths. User config and secrets are null outside an account. Credential locks serialize the whole read/refresh/write across daemon and runners, wait at most 15 seconds, and throw on contention or absent durable vault storage. No fallback or prompt-token cost; each acquisition starts one short kernel-lock helper and retains one descriptor until release
29db().migrate(steps)Plugin-namespaced SQLite tables and ordered migrationsBundled MCP; registry domain pluginsRequires reads:['db']; use p_<plugin>_ tables, never core columns. Database work is outside model context
30emitCard(card)Live structured BrainCard to interactive clients during a prompt turnBundled Terminal; registry TodoNo-op outside an interactive turn; CLI shows only pinned cards. Optional startedAt ticks while running and durationMs freezes on completion; old cards omit both. Core owns card transport and generic fallback; card details do not enter model context
31details.toolTrace, details.ownRow and details.performedTool-result presentation and reload reconstruction for nested work, and what a settled call didCodeMode producer of toolTrace/ownRow; Sandbox's Desktop produces performed; all transcript surfaces consume themtoolTrace records nested calls/progress, optional startedAt epoch milliseconds and settled monotonic durationMs, and stored images references with producer-local imageOrder and cross-result completion time imageTimestamp; nested ShareImage({ latest: true }) reads that producer's newest settled image before the wrapper returns, and journal lookups read the same references afterwards. No automatic image publication is implied. Share tools commit ownership through BrainStore.recordSharedAttachment in the existing native journal before returning success or publishing their live attachment event; failed commits are errors. Attachment routes, retention and deletion read that same durable proof. Ownership stays off the model branch through discard and rewind. Ordinary image storage or trace-budget exhaustion drops optional image references without failing a successful tool; explicit share delivery metadata must fit. Share quota slots are reserved before yielding and released on refusal or failure. A call reported pending is rewritten in place, in the result that reported it, once it settles. ownRow: false is a hard no: the producer's own row is not drawn, including progress notes. performed is one line saying what the call actually did (clicked push button "Keep" in gimp): when the call settles it replaces the row detail derived from the arguments, on the live row (tool_output/tool_end carry it as detail), the nested row and the reloaded row alike, cut to 200 characters. Without it the row keeps the argument detail; a string action argument leads that detail as a verb (click e211). Any tool may set it, and only for what it knows happened: a failed step says so, and a call whose effect is unknown must not claim one. Put it in details, not in the text the model reads. Details are host-only, so no token or cache cost
32processes and terminal process accessHost process registry, output, detach, and kill for owner-scoped toolsBundled Terminal and SubagentCore owns supervision, identity, kill semantics, and the conversation-scoped processes status/SSE projection; re-register the same handle on detach, never publish process cards. Output can cost model tokens only when returned by a tool
33host.stores(), external users, and conversationsRead-only core projections for tenancy, identity, Projects, users, installed-plugin display metadata, events, and conversation targetsBundled/registry Sandbox, Changelog, Cronjob, Sites, GitHub, Teams, BrowserProjection is not mutation authority and never grants a foreign transcript. Re-resolve current ownership, plugin availability and Project access at operation time
34host.projectFiles(), assertPathAllowed(), host.git()Canonical path trust boundary and read-only checkout snapshots/diffsRegistry Editor, Teams, GitHub; bundled FilesGit requires reads:['git']. projectStatus(directory, { authorizeRoot }) resolves the repository, authorizes its discovered root before reading contents, and returns { root, branch, files } with short-format staging columns. Files stats and authorizes directory/file inputs first and caps display at 120 rows. projectSnapshot and projectStatus accept { managed: { project, accountUserId }, signal }: core verifies the current account/selected target and consumesControls:['sandbox'], then uses the same strict guest executor as project inspection and branch telemetry. Membership is rechecked by the environment provider; managed execution never falls back to host Git. Missing provider or runtime failures throw, while only Git-attested missing repositories become empty snapshots. Commands have bounded output and lease cleanup; settled failures release rather than cancel a finished guest. No contribution or host wiring means unavailable, never invented status. Callers retain path authorization; reads may become model input only when returned. host.projectFiles().read(path, { maxBytes }) returns { path, bytes: Uint8Array } for binary platform transfers using the same readShareSource boundary as ShareFile and ShareImage. Minimal use: const { bytes } = await ctx.host.projectFiles().read('/project/report.pdf', { maxBytes: 20 * 1024 * 1024 }); Teams TeamsSendFile and MicrosoftFiles.upload are real consumers. Requires reads:['project-files'] and the loader-approved Teams caller name (other platforms read shared attachments from conversation storage and are not admitted), plus a current linked conversation. Relative paths use the current turn working directory. The host guard limits paths to caller-accessible roots; unrestricted host sharing additionally requires an owner identity. Managed project/account authority comes only from the current turn, with provider membership checks on every chunk, version checks and no host fallback. The managed environment remains one whole guest filesystem, as for chat shares; the plugin cannot select another environment or account. Missing host wiring, missing Sandbox, invalid paths/bounds, denied reads or changed files throw. maxBytes is an integer from 1 to 250 MiB, chosen by the consumer's transfer cap; files are size-checked before reading, host reads are synchronous, managed reads use 128 KiB chunks plus opening/closing stats and return one in-memory byte buffer. No call means no read or transfer; this seam does not store, publish or send the bytes
35host.publicHttp()Validated outbound HTTP with DNS resolution and socket pinningBundled Web; future consumers otherwiseRequires network: true; core controls destination validation and TLS host preservation. It is not an arbitrary socket or an inbound route
36host.defaultInference(), resolveProvider(), embeddings, and imagesShared provider, embedding, and image services with core credentialsBundled Web, ELOWEN Docs, Codebase, Image pluginsRequires the matching read grant or own configured provider. Credentials stay in core; inference and embedding calls cost provider tokens/latency. OpenRouter images select an image descriptor from the shared PI catalog and use ModelRuntime.generateImages; unknown image ids fail before dispatch. Image requests contain providerId, model, prompt and optional signal, plus images for edits. No size, quality, background or count controls are accepted; unknown fields fail before dispatch. Result metadata includes the returned format. Image usage returns token counts, not cost or origin attribution
37host.subagentAgentDir(), host.subagentOwnerApi(), subagent and workflow emittersOwner-scoped child controls, agent continuation, stop, detach, and workflow expansionBundled Subagent and integrationssubagentAgentDir() requires Subagent's reads:['agent-files'] grant and returns the fixed user-definition directory, or undefined for an in-memory database; missing host wiring throws. The plugin reads one catalog snapshot at registration and requests a restart after editor writes, so the old generation remains active until reload. Core revalidates ancestry, principal, project scope, client fencing, and read-only tools. Human child views are always read-only: subagentOwnerApi().preflightSend(): never rejects the operation without a payload. The bundled POST /brain/subagent/send first enforces account/plugin access and runtime availability, then answers 409 synchronously before reading or validating any body, with no session lookup, provider turn, queue entry, origin pin or detached acknowledgement. There are no human send or pinOrigin operations; parent-agent continuation remains on its existing principal/scope-checked turn seam. For the Subagent plugin's workflow stop route, subagentOwnerApi().ownsWorkflowParent(userId, sessionId) verifies the authenticated account against an existing owner-chat parent; the plugin additionally checks the run's origin principal and running state and shares the model tool's stop implementation. Without the owner runtime the route refuses; it never guesses ownership from a workflow id. subagentOwnerApi().switchModel() is asynchronous: await its { model, identity: BrainSessionIdentity } result. Core releases idle runner/local runtime, persists the selection, and publishes resync: model-switched; busy children refuse. The shared identity projection also supplies model/effort to progress emitters. Delegated continuation session progress carries structured sessionIdentity with separate provider/model from that same projection; elowen-plugin-shared/modelIdentity exposes modelIdentityLabel for the qualified display label used by core progress and workflow dispatch/resume. It preserves slashes inside model ids, omits absent models and keeps unknown-provider labels unqualified, with no I/O or token cost. Workflow nodes replace model/effort from it on live and boot resume, including clearing an absent effort instead of reviving a declaration. An already-answered boot child emits its stored identity without opening a model turn. Delegation progress carries optional agentType separately from the task's name, using the chosen catalog name because the current catalog has no separate display label. Untyped and fork children omit it. The host preserves it on continuations and in durable run JSON; workflow node snapshots preserve the same field. Fixed-session snapshots project session.delegation: { agentType?, status } for the CLI and web locked-composer narrative, with no extra fetch or token cost. Persisted progress also publishes that same projection as a delegation frame on attached child streams, including hidden-tab transports. Unattached children do no display-projection queries; reconnect rebuilds their exact sidecar from durable state. Core verifies requested child ancestry and reads shared parent lifecycle, run rows and workflow nodes once per publication. Parent and watched child frames keep their synchronous order; only the child's own type and outcome travel, and turn idleness does not finish a delegation waiting for nested children or background jobs. The field is display-only, not a tool or permission selector. Delegation progress carries runStartedAt for the current call independently of child creation; workflow progress carries runTiming for the current DAG run. The store validates and preserves both timing fields. Historical snapshots without recorded run timing expose no invented elapsed interval. Missing runtime is explicit; child model turns cost their own tokens. A child's name on a progress update is a suggestion for its FIRST call only: core fixes the name when the child is first delegated (its workflow node's name, else its earliest run row's) and stamps it onto every later call's row, so a continuation's emitter sends none and a stray label is replaced (originalChildName, src/store/delegatedChildName.ts)
38host.sandbox control and managed project file bridgePrepared confined/managed execution, guest roots, leases, and runtime lifecycleBundled Sandbox; registry Editor, GitHub, Sites, LSP, MCP, Browser, CronjobNo unconfined request or host fallback; provider absence makes managed work unavailable. Process and runtime cost is bounded by core
39Browser pages and navigation metadata in web.navPlugin bundle listing, navigation, page route, and generic frameBundled and registry browser pluginsHost owns discovery, visibility, React instance, routing, error boundaries, and API auth; missing page is omitted or unavailable
40web.account, web.user, web.project, web.settingsContextual panels and settings sections mounted after listingBrowser profile and Sandbox Desktops account sections; bundled and registry pluginsDeclared panel is not authorization. Visibility can be filtered by seam 19; missing bundle or incompatible API omits the panel. A user panel may also register userStatus[panelId] (UI API 46): a small component the host draws in the block header right after the title, mounted while the block is collapsed; absent, incompatible or crashing it draws nothing
41web.projectRowsProject-register row hook with normalized Project projectionBundled Sandbox and registry Editor/OneDriveCore supplies visible rows and generic project shell; plugin owns indicator/action copy. Optional UI API 48 nameIndicator: Record<projectId, PluginProjectRowStatus> draws one named lucide icon with shared HelpTip beside the name; first contributor wins, missing entries draw nothing. Sandbox uses the shared stored desktop overview, never per-row guest probes. UI API 50 extends each metric with optional action: { label, onSelect }: both ResourceMeters sizes render an isolated keyboard-accessible ghost ring button, including stopped/unknown readings; absent keeps passive markup. Labels include resource and Project. Metric/contribution shapes come from the kit; frame signatures include action presence and label, never callback identity. Handlers must stay callable across published frames; overlays and authorized history reads remain plugin-owned, with no per-ring host polling
42web.projectEditIconIcon field of the core Project edit form, mounted from every compatible bundle that declares itRegistry EditorManifest flag only gates bundle loading; the bundle must register projectEditIcon. The picker uses the editor's typed project/system root selector and root-relative file requests. Project.icon stores a project-relative image path or, only for managed projects, a guest-absolute system image path; core validates existence and canonical confinement, and shared ProjectIcon serves that same root on every card/pill. Empty clears the icon; existing relative icons need no migration. Kernel-interface exclusions come from elowen-plugin-shared/projectExecution. Missing or incompatible bundle leaves the core icon field
43web.dashboardMetricsOptional dashboard metric component, loaded only for manifests advertising itRegistry CronjobCore dashboard and core usage remain complete; missing metric is omitted and no plugin query runs
44web.chatPickersLocal browser picker keyed by slash command, without a model turnRegistry Skills and TodoMissing picker is an explicit unsupported state; slash parsing and API authorization remain core. sessionId is the viewer's own conversation, null over a focused sub-agent. Unavailable browser pickers, and pickers that throw while rendering, render one host-owned PluginPlaceholder frame with a shared Close action and one alert (the unavailable text, or the generic crash text), drawn in place in the footer of the chat surface that raised the command (above the composer), never beside the app shell. Contribution loading and mount authorization remain outside the placeholder.
45web.chatCardsRenderer keyed by opaque BrainCard id before generic StaticCardRegistry Todo; Sandbox desktop uses web.chatDock insteadMissing renderer uses read-only generic card; component presence never grants mutation. Draw the card prop: it is the daemon's card for the session on screen, a drilled-in sub-agent included, so a renderer that reads its own route instead shows the viewer's data, not that session's. sessionId is null where the plugin's routes cannot act (a sub-agent view, a read-only transcript); offer controls only with one
46web.chatRailSectionsPlugin section in expanded and compact chat rail variantsBundled MCP, Subagent, Terminal; registry LSP and TodoMissing section is omitted while rail shell and SSE/auth stay core. data is the host's projection of the session on screen; sessionId follows the chatCards rule (null where the plugin's routes cannot act). From API 27 an expanded section is composed from the runtime's rail grid (RailSection, RailList, RailRow, RailMeter, RailDot, ShimmerText; typed in the kit as RailSectionProps, RailRowProps, RailMeterProps, RailDotProps, ShimmerTextProps), so its labels, figures, row heights and type steps line up with every other section; a bundle on an older API still renders with RailSectionHead and its own rows. From API 28 a task list is the runtime's TaskList (and one task TaskRow, typed as TaskListProps, TaskRowProps, TaskListTask): open work first, a same-size "+N more" row, finished tasks folded under one "Completed" row, each row its own status menu; the Todo section is the consumer
47web.historyBranchesPlugin branch under a conversation rowBundled Subagent and registry Cronjobopen('session:' + encodeURIComponent(id) + ':1') requests eligible continuation; :0 or omission previews. Every host handler parses it with the one parseBrainSessionTarget (web/lib/pluginChatUi.tsx); an invalid target opens nothing. The host forces delegated child transcripts read-only regardless of the flag. Navigation grants no authority and uses the existing session stream, with no polling. Missing renderer leaves generic row/navigation; pagination, search, grouping, and auth stay core
48ElowenUiRuntime, web.strings, locale blocks, bundle/CSSShared React/runtime including API 54 AdaptiveBrandMark and the staged shared hooks.useNow clock, plugin-owned strings, immutable assetsAll browser pluginsBundle must use host React and validate runtime data. Source changes require regenerated assets; a stale or incompatible bundle is not silently treated as current. API 26 adds hooks.useInvalidateConversationLinks(), a callback that invalidates both canonical host scopes through the shared QueryClient; no mounted query means no network request. Cronjob uses it after its own job mutations. API 27 publishes the chat rail grid and API 28 its checklist (see row 47). API 31 publishes shared workflow labels and counts. API 32 publishes the existing utils.cardTasks(cards) projection and utils.cardTasksAddressable(rows) id guard for Todo: structured owner, blockers and measured times survive every web consumer. Missing Todo cards return no rows; no route reads or polling are added. The Todo rail consumes the host's TaskRailData directly. API 36 publishes components.DesktopPreview and hooks.useDesktopPreviewState(), the one desktop tile, enlarge surface (the bare Modal), claim and status presentation that Browser and Sandbox share; minimal use, slots, mobile policy and the chat dock measurement are in docs/WEB.md (Shared desktop UI). A producer passes its own useDesktopControl client and the shared preview state, and owns only its identity, action text, navigation and power confirmation; there is no new register* method or manifest slot, and a bundle requiring API 36 is refused by an older host before mounting. API 39 moves narration and pending questions into DesktopPreview through typed narration and pendingInput props, removes notices and DesktopPreviewSlotContext, and uses one ten-second hide/dismiss/reset and collapse-then-reveal path for Browser and Sandbox. API 38 adds the shared viewport-gated still hook. API 54 adds hooks.useOperationDock() and removes components.OperationProgressDialog; Sandbox follows durable UI-started operations through the shell dock, including after reload (see UI API 54 below). API 53 adds filtersNamed: 'actions' on PageToolbar and named: 'actions' on PageFilters; ControlSurfaceToolbar forwards these names and action content. Settings Plugins uses the same panel for category filtering and one batch update, with confirmation outside; absent naming retains Filters/Options and adds no fetches (see docs/WEB.md). API 30 adds badge on DeckNavigation records, filterActions on PageToolbar and hooks.useInvalidatePluginUi(), which refreshes the navigation count a registerNavBadge probe feeds (see docs/WEB.md). API 29 adds the toast helpers below and editing on the auto-save result. hooks.useToast() is the sole toast adapter: the same stable object retains toast(message, tone?) and adds promise(task, { loading, success, error }, options) and dismissKey(key). The only option is key. Bundles feature-detect new helpers on older hosts; no bundle imports Sonner. Keyed successes replace one card, identical unkeyed successes deduplicate for 4000 ms, and errors always announce immediately in an assertive mirror. The host config draft and settings-page save channel raise the trailing "changes saved" confirmation themselves, so a bundle does not announce those saves again. Bounded UI controls share the host's Radix-backed selection behavior: Segmented supports disabled options and decorative rendered icon content, and ManageSelectionModal uses it for independent item and group filters. Spinner retains named sizes, including toast loading, with caller-specific reduced-motion styling. See docs/WEB.md, Bounded shared UI.
49ctx.notify(), askUser(), answerQuestion()Outbound notification or interactive question in a live prompt contextBundled adapters and AskUserNotification delivery and question ownership stay host-controlled; outside an interactive turn these may be unavailable or no-op. Options expose recommended?: boolean; timeout answers expose outcome: 'expired'. AskUserQuestion returns durable details.questionAnswer for the existing transcript projection. Replies/cards are runtime data, not stable prompt instructions
50writeCard(sessionId, card)Persist and publish a BrainCard for one explicitly named conversation, from an authenticated plugin API route rather than a live prompt turnRegistry TodoValid only inside an authenticated API-route request scope; the host derives the caller account and rechecks the named session's ownership, and refuses calls outside that scope. A cold conversation hydrates the card on its next connection
51ctx.alerts.raise(...) and ctx.alerts.clear(key)Durable per-recipient operational alerts, delivered to the bell and eligible Web Push devices. holdMs on a raise holds back a self-healing condition until it has lasted that longBundled Sandbox and core-facing producers; registry plugins may consume the seam. Sites uses holdMs for its gateway, worker and per-host noticesRequires reads:["alerts"]; the host stamps the source, resolves and validates recipients, deduplicates by (recipient, source, key), localizes at read/push time, and keeps alert content out of SSE invalidations. holdMs (integer, 1 ms to one hour, requiresCore the first release that ships it): call raise on every check while the condition is true and clear the moment it is not. Core keeps one in-memory clock per (source, key), writes and rings nothing before holdMs has elapsed since the first report (the call returns { recipients: 0, pushed: 0 }), then raises normally; clear resets the clock at once. Core fills the message param since (ISO time of the first report), so the producer must not send one and a template may use {since}. The hold restarts if reports stop for longer than holdMs, so report more often than that. The clock is not durable: a daemon restart restarts the hold, and an already open row stays open untouched until the producer clears it or the hold elapses again. Omit holdMs for events and for conditions that must ring at once (licence, update, data loss, security). String params, such as a failure detail, are already control-character-free and cut to 200 characters; keep it to one line yourself
52ctx.projectImageFiles() and shared createImageRuntime(ctx)Binary image read/write in the current host or managed execution targetRegistry Image-gen and Image-editAvailable only to those two plugins; host path authorization and managed guest authority apply. Writes are create-only unless explicit replacement is requested; no host fallback for a managed session
53ctx.control('sandbox').environmentSnapshots({ project, accountUserId })List snapshot ids and metadata for an authorized managed ProjectPublic ProjectEnvironmentControl method; core's project service reads it for the agent's Project tool (get)Returns crash-consistent snapshot metadata, not snapshot contents; pass a returned id to the environment restore operation. Resolve the live control at each use; absent Sandbox control means the operation is unavailable
54PlatformControlApi.compact(ref, senderPlatformId), setAccountFast(ref, sender, on, active), restart(ref), switchWorkMode(ref, senderPlatformId, mode)A room's /compact, /fast, /restart, /plan, /build and /workflow, run through the shared runControlCommand coreRegistry Discord, Teams, Telegram, WhatsApp through elowen-plugin-shared/chatCommandsMode controls come from the shared catalog and invoke switchWorkMode with the real sender id; the daemon resolves the linked owner and uses the same serialized, persistent conversation switch as CLI and web. The returned BrainWorkModeSwitchResult.workMode is current control; queued: true means the request waits for the turn boundary, so the shared bridge reports a queued request rather than claiming the selection changed. The durable mode event completes it. status(ref).workMode projects the same selection. No adapter stores mode or stamps sends. Missing control wiring rejects. One native mode event is the durable notification, not a second command event; no-op selections produce none. Changing selection does not relax captured delegated tool policy or read-only provenance. Compaction forwards the invoking sender id from the shared command bridge; the host resolves the linked account and pins its platform origin inside the session lock until compaction exits. An unlinked or absent sender has no invented account attribution. Each summary attempt contributes its own usage, even when refused or aborted. Each run is recorded in the room's conversation as a command chat event, the same record an owner's command writes, and the method returns it as event; the shared core renders the reply from it, so the room, its model and every transcript read one fact. A failure records its exact error text and then rejects with it, and the reply shows that text. Fast is the linked account's choice for the room's selected model only: active is the plugin's catalog pick ({ provider, model, fastAvailable }), used only as a last resort: the host keys the model the room really runs, which is its live session's model, else what a new conversation of the invoking account would run. A model without a Fast route, or null, stores nothing and the event records it as unavailable. fastStatus(ref, sender, active) reads the same per-model state. restart(ref) holds the restart (bounded) until the row is written. A failed write is logged; the command still ran. Shared API 6 removed the conversation-local setFast(ref, on), and status(ref) and fastStatus(ref, sender) no longer report fastAvailable
55registerHook({ name: 'brain.run.beforeSettle' }) returning patch.boundary { entries, continue } (minimal use: return one { kind: 'message', text })Once per settle attempt inside the running turn, after the last assistant message and before the run settles, in the turn's own scope; also in sub-agents and delegated runsNo consumer yet; registry Todo (note plus continue) and registry LSP (note only) are planned after the core release that carries the seamRequires mutates:['runBoundary'] (operator consent); without the grant the patch is dropped and logged (the spawner bus carries no audit sink). No subscriber means no handler is installed and the run settles exactly as before. At most 4 entries, notes clipped to 1 KiB, records to 4 KiB JSON; notes are model-visible only and never drawn in the transcript, records are invisible to both. continue needs an accepted message, is ignored unless the run ended completed, and is capped at 1 per run (a granted continuation is one more full-context agent run, up to one step ceiling of steps, billed to the same origin). Throws and the 3 s budget fail open; not filtered by account grants. An abort during a slow hook can still leave one small note. The cap is per run, not per session: every automatic run (a sub-agent result, a scheduled turn, a goal turn) gets its own continuation. A pause between the note and settlement resumes with one extra model request, like any trailing chat event
56manifest.service, ctx.serviceOne plugin-owned Node process independent of daemon lifetime; installed/updated at authoritative boot and root refreshSites declares its worker for proxy Sites, Project previews and retained-address maintenance; Chatbot declares its durable visitor intake. Authoritative registration prepares the projection before probe-gated cutoverUser plugins only; absent declaration, runner, or non-authoritative process receives null. Fixed 512M/64-task sandbox, preflighted restart, one socket. The live read-only ctx.service.trustProxy projects core policy; address resolution still calls src/api/clientIp.ts. Explicit elowen proxy apply --plugin-service-hook chatbot/v2 probes socket ownership and /_elowen/service/health before selecting the service on the instance vhost. Without that option hooks retain daemon routing. Disable/failed load keeps the process; uninstall removes it before data deletion
57manifest.liveConfig: true, registerConfigValidation(fn), registerConfigChanged(fn)Apply a saved plugin configuration without restarting the daemonSites proxy policyThe loader requires explicit opt-in. One callback per plugin reads a pinned ctx.config throughout its asynchronous application; saves are serialized. Outside that callback, existing daemon and runner contexts read current persisted configuration through the host ConfigStore reader, with one store read per config access. An optional, pure synchronous validation callback receives a frozen candidate and returns null or a bounded rejection reason. Enabled live plugins must have a registry available before saving; invalid candidates return HTTP 400 without any write, and unavailable validation returns 503 without saving. Validation runs again after a CAS retry. Persistence remains the CAS commit point. A failed callback returns HTTP 503 with masked canonical values and applied:false, never success. Disabled plugins save desired configuration without applying it. Non-opted-in plugins keep their daemon-restart path

| 58 | registerDesktopProvider(provider) with provides.desktop: true | Standard desktop routes and a display-keyed daemon controller; UI API 35 shared control/viewer and API 36 shared DesktopPreview | Registry Browser and bundled Sandbox supply the host byte channel and action events | Missing producer is unavailable. Providers throw DesktopAccessError from elowen-plugin-shared/errors for expected admission refusals, for example throw new DesktopAccessError(409, 'Desktop admission unavailable') in Sandbox during environment operations. It carries a 401/403/409/503 status and optional session_missing or session_not_ready reason; core returns that refusal and logs it locally at INFO. Sandbox uses this only for unavailable admission, changed desktop identity, invalid session/operation and Project scope refusals; probe, protocol and start failures remain ordinary errors. Ordinary errors remain 500 provider failures, and this classification adds no guest request or retry. Optional bounded, authorized prepare runs only before agent and stream/ticket admission; Sandbox ensures only for agent admission, identified by the supplied Project id and command kind; a ready takeover rechecks authority without starting and can interrupt input, while its cold ensure uses the same queue without holding it during the human wait. Viewer admission observes a stored ready desktop. Agent execute, viewer preparation and member stop share the existing Project queue; stop fences active views and never becomes an agent operation. Core checks every input message, private claimant proofs and current permissions; 4 MiB transport high-water, 15 s admission/tickets, 5 s live recheck. Managed streams require native VncAuth; runner commands use active-turn RPC and confirmed cancellation. The host-error envelope preserves a validated error code and explicit action_delivered evidence through its required nullable details; absent evidence stays unknown. An agent operation of kind takeover asks a person for control and runs its input only after control returns (Sandbox handover); every control change is logged without secrets. Action events: the provider's subscribe(sessionId, send) receives send(payload, 'action'); Sandbox sends two per agent operation with the same id: {id, status:'started', action, label?, text?, key?, direction?, point?, step?} before the guest call, then {id, status:'done'\|'failed', action, label?, point?, message?, step?}. action is look, screenshot, click, type, key, scroll, drag, hover, wait or activate; label on started is the target as written (ref, selector, app or window title) and on done/failed, when the guest reported the element the input hit, that element's plain display name (its role when the name is empty), at most 128 characters, never quoted or prefixed; text is a preview of typed, filled or awaited text: at most 60 UTF-16 units, and only when the daemon cut it does it end with … (it never splits a surrogate pair), so a viewer must not infer a cut from the length; point is {x,y} as fractions 0..1 of the framebuffer (coordinates at start, where the input landed at the end); message accompanies failed; step is {index,total} inside a batch, whose steps use ids <requestId>:<n>. Events are never persisted or logged and passive stills send none; a producer that sends only {action, target} (Browser) stays valid. A batch whose step throws after its guest call began (protocol, epoch or observation failure, transport error) does not lose its history: the result is success:false with error_type the error's code (desktop_error when it has none), the steps already run as reported, the failing step as failed with the error's message and code, the rest skipped, action_delivered:null, and a message saying that step's delivery is unknown, so it must be checked and not replayed. Ordering: Sandbox queues the agent execute operations that reach its entry from one process, which means one conversation's turn, in call order, so parallel Desktop calls of one model turn act in the order they were made. Calls from different conversations or runners are not ordered against each other; core serializes them per display, in the order each reaches its own queue. takeover and passive stills are never queued here | | 59 | registerSkillPostTurnReviewer(reviewer) | After a turn settles (settleTurn), detached from the turn: reviewer.run({ userId, userText, assistantText, trajectory, inference, budget, reviewEveryToolCalls, signal, authorize }) | The skill learning loop reviewer (creates and improves skills from the finished exchange) | One reviewer per daemon: a second registration is refused with a warning and the first wins. Absent: nothing runs and the web hides the switch (skillLearningAvailable in the account settings response). Armed only for a clean owner-chat turn (web/cli surface; never internal, scheduled, automated, steered, compacted or without a completed final answer) of an account with autoLearnSkills on, the CreateSkill tool grant and the reviewer plugin's grant (administrators included). trajectory lists the turn's SUCCESSFUL tool calls, nested code-mode tools.* calls included; argsSummary is one salient argument, redacted before it is cut to 200 characters (best-effort pattern redaction, not a guarantee), and skillName names a successfully loaded skill. run executes in the author's policy scope with no turn-bound callbacks, so ctx.userConfig() resolves to the author. Bounded to 120 s by aborting signal (pass it to inference.decide; the host cannot stop a reviewer that ignores it). authorize() is a synchronous fresh check (account exists, autoLearnSkills still on, grants and the same reviewer still in place) to call before EVERY write. budget caps the skill write operations (create or improve) the review may propose, not tool calls (runtime limit skillReviewMaxOps, 0–6); reviewEveryToolCalls is the operator's cadence counted across exchanges (runtime limit skillReviewEveryToolCalls, 1–200), so a reviewer counts the author's tool calls and reviews once they reach it instead of keeping its own setting; core passes only exchanges with at least skillReviewMinToolCalls successful calls (1–200), so trajectory is never shorter than that; the inference cost is billed to the author as internal usage, and inference.decide rejects without a request once the author loses the model grant while the review waits. Never awaited; a throw or rejection is logged as brain.skill_review_failed, and the abort deadline as brain.skill_review_timeout, through the structured logger with code-only reporting. Neither can skip the turn's other settlement effects | | 60 | web.chatDock (UI API 42) | One view per plugin that the chat mounts once per surface for the conversation on screen, after the loaded turns, with { plugin, project, sessionId, narration, pendingInput }; project is the conversation's execution target from the status (null without one) | Bundled Sandbox: the running desktop of the conversation's managed Project; registry Browser: the live browser sessions this conversation opened, filtered from the same sessions read as Account → Browser and drawn only with a sessionId | Drawn from live state, never from the transcript, so it does not depend on which turns are loaded: a reload or paging through history never hides it. Whether it survives a daemon restart is the producer's state, not the slot's: Sandbox's Project desktop keeps running across one and its tile returns, while Browser closes every session when the daemon stops and at boot, so its tile goes with them. Without a contribution nothing is drawn; a view returns null when it has nothing to show, and one that throws is replaced by the host crash notice without touching the transcript or sibling views. Sandbox keeps its desktop controls with a null sessionId on purpose: the takeover belongs to the Project desktop, not to the conversation's plugin routes, so a person watching a sub-agent can still answer its handover. It re-renders with the streamed narration, so keep it cheap; the bundle owns its own polling | | 61 | beginGuestUpload(files, { path, size, expectedVersion, subject }) and uploadGuestFile(files, input, body) from elowen/dist/plugins/guestUpload.js | The one chunked write of bytes into a managed guest file (write-begin, write-chunk, write-commit, write-abort), for any caller that holds an authorized guest file call | Core chat and chatbot uploads, ctx.projectImageFiles() writes and stored tool output; registry Editor and OneDrive use the published guest upload helpers | A published module, not a PluginContext method: it holds no authority. files is the caller's own operation channel, for a plugin its projectFiles binding with its generation, workspace and startIfNeeded, so the provider still authorizes every operation. Minimal use: await uploadGuestFile(files, { path, size: bytes.length, expectedVersion: null, subject: name }, [bytes]). expectedVersion: null is create-only, a version replaces exactly that content and is checked again at commit. The transport resolves the target and builds missing parent directories, so callers do not stat or mkdir first. beginGuestUpload returns path, received(), broken(), append(), commit() and abort(); the declared size stays with the caller. append regroups any pieces into the transport's exact chunks, holds at most one chunk and drops a trailing partial chunk when a stream ends early; received() counts acknowledged bytes and the next append resumes there. A failed chunk marks the upload broken(). Begin and commit throw the transport's error with its code (already_exists, version_conflict); commit refuses a byte count other than the declared size. uploadGuestFile aborts on any failure and reports a failed abort alongside the first error. Collision naming, size limits and HTTP mapping stay with the caller. Requires the core release that ships the module (requiresCore) | | 62 | openHostUpload({ path, fd }, { size, subject }) from elowen/dist/plugins/hostUpload.js | The one staged write of bytes into a host file: a part written across any number of requests and published under its real name only when complete | Core chat and platform uploads into host Projects and ctx.projectImageFiles() host writes; registry Editor uses the published host upload helper | The host twin of row 61, and like it a published module that holds no authority: the caller claims the part itself, create-only (wx), where its own path guard allows, and passes the open descriptor, which is closed here. Minimal use: const upload = openHostUpload({ path: part, fd: openSync(part, 'wx') }, { size, subject: name }); await upload.append(body); closeSync(upload.publish(target, 'create'));. The returned handle exposes received(), broken(), append(), publish() and abort(); the part path and declared size stay with the caller. The part's device and inode are recorded at the claim; each append opens it with O_NOFOLLOW, refuses another file or one that no longer holds exactly received() bytes, writes there and closes it, so no descriptor outlives a request. size: null accepts any length. publish(target, 'create') hard-links the part and throws EEXIST when the name is taken, so a collision walk tries the next name; 'replace' renames it over the target, explicitly. Both check the published name through a returned read descriptor (the caller closes it): a different file or byte count marks broken() and takes a created name back before throwing, while a replacement has already happened and only throws. On a failed publication the caller calls abort(). Reasons name an error code, never a path. A failed write or a swapped or cut-short part marks broken(); a stream that broke off resumes. abort() removes the part only while it is still this upload's own regular file, so a swapped part, somebody else's file by then, is left alone. Core's ctx.projectImageFiles() host writes stage through it too; if both writing and aborting fail, the rejection is an AggregateError retaining both errors and naming both in its message. A restart forgets the upload and leaves its part, which the caller's listing should hide. Requires the core release that ships the module (requiresCore) | | 63 | agentShellEnvironment() | Merged into the environment of every process launched for an agent, at launch time inside the turn | Bundled Sandbox (direct, confined and managed launches) and the Terminal plugin's no-Sandbox fallback | Returns { ELOWEN_AGENT_SHELL: 'account:<id>' } only for the account's own unrestricted chat, else { ELOWEN_AGENT_SHELL: 'restricted' }, also outside a turn. The elowen CLI reads it before resolving any credential: restricted refuses every daemon command and names the ElowenApi tool; account:<id> uses a cached login only after GET /auth/me proves it is that account. A launch path that omits it leaves a shell the CLI treats as a person's terminal, so every new launch path must merge it. It is a CLI guardrail, not an authority: no API credential is ever put in an agent environment. Cost is one policy read per launch | | 64 | Tool result details.modelObservation: { key } | Code mode retains the latest declared content for each cell-local key and drains it at yield or completion | Bundled Sandbox's Desktop, keyed by Project | Runtime declaration validation: plugins/code-mode/src/protocol/observation.ts; producers declare plain metadata without importing another plugin's private types. Minimal result: { content: [{ type: 'text', text: 'current state' }], details: { modelObservation: { key: 'producer:resource' } } }. Undeclared results remain script-owned. Declaration and media-admission errors add a visible cell diagnostic without changing or rejecting the completed nested tool result. Maximum 32 keys per drain, 16 text/image blocks per declaration, 8 MiB text characters per block, above Desktop's existing 4 MiB plus 64 KiB aggregate transport bound for batches and final snapshots. Images use the existing processor and reserve the shared 20 MiB media limit when observed, before drain; replacing a key releases its old reservation. The formatter measures script demand after deduplication and reserves min(observationDemand, max(ceil(budget/2), budget-scriptDemand)); small keys return unused shares to larger keys. It uses the existing explicit truncation/omission markers; markers and the status header remain outside the content budget. Exact text(block.text) and image(block) forwarding is deduplicated against canonical content. Metadata carries only the key, never a second snapshot or image copy. Draining consumes declarations; another yield without new results does not replay them. This does not cancel calls already queued inside the same script. No registration, settings or Desktop-specific logic in code mode |

| 65 | host.voice().transcribe({ source, audio, mimeType }) and host.voice().speak({ source, text }), plus thirdPartyCall({ accountUserId, language, brief }) | Transcribe incoming voice notes and speak replies through core speech for the plugin's registered platform adapter; bridge third-party call media through the same Live engine | Discord, Telegram and WhatsApp adapters; registry Twilio | Minimal use: declare capabilities.reads: ['voice'] in the manifest, register the platform adapter, then call await ctx.host.voice().speak({ source, text: 'Hello' }). The accessor and daemon wiring are required. Missing capability or an invalid source platform throws; voice off, an unlinked sender, no voice grant or a reached spend limit returns the speech service's typed { refused: 'disabled' \| 'unlinked' \| 'not_granted' \| 'spend_limit' }. Provider and persistence failures reject. SessionSource must name the calling plugin's registered platform and a genuinely linked sender; IdentityResolver.forPlatformTurn().linkedUserId supplies authority, never automation or actAsUserId. Every account, including administrators, needs users.can_use_voice, enabled voice and AccountSpendGate admission. Transcription uses the configured OpenAI model; speech parses Markdown, bounds spoken text to 4000 UTF-16 units with a cs/sk/en notice, and returns audio/ogg; codecs=opus from the shared core voice connector's Realtime path and WASM Opus encoder. Browser calls use GPT-Live with client delegation to BrainService. thirdPartyCall admits before dialing and returns a handle with attach(media), hear(pcm), played(ms), end(reason) and result; the call itself has no written history, tools, rings, subscriptions or BrainService delegation; to give the call a conversation, pass the returned callId to host.conversations().send (row 69), which attaches core's own record of the finished call. PCM is mono PCM16LE 24 kHz with VOICE_AUDIO_FRAME_MAX_BYTES bounds; plugins convert their wire formats. Declare userGrantable:true; a real human paying account must explicitly grant this plugin and hold can_use_voice. A non-grantable caller throws; missing account/grant returns not_granted. Daemon only; runners throw. Expected call refusals are not_granted, disabled, spend_limit, call_active, provider_unavailable, daemon_restart. An unattached call ends after 120 seconds. Live opens on attach and greets immediately; the account pays Live duration. After media and usage teardown, result never rejects and returns { callId, reason, seconds, transcript }, including daemon_restart on restart. Core performs no outcome inference; the plugin owns interpretation and may give the transcript to its agent through row 69. Cost and origin are stamped as the paying account and platform:<plugin>. No caller adds no call or cost. Both operations bill the linked account and platformOrigin through VoiceUsageSink. Credentials, pricing and provider validation remain in core; no platform-owned provider key, subprocess or native addon is needed |

| 66 | retiredConfigKeys | Explicit removal of a plugin's retired instance-config properties before registration in the authoritative daemon | Discord and Telegram retire sec_voice, voiceProvider, stt, sttModel, tts, ttsModel, ttsVoice; Image Gen and Image Edit declare ["size"] in their registry manifests | Minimal use: "retiredConfigKeys": ["stt", "tts"]. At most 64 unique, non-empty, trimmed keys of at most 128 characters; overlap with configSchema is invalid. Core reads the stored slice, deletes only present listed keys and persists once through ConfigStore.update against the snapshot revision, rejecting concurrent config changes; each removal logs its key without values with reporting: 'local-only'. No list, an empty list, no present keys or a runner means no write or removal log. Other properties, including undeclared values and secrets, other plugins and per-account config are untouched. Reloads are idempotent. Cost is a bounded key scan and one config write when needed; failed persistence fails plugin loading |

| 67 | host.usage().reportCost({ accountUserId, eventId, item, costMicrousd }) | Plugin-reported external costs through the existing usage receipt and rollups | Registry Twilio after a billed call | Declare reads:['usage'] and userGrantable:true, then await ctx.host.usage().reportCost({ accountUserId, eventId: callSid, item: 'pstn-call', costMicrousd: 120000 }). Daemon only; missing capability, non-grantable caller, a non-human account or missing explicit plugin grant throws. Core stamps provider as the plugin, model as item, origin platform:<plugin>, current time and zero tokens; the receipt identity is plugin:<plugin>:<eventId>. Repeated event IDs add nothing. Cost must be a nonnegative safe integer in micro-USD; invalid input or storage failure rejects, with persistence failures marking the existing account usage health guard. VoiceUsageSink.recordCost writes voice_usage_events, usage_by_provider_day and usage_by_origin atomically, so the monthly account limit sees the charge. There is no new table, price registry or parallel rollup. Plugins own pricing and report once when charged; no mid-call metering. No report adds no cost. One receipt and two existing rollup writes per unique event |

| 68 | configSchema / userConfigSchema field display.placement and UI API 51 settings controls | Opt-in editable-field placement and the shared settings form at render time | Teams rolePolicies in People; Chatbot and Teams shared settings; core account and plugin detail | Minimal use: display: { "placement": "pluginForm" }. Omission keeps the generic editor; sections and unknown placement values are rejected. The field remains in config validation and revision-safe saving, with no authorization change. Empty disclosures are omitted while intentional information cards remain. API 51 publishes typed PluginConfigEditorProps, PluginConfigEditorDetail, InputProps.unit and LimitSliderRowProps; localization is internal, fold keys persist, and dependent bundles require 51 in manifest and registration. No new request or storage; one local schema scan. Intentional duplicate surfaces are preserved unless placement is explicitly opted in |

| 69 | host.conversations().send({ accountUserId, key, title, text, display?, call?: { callId } }) returning { sessionId, completed } | One owner-chat conversation per (account, plugin, key) in a granted account, opened or continued by an agent turn the plugin starts, for example one per meeting a plugin calls about | Registry Meeting Confirm, one conversation per meeting and a new turn per placed call attempt | Minimal use: declare capabilities.mutates: ['conversations'] (operator consent) and userGrantable: true, then const { completed } = await ctx.host.conversations().send({ accountUserId, key: meetingId, title, text }). Core derives the id brain-<uid>-plugin-<plugin>-<sha256(plugin, key)[:24]>, creates the row on the first send through BrainService.sendOwnedConversation (the path a Cronjob dedicated origin uses), names it title once (clamped to 120 characters, never renamed later) and runs every later send as a new turn in the same conversation, so it lists, titles and resumes like any other conversation of that account. Every send rechecks that the account is a human that explicitly granted the plugin (administrators included) and throws not_granted otherwise; a missing capability or a non-grantable plugin throws; daemon only. Only the owning plugin can address its ids: another plugin, a Cronjob origin and another account are refused. The turn is automation: 'scheduled', surface <plugin>, origin platform:<plugin> pinned for billing in usage_by_origin, and runs under the account's own model, tool and exec grants and spend gate. display (default text) is what the transcript shows; text is what the model reads. call.callId attaches a VoiceCallMessage card and gives the model the transcript, marked as untrusted data, only from core's own in-memory record of a thirdPartyCall this account placed, kept 6 hours, and only for a plugin in the chain that placed it: the plugin that dialed and every plugin whose ctx.control(...) call led there (Meeting Confirm calling Twilio's phoneCalls). An unknown, foreign or expired id, another plugin of the same account, or a daemon restart since the call just yields no card. The turn neither arms nor clears voice ring-back. completed settles on this send's own turn: it resolves to that turn's final text, and rejects when the conversation is busy with another turn (ConversationBusyError; the message is refused, never queued into it), when the run fails (including a provider error PI settles without throwing) or is stopped, or when the conversation is unavailable. A daemon restart ends the process before completed settles, so a plugin that waits on it keeps its own durable deadline; the plugin's tools run inside it and can read ctx.currentSessionId() to check that a request belongs to the conversation they opened. No caller means no conversation and no cost. Cost is one full agent turn per send, billed to the account |

| 70 | ctx.logger.info/warn/error and PluginLogger | Plugin-owned local diagnostics and optional bounded problem reports | Bundled Terminal cancellation and registry Meeting Confirm exhausted delivery | Minimal use: ctx.logger.warn('Delivery failed', undefined, { code: 'meeting-confirm.delivery_exhausted', reporting: 'code-only' }); use a declared plugin-name.event_name code in PROBLEM_CODES, or { reporting: 'local-only' } for an operator-only condition. Warning/error types reuse core Logger and require metadata; info stays optional. Calls act immediately, with ownership stamped by the registry. Reporting disabled or local-only means no remote report; untyped JavaScript without valid metadata keeps its original local log and reports plugin.reporting_invalid without text when reporting is enabled. Code-only exports counts, never diagnostic prose. Costs are one local log write and bounded aggregation; no registration, extra permission or UI-kit/shared-package logger copy is needed |

| 71 | manifest.web.operationDock, UI registration operationDock, hooks.useOperationDock().open(id, pluginName) | Contribute durable own UI operations to the shared shell dock through the existing plugin bundle and SSE | Registry Cronjob manual runs | Minimal use: declare web.operationDock: { desktopOnly: true }, register a PluginOperationDockSource with eventKind, list/get/decodeEvent and durable acknowledge, then pass the accepted run id and plugin name to open. Normalize validated plugin replies to PluginDockOperation, including translated status label and exact history URL. The host filters account, UI initiation and acknowledgement; no admin bypass. One bounded React Query list per source/account/locale and one shared plugin-event subscription, refreshed on reconnect; pushed rows beat in-flight snapshots. No declaration means no request or item, and desktop-only sources do not load at mobile or unknown width. No second store, polling, per-run connection or shell feature branch. See Cronjob manual-run source below. Unreleased consumers must wait for the core release carrying this contribution and declare that minimum before publication |

| 72 | Tool result details.blocks (message blocks): return { content, details: { blocks: [{ kind: 'item', key, match?, title, subtitle?, chips?, image? }] } }, typed as ToolMessageBlockInput from elowen/plugin-api | The host validates the blocks and stores their images when a plugin tool call returns, after tools.call.after (normalizeToolResultBlocks in src/brain/messageBlocks.ts, wired in gateToolAccess). When the run ends (PI agent_end), core keeps the blocks whose match keys appear in the final assistant text, appends them as one off-model elowen.message-blocks journal entry after that message (src/brain/session/messageBlocksExtension.ts), publishes a live blocks event with that entry's id, and every reload renders the same entry under the message | Registry Parts: product cards from PartsShopSearch (photo, price, availability) and OE-number cards from PartsVin | Without details.blocks nothing is stored, sent or drawn. item is the only kind: key ≤ 64 (first block per key wins), title ≤ 120, subtitle ≤ 200, ≤ 6 chips of ≤ 40 characters with tone neutral, positive, warning, danger or accent, ≤ 4 match keys compared case-insensitively without spaces, -, / and . (a key shorter than 4 such characters never matches; a block without match is always shown). image is { data: base64, mimeType }, png/jpeg/gif/webp sniffed from the bytes, ≤ 1 MiB, ≤ 4 MiB per result, stored in the chat-image store and served only to the conversation's owner; an unusable image drops only the picture. At most 12 blocks per result and 12 shown per message. Texts are plain text: control characters stripped, clipped, never rendered as HTML or Markdown. Invalid blocks are dropped with a plugin warning; the tool result never fails because of them. Ignored on failed results, on code-mode nested calls, and before a mid-turn steer. Details are host-only and the entry is never in context: no token or cache cost. Web draws a card grid; CLI, export and platform adapters draw nothing, since the reply text already names every shown item. Set requiresCore to the first release that ships it |

host.elowenCli(), host.prompts(), host.relayClient(), and host.push() are separate public host accessors. A zero in-repository caller count is not proof that an external plugin does not use a published seam.

For every changing value, use the placement and cost rule from the opening: registerTurnContext(..., { placement: "after-user" }) for per-turn context, never a system-prompt fragment. The system prompt is the cached prefix.

The summary route obtains authorized rows from ProjectService.authorizedRows, sharing the canonical account visibility and order without filesystem projection. Its one five-second registry lookup also supplies the Sandbox control for managed branches; registry failure is logged once and never triggers another unbounded registry wait. Output follows the shared ProjectSummary contract and accepts only ProjectIndicatorTone own keys. GitHub and OneDrive remain real indicator producers; with no contributions, the indicators array is empty.

Browser contract history

The current value is exported as PLUGIN_UI_API_VERSION by the UI kit and mirrored with a literal type in the host runtime. A bundle's manifest and registration requirements must not exceed that host ceiling. This section owns the history; source comments and package READMEs link here. Historical additions can later be withdrawn as described below.

  • UI API 17 publishes Calendar, backed by the host's date engine.
  • UI API 18 publishes the host's Todo-card preview rule.
  • UI API 19 publishes SectionDeck and DeckNavigation for responsive settings overlays.
  • UI API 21 requires the viewer locale for buildUsageSummary.
  • UI API 22 publishes Textarea and retires unused component variants and SettingsRow.icon.
  • UI API 23 publishes utils.impersonateUser and the canonical localized utils.formatUsd.
  • UI API 24 publishes CardGrid and CardGridItem for the host's card-register layout.
  • UI API 25 publishes SelectionActionBar and CardSelectCheckbox for bulk selection.
  • UI API 27 publishes RailSection, RailList, RailRow, RailDot and ShimmerText.
  • UI API 28 publishes TaskList and TaskRow for Todo cards and rails.
  • UI API 29 adds toast promise and dismissKey, hooks.useAutosaveToast and autosave editing; toast(message, tone) retains its signature.
  • UI API 30 adds DeckNavigation.badge, PageToolbar.filterActions and hooks.useInvalidatePluginUi; the toolbar panel is then called Options.
  • UI API 31 adds shared workflow labels and counts.
  • UI API 32 publishes cardTasks and cardTasksAddressable for Todo-card projection.
  • UI API 33 retires the unused LiveTail preview and generic mcpServers config field; MCP management belongs to the MCP plugin.
  • UI API 34 publishes the canonical child and detached-process rail visibility selectors.
  • UI API 36 publishes DesktopPreview and useDesktopPreviewState for Browser and Sandbox.
  • UI API 38 adds one viewport-gated still scheduler; suspended streams retain their session snapshot.
  • UI API 39 moves narration and pending questions into DesktopPreview, removing its notices slot and context.
  • UI API 48 adds the optional Project-row nameIndicator projection described in catalog row 41.
  • UI API 49 withdraws useResetUsage and aggregate speed/provenance from complete provider statistics.
  • UI API 51 centralizes plugin editor localization and publishes Input.unit and LimitSliderRow, as described in the configuration section and catalog row 68.
  • UI API 53 renames the canonical filter/action toolbar panel Actions.

UI API 40 publishes components.RowPicker, the host's single-choice dropdown (an anchored combobox with a search box above eight rows, RowPickerProps and RowPickerItem in the UI kit). A pick calls onChange and closes it with no Save step; notice and onOpenChange cover a lazily loaded catalog, and onClosed runs after a pick, with focus on the trigger. ManageSelectionModal no longer has a single mode: a bundle that opened it for one choice uses RowPicker, and ChoiceField, BrainModelField, ModelCatalogField, BackendPicker and ExecutorPicker are anchored dropdowns too (they no longer take subtitle; ExecutorPicker no longer takes moreLabel or limit). A bundle that needs it sets requiresApiVersion: 40.

UI API 41 adds two optional props; a bundle that passes them sets requiresApiVersion: 41, so an older host refuses it before mounting instead of silently drawing the large strip. ResourceMeters takes size: 'compact', the same snapshot on one caption line (a 16px ring, the label and the percentage per resource, exact figures in the tooltip and accessible text); the default stays the three 52px rings. DesktopPreview takes caption, what sits under the collapsed tile beside the claim control in place of identity; the expanded controls row keeps identity. Absent caption, the collapsed tile shows identity as before. Sandbox's chat desktop tile passes its Project's compact meters as the caption, from the same useProjectUsageMetrics cache the Projects register reads.

UI API 42 adds the chatDock registration with manifest web.chatDock (catalog row 60). A bundle that registers it sets requiresApiVersion: 42; an older host never mounts the slot. Sandbox and registry Browser are its consumers.

UI API 43 removes the chatArtifacts registration, together with the core ctx.chatArtifacts seam that fed it and the inline_artifact stream event. An artifact rendered only while the tool turn it was anchored to was loaded, so a reload or a daemon restart could hide something still running; live state in the chat is a chatDock view, drawn from the plugin's own state. A bundle that still registers chatArtifacts loads, and the key is ignored. Core migration 46 drops the brain_inline_artifacts table.

UI API 54 also publishes components.AdaptiveBrandMark, the same brand renderer used by core's ModelIcon. Its AdaptiveBrandMarkProps are declared once in the UI kit and consumed by the host and bundles. Minimal use: <AdaptiveBrandMark src="/api/plugins/sandbox/icon" size={12} monochrome />. Monochrome masks inherit local text color, so black SVG icons remain visible on light and OLED surfaces; the default mode retains a colored image. An omitted or empty alt makes the mark decorative, and an absent icon is handled by the caller omitting the component. Rendering loads only the supplied asset, with no polling, subscriptions or extra API calls. Changelog pills are the plugin consumer. A bundle using it declares requiresApiVersion: 54; older hosts refuse it before mounting.

Core's DockItem projection uses the same shell through each operation's title and renderProgress presentation; the shell's controls and inspection are independent of operation kind. Source adapters keep ownership, transport and durable acknowledgement. Marketplace install/repair and memory-maintenance adapters are administrator-only at the shell's desktop breakpoint, with no extra browser store or connection. Plugin environment calls below are unchanged.

UI API 54 publishes hooks.useOperationDock(): call const dock = hooks.useOperationDock(); dock.open(operation.id) after an authenticated UI environment action. open(id) means track and show in the minimized dock; it does not open a progress dialog or move focus. The person opens progress by clicking the dock icon or an overflow-list entry. Start and completion are announced in a polite live region, and a failed operation keeps a destructive-tone alert icon until explicitly acknowledged, including an alert on +N when the item is in overflow. The hook signature and UI API version remain unchanged; showProgress is a shell-only action, not a plugin contract. dock.operations supplies the same own, UI-initiated operation rows for Project busy state. The host mounts one progress view and dock in the shell; bundles do not mount progress dialogs. Sandbox snapshot creation, environment lifecycle, resource/network autosave and snapshot deletion are the consumers. The source is Sandbox's authenticated list plus pushed durable rows and server acknowledgement, not browser storage. No Sandbox means no environment items or reads. There is one list read per page load/reconnect, one single-operation seed read on opening progress and no per-frame requests. Environment history is bounded to twenty returned items and unseen settled results from the last 24 hours. Update requests use the existing coordinator queue, journal and report through the same host dock. The host also groups own hand-started conversations at desktop widths using the existing personal-session query and conversation invalidation; this is core-owned and does not change the plugin hook. All item kinds share the typed DockItem model and the reserved shell footer described in WEB.md. components.OperationProgressDialog is removed. A bundle needing this hook declares requiresApiVersion: 54; an older host refuses it before mounting, and a bundle supporting older hosts must feature-detect the hook.

UI API 52 lets components.TimeSeriesChart take height: 'fill', which draws the plot to the height its flex column parent leaves over, with a 240px floor. A bundle that passes it sets requiresApiVersion: 52, and an older host refuses the bundle before mounting. Absent or numeric height is unchanged. Sandbox's load-history dialog is the consumer: one combined chart fills the modal body beneath its controls. See WEB.md for the layout contract.

UI API 50 also publishes hooks.useMobileViewport(): boolean | undefined, the host's existing phone-breakpoint hook. It is undefined during SSR and the first render, then updates after measurement and on breakpoint changes. Wait for a boolean before mounting a viewport-specific surface; do not implement a local media query. Sandbox history uses it for Settings' size="page" and explicit center/fullscreen Modal presentation, and enables its history query only after measurement. Each consumer has one subscription with unmount cleanup and no polling or network cost. Older hosts refuse bundles requiring API 50. See WEB.md for the runtime contract.

UI API 50 publishes typed resource actions, instant ranges and numeric time axes through the existing runtime and kit. A bundle requiring them sets requiresApiVersion: 50; older hosts refuse it before mounting. PluginProjectRowMetric.action takes { label: string; onSelect: () => void }, with Project-qualified localized copy. components.DateRangeFilter has discriminated DateRangeFilterProps: absent/day precision preserves calendar dates; instant precision takes InstantRange, optional InstantRangePreset[], and canonical ISO min/max constraints. utils.instantRangeBounds({ from, to }, constraints?) validates complete fixed custom instants to InstantRangeBounds, with explicit errors. Live presets are sent as window=1h|6h|24h|7d|30d, mutually exclusive with explicit from/to; Sandbox resolves them against its own current time and includes the current partial bucket. components.TimeSeriesChart accepts optional TimeSeriesTimeAxis through timeAxis: { dataKey, domain, tickFormat, tooltipFormat }. Time mode uses unique ascending epoch-millisecond keys, linear disconnected lines and Recharts keyboard accessibility; category behavior and the lazy boundary remain. Sandbox's useUsageHistoryActions opens the same ProjectUsageHistoryModal from Project, Account-desktop and chat-desktop meters; its history hook is the consumer and owns data authorization, query bounds, retained states and dialog selection. Core introduces no plugin-specific route or polling. Minimal composition, DST policy, absent behavior, limits, cost and accessibility are documented in WEB.md.

UI API 47 adds the optional figure on PluginProjectRowMetric, a short text (four characters at most) drawn in the hole of the Project-card ring instead of the percentage; description then names the unit beneath it and percent keeps setting the arc. Without it the hole shows the percentage. Sandbox's disk speed ring is the consumer.

UI API 46 adds the userStatus registration for web.user panels (catalog row 40). It is a record of components keyed by panel id, with the same props as the panel (plugin, panelId, user, surface: 'user'). PluginUserPanels draws it in the DetailBlock header right after the title, outside the disclosure button, whether the block is open or closed, inside an error boundary that renders nothing on a crash. It draws nothing when the bundle is not loaded, incompatible or registers none. Because it is mounted while the panel is not, it must fetch only what it shows. A bundle that registers it sets requiresApiVersion: 46. Sandbox is the example: EnvironmentStatus shows a green or red dot for the bubblewrap probe from the probe-only route environment/status, so a closed block never runs the HOME size walk.

UI API 45 withdraws every runtime member that no bundled, registry or installed bundle named (the full list is in docs/WEB.md, "Bounded shared UI"), among them ExecutorPicker, BackendPicker, RailMeter, RailSectionHead, hooks.useAutosaveToast, hooks.useConfig and utils.formatCost. The version is a compatibility ceiling and cannot express a removal, so the withdrawal was taken only after every consuming repository was searched; a bundle that still names one of them reads undefined.

UI API 44 publishes utils.localDateTime(timestamp, locale?, seconds?), the host's rendering of a stored timestamp (SQLite UTC or ISO) as an absolute date and time in the viewer's zone, so a bundle never prints a raw UTC database string. Pair it with the already published utils.parseTs and utils.compactElapsed for a relative hint. A bundle that calls it sets requiresApiVersion: 44; on an older host the name is absent. Sandbox's snapshot list in the Project Environment tab is its consumer.

UI API 39 also publishes components.ResourceMeters({ metrics: PluginProjectRowMetrics }). This is the existing Project-card strip, not a plugin renderer. Sandbox's web-src/useProjectUsageMetrics.ts supplies the same batch cache key, polling and generation-sensitive recall to its Projects row contribution and Account Desktops. Both call it with the full authorized Project list; a refused read drops remembered figures while an ordinary failed read retains them marked stale. The row contribution keeps lifecycle status, actions and overlays, and the Account card places the strip below its name and state without adding a per-desktop usage request.

The same ResourceMeters seam accepts up to five metrics. Sandbox contributes CPU, memory, disk, IO and network from one authorized batch. A producer can supply figure with state: 'absolute' for an unlimited rate; it is visible without claiming a percentage. Default rings retain their 52px size and single-line captions on one native horizontal track; narrow containers scroll instead of wrapping. The shared horizontal-overflow observer drives the existing edge fade and focus reveal, and gestures on the whole track stay out of card reorder sensors. With no contribution nothing renders; there is no extra host request. Network history uses the existing chart's right axis for Mb/s and full-point formatting for contemporaneous percentages.

EnvironmentLimits.netMbitPerSec travels through the existing Project resources and Sandbox lifecycle control, as an integer rate in decimal Mb/s with zero unlimited. It never enters the envelope hash. Sandbox's root gateway operation set-network-limit uses the environment-control lock and the existing framed transport/capability catalog, derives and proves its veth peer, and owns a marked IFB only while needed. Before start, the helper projects the current ceiling into the root-owned per-machine 20-network.conf outside creation identity. Its mandatory ExecStartPost imports that same installed helper and calls apply-network-limit, which accepts only the machine and reads the current unit ceiling under the same environment-control lock before proving its new veth. Starts are queued with --no-block so the hook can acquire that lock; the client waits for both registration and an active unit, refusing a failed hook. Start retries and resumes verify both live and network limits before success. Live updates and reconcile use the same shaping code; Stop also runs against an already-stopped envelope, and stop/destroy/unlimited remove the owned IFB. The existing privileged-file byte comparison includes the helper source and its hook renderer, so refresh detects these changes without a new installed file. The plugin performs no shaping without the runtime provider, and a refused root operation remains a failure. This adds no host route or independently stored quota.

API 55 publishes utils.interpolate(template, values), the host's existing i18n helper. Use runtime().utils.interpolate(strings.readersCount, { read: read.size, total: people.length }) in the Changelog reader count. It replaces every named placeholder with the literal string or number, preserves unknown placeholders and dollar sequences, and does not recursively expand inserted values. It runs synchronously during rendering with work proportional to template length and performs no requests. Bundles using it declare requiresApiVersion: 55 in their manifest and registration; older hosts refuse that contribution instead of invoking a local fallback.

API 55 types the existing hooks.useEnvironmentOperation(operationId, projectId?) with EnvironmentOperationFollowState: operation, logTail, loading and loadError. Sandbox ProjectEnvironmentSettings follows one durable operation with a seed read followed by pushed sandbox frames. A null id parks the hook without a read or subscription; its parked state has loading true. Loading ends on the seed result or first matching frame. A seed transport error is loadError, separate from operation.status === 'failed'. projectId optionally scopes the seed request. Cleanup aborts the seed and unsubscribes; there is no polling.

API 55 accepts PluginApiRequestInit in runtime().api(path, init). Pass { method: 'PATCH', json: payload } for a JSON write; json and raw body are mutually exclusive. The host uses its existing JSON builder and authenticated request path, defaults JSON writes to POST, and preserves cancellation, request deadlines, restart recovery and server errors. Calls without json retain RequestInit behavior, including FormData uploads. A JSON payload is serialized once, on the browser request boundary; serialization failures reject the call. Licensing customer edits consume this option through their existing runtime request wrapper. Bundles using json require browser API 55 in both manifest and registration, with no local serializer fallback.

Modal no longer accepts intent. Automatic presentation depends only on overlay depth and viewport: the first roomy overlay is a drawer, nested roomy overlays are centered, and phone overlays are fullscreen. presentation remains the explicit geometry override. WorkspaceDetailRail uses Modal with drawerWidth='default'; PageOverlay uses standsInForPage so editors opened from Settings resolve as first-level drawers. Resolved shape owns the z-band. Focus isolation, dismissal and onClosed are unchanged; no contribution is required for the default rules. The withdrawn plugin runtime hook useQueries is no longer published; useQuery, useMutation and useQueryClient remain on the host's shared query client.

Cronjob manual-run source

The registry Cronjob plugin uses the same durable-row plus private-plugin-event shape as Sandbox. It owns the queue and history and contributes its browser adapter through web.operationDock: { desktopOnly: true } and registration.operationDock. Core contributes authenticated plugin routes, publishEvent, SSE, the existing UI loader and the generic operation dock source above. hooks.useOperationDock().open(runId, 'cronjob') reads the accepted run before SSE and tracks it minimized. The adapter validates raw operations and maps queued/running/done to pending/running/succeeded; failed and interrupted both use failed display rules, with an explicit translated Interrupted label. With no Cronjob plugin there is no read or item.

POST /plugins/cronjob/jobs/:id/run accepts {requestId, expectedRevision} and returns {ok:true, requestId, runId, operation}. The actual account comes from verified auth. As in Sandbox, full/impersonation credentials mark initiator:'ui', verified agent calls mark 'agent', and other credentials mark 'system'. The initiating account is separate from the job owner. Existing owner/instance rules still authorize Run now and run-history reads; even an administrator cannot read another account's dock operations.

GET /plugins/cronjob/api/operations returns {operations}: the newest 100 own UI rows still waiting/running or with an unacknowledged outcome. GET …/operations/:id reads one own operation, including an acknowledged one. POST …/operations/:id/acknowledge settles acknowledgement idempotently on a terminal row, returns {ok:true, acknowledgedAt} with the original durable server timestamp, and refuses unfinished rows with 409. Missing and foreign ids both return 404. The list and detail are projections of p_cronjob_runs, never a second operation table; detailed rows and request dedupe last seven days, matching the existing history retention.

Operation fields are id as the run id, requestId, jobId, jobName, accountUserId, initiator, status, queuedAt, nullable startedAt/finishedAt/acknowledgedAt, nullable preview/errorMessage/skipReason, and historyUrl. Status is queued | running | done | failed | interrupted. The history URL is /p/cronjob?run=<id> and opens the exact retained receipt, including loading and unavailable states. A skipped guard or revoked execution grant maps to failed with its reason; restart interruption remains an error in history, with explicit interruptedAt and dock status interrupted.

After each durable queue/start/settlement/acknowledgement, Cronjob publishes {type:'plugin', plugin:'cronjob', kind:'manual-run', projectId:null, userId:accountUserId, data:{operation}}. The existing SSE recipient boundary delivers it only to the initiator, including for admins, and the shared activity store skips it. Seed again from the authorized list on reload and reconnect, preserving frames received during that read as Sandbox does. Keep the last known state on a failed read; no polling per item, elapsed-time success inference or cancel action. Rendering and opening Cronjob items are desktop-only.

The plugin's useRunCronJob in web-src/jobs.ts validates the accepted id, opens it through the host dock on desktop, provides shared queued/error feedback and refreshes the existing caches once. All Run now buttons call it. Scheduler claims consume the existing JSON request before starting that same journal row. Boot preserves requests still queued on jobs and marks stranded manual rows interrupted immediately, including runs younger than a scheduler tick. All job deletion paths pass through JobStore.save; its post-save callback settles removed waiting runs without cancelling claimed turns. Account removal and boot reconciliation clear a removed initiator's private link while preserving instance run history. No historical initiator is guessed for pre-migration rows. Publish this registry change only after a core release carrying private-event persistence exclusion and the generic plugin dock contribution, and set its minimum core to that assigned release before publication.

Environment operation provenance and acknowledgement

Sandbox's dock reads GET /plugins/sandbox/api/environments/operations without a Project parameter and receives {operations: EnvironmentOperation[]}. POST …/operations/ack accepts exactly {operationId: string} and returns the acknowledged operation. Both use the verified account and fresh Project-management access, with an own successful deletion exception after the Project disappears. The list includes only own UI operations: pending/running rows and unacknowledged outcomes settled within 24 hours, bounded to 20 after access filtering. Host operations and automatic recovery are excluded. Foreign, missing or inaccessible acknowledgement targets answer 404; running targets answer 409; repeated acknowledgement keeps the original timestamp. The operation carries updatedAt, nullable acknowledgedAt, and initiator: 'ui'|'agent'|'system'; acknowledgement republishes the same environment-operation event with outer userId taken from the durable row's owner. The SSE gate delivers that frame only to that authenticated account, including tabs connected after the deleted Project and memberships disappeared; other accounts and administrators receive no private frame. Unacknowledged lifecycle progress keeps its existing Project tenancy. The existing per-environment history retains at most 20 disposable settled rows, so a row already pruned from it cannot reappear in the dock. Retrying a failed request clears its acknowledgement and stamps the retrying caller; live progress exposes the timestamp just committed to the durable row. No second store or polling loop is created.

The authenticated HTTP dispatcher preserves PluginApiAuth.credentialScope as well as the verified agent marker. Only full and impersonation identify interactive login sessions; agent is a turn-bound ElowenApi call, and api/advisor are non-session credentials. An absent origin on a non-HTTP transport is not proof of a UI action. Sandbox derives provenance from these host fields and rejects initiator in the HTTP body. Core's Project create/delete routes and conversation execution-selection route forward their verified provenance through the existing requestEnvironment input; direct Project-tool calls default to agent, internal reconciliation to system. Migration 16 adds provenance and acknowledgement to the existing Sandbox row, preserving old rows as system rather than guessing which were UI requests. When Sandbox is unavailable its API is unavailable; the dock must never invent operations.

All published Sites gateway operations preserve intentional privileged-client shutdown, including daemon pause, as PublishedSitesGatewayStatus.code: 'closed' alongside available: false and the unchanged detail. A consumer checks result.code === 'closed' rather than parsing error text. The classification acts when an injected worker call rejects because its client has closed or paused, including pending handshake and request settlement during detach. Successful replies, helper refusals, deployment validation errors, missing injected transport and unexpected connection loss omit the code; omission carries no shutdown classification. The existing status conversion performs no extra I/O or retries and does not change cleanup. The registry Sites gateway and worker monitor consume this classification when handling daemon shutdown.

Shared desktops

Sandbox uses host.machineRuntimeGateway().connectDesktop(request, options) for the fixed guest connector only. The strict request uses op: 'desktop-connect', lifetime: 'connection', the owned Project generation/disk/spec identity, and /usr/local/bin/node /usr/local/lib/elowen/desktop/connector.mjs at /. The privileged worker revalidates root-owned machine ownership before admission. It removes only the command runtime ceiling, not attach/handshake budgets, capacity limits, execution leases, owner-loss cancellation or settlement proof. Ordinary execution retains its existing deadline. Without the gateway or a packaged desktop, admission fails; there is no host socket fallback or automatic installation.

The chat shows a Project desktop through web.chatDock: Sandbox's view reads the same stored desktop/overview as Account → Desktops, one shared query that never runs a guest command, and draws the tile whenever the conversation runs in a managed Project whose desktop is ready, whatever the transcript has loaded, so a reload or a daemon restart never hides it. It contributes only a ready tile, never absent/loading/error bars, so the chat dock lets text flow beside it exactly like Browser. A conversation outside a managed Project reads no desktop at all. Migration 12 removes Sandbox's legacy sandbox-desktop cards. The desktop provider publishes bounded action captions only after agent GUI admission, never from passive still capture; labels exclude typed text, keys and launch arguments and stop at 128 characters. One subscriber set per session is removed on unsubscribe without closing the display.

Sandbox supplies exactly one desktop per container on the current project-base@19 image, gated by desktopCapability(reference) and its contract in ROOTFS_RECIPES['project-base'].desktop.imageContracts (1920x1080). The guest agent is baked into the image. Only the current pin and guest are supported. Retired materialized disks keep their original provenance for start, restart and boot recovery without archive lookup; they have no admitted desktop. Recreate or restore involving a retired environment or snapshot fails before mutation, including recovered durable intents. No older-result fallback or version-specific parser exists. A Project on an older image gets a desktop only as a new Project; no compatibility or installation path exists. elowen-desktop --json ensure starts the guest desktop on first agent use. status only observes. Stream/ticket preparation observes an already stored ready desktop and refuses a stopped one. The desktop runs until a member stops it or the container stops, including when the daemon restarts. Migration 13 removes desktop intent, idle timestamps and VNC-generation columns, invalidates old X11 observations and fails queued obsolete lifecycle actions. There is no app tracking. Human stop uses the shared confirmation and existing Project operation queue.

The current image carries desktop payload 13. Keyboard destinations are explicit in the shared daemon/guest command contract: type requires target; key requires exactly one of target or window. Examples: {action:"type",text:"2400",target:"e12"} and {action:"key",key:"ctrl+s",window:"Editor"}. Guest argv is type -- 2400 e12, key -- ctrl+a e12, or key --window=Editor -- ctrl+s. The host validates every batch step before dispatch. No previous click or implicit seat focus supplies a destination.

The existing guest Accessibility seam owns numeric, setValue and focused. numeric returns finite CurrentValue/MinimumValue/MaximumValue and the displayed Text, or null for absent Text. Spin-button fill refuses invalid or out-of-range input and requires Text before writing org.a11y.atspi.Value.CurrentValue through D-Bus Properties.Set with a double variant. Within two seconds both Value and the number parsed from nonempty displayed Text must equal the request. The standard Number parser accepts equivalent numeric formatting but does not guess decimal separators or strip units. Clamping, rounding, unparsable Text or inconsistent precision fails, including when Value already matched before the command. Read and snapshots still prefer committed Value for spin buttons; numeric verification reads Text separately through the same bounded text reader. Missing Text or missing or unwritable Value fails without a keyboard fallback; an uncertain write is never repeated. Equal, verified values deliver no input. Plain text fill still verifies exact text and does not implicitly submit.

Commands.keyboard is the one preparation path for text fill, targeted type/key, window shortcuts and focus confirmation after an editable left click. It preserves modal/coverage admission, activates a non-popup destination window and refreshes the AT-SPI identity. An already focused element keeps its caret and selection. Otherwise a real pointer click at its visible centre requests focus, which must be observed within 1.5 seconds. After an explicit editable click it only confirms focus, never clicks or activates again. No GrabFocus call is needed, so providers that reject it remain usable. Popup targets keep their surface identity but verify the same-process non-popup owner through compositor transient links. An override surface without a link requires one focused non-popup window of the same process plus the exact element's focused state. That owner id is fixed for the command; missing, ambiguous, changed or unfocused owners fail. Window shortcuts confirm window focus without choosing an element.

The Input seam checks window focus and saved element address/name/role/state before each contiguous plain-text run, at Tab/Return and paste boundaries, and once at the end. A plain run costs two guards regardless of its length, in addition to command preparation; each guard reads windows twice and performs three direct AT-SPI reads for an element, with no tree traversal. Key chords retain checks between their presses; clipboard transfer is checked on completion. Cancellation is checked for every character and held keys are always released. A final explicit Tab, Return or shortcut may intentionally move focus; later input must still pass the original destination guard, so another destination requires another targeted command. A failed check stops rather than refocusing or retrying input. Delivery remains false before input, true after proven input even if a later check fails, and null for uncertain delivery. A delivered focus click remains true if later input fails. GNOME keyboard delivery and AT-SPI focus reads are separate bus operations, not an atomic compositor transaction; a focus change inside a text run is detected only at its next boundary. Live focus-steal and widget persistence checks remain part of image qualification.

Only the daemon adopts fresh observation identity, at boot reconcile and when an agent ensures the desktop or a stream/ticket observes a stored ready desktop; no surface probes the guest to draw a tile. Account Desktops and the chat dock read the same bounded keyset overview pages of stored state (one useDesktopOverview query); Account does not claim control, but members can stop a desktop from its row or expanded preview after confirmation. Chat retains the shared tile, narration, action captions and human takeover button. Collapsed stills remain passive and never prepare a desktop.

The one model tool is Desktop. It advertises one flat top-level object whose required action picks the variant (so providers that keep only the root properties still show every field, and reason is accepted like on any other tool); the zod discriminated union inside execute is the only validator and answers a bad call with invalid_arguments and one message for the chosen action. It always operates the desktop of the managed Project the conversation runs in (currentAccess().projectRef), which is the one desktop that conversation's chat shows. Outside a managed Project it refuses with managed_project_required and tells the model to switch the conversation first; there is no argument that names another Project. Its actions are look, read, screenshot, click, clickxy, fill, type, key, scroll, hover, drag, wait, activate, batch and handover. target takes a ref or a semantic selector (name="Submit", role="text box" name=City, text~=Hotovo); a ref stays valid while its element exists, also across actions in other windows. activate raises the window whose title matches window (exact, else a unique substring). batch runs 1 to 20 steps (no screenshot, handover or batch), one guest call each, validated before the first and stopped at the first failure. Look maps to guest snapshot and read to guest read (one element's exact text or committed numeric value and states); the other actions keep their argv shapes and strict GUI result validation. Input actions already collect a post-action accessibility snapshot. The tool description leads with consuming snapshot, snapshot_state and verification, rather than requesting another look. In code mode, Desktop declares details.modelObservation through seam 65: the latest canonical content per Project arrives at yield/completion even if the script prints only status. Explicit text(c.text) and image(c) forwarding is deduplicated; the normal text budget, explicit truncation markers and image processing still apply. Already queued calls inside the script cannot be cancelled by this delivery. A batch returns step summaries and the last executed step's observation when available, not every intermediate snapshot. Use a screenshot for visual work or unnamed controls and derive its region from current geometry. Failed verification may follow delivered input: inspect action_delivered and verify current state before repeating an uncertain action. Daemon preflight and the bundled guest share lib/desktopSelectors.mjs; mixed bare text plus predicates is rejected before input. One lib/desktopResult.mjs discriminant covers host and guest results: success, error_type, action_delivered, with null meaning unknown. Ambiguous exact expectations include scope and bounded role/name candidates. A partial startup look succeeds with snapshot_state: incomplete and does not prove completeness. Every input action takes optional expect (a semantic selector, never a ref; bare text means text~=) and expectTimeout (0 to 60 s, default 10); the guest then verifies the result in a fresh snapshot and reports verification without retrying the input. screenshot takes marks to draw element refs on the image. Whole-screen and window screenshots arrive at the largest quarter scale a provider passes to the model unchanged, regions at a quarter-step scale from 0.25 to 4; every result reports its region and scale. type presses Tab for \t and Enter for \n; when they move focus, subsequent input requires a new explicit destination. wait polls for text and is not a sleep. Start apps from Bash with desktop-open <app> [args]; it exits non-zero with a message when the program did not start, and otherwise waits up to 10 s for a new window, then up to 3 s more for its accessibility tree, and prints its title, says its controls are not readable yet, or says no window appeared. A selector that fill uses is narrowed to editable matches, because GTK names an entry after its label. handover is the desktop's use of core's takeover operation kind: Sandbox contributes {tool:'Desktop',kind:'takeover'} with input {action:'look'}, so DesktopRuntime.execute calls controller.requestTakeover (the tile shows the request and offers the claim), waits until the person returns control or the tool call is cancelled, then runs a fresh look under normal agent admission. Before that wait the tool runs one ordinary look under its own request id, which admits, authorizes and prepares the desktop, so the chat tile is there while the person is asked; a refused desktop never waits. Only a person claims control; like Browser's request-takeover, the wait has no timer besides cancellation of the tool call, which stopping the turn triggers. No guest condition, hint or wording names a program: input, paste and table reading are decided by generic AT-SPI roles and interfaces only.

Sandbox's human-only stop route is POST desktop/stop?projectId=…, declared in its manifest with access: 'user', like Browser's close route. The plugin dispatcher sets optional PluginApiAuth.agent: true for verified ElowenApi credentials; the stop handler refuses it, then rechecks membership and scoped Projects, even for admins. Absent agent means an ordinary authenticated caller, not anonymous authority. useDesktopStop is the single mutation/confirmation path for Account and chat: shared danger IconButton on the Account row and DesktopPreview.power in the raised view, translated error toasts, then invalidation of the shared overview and resource snapshot. The shared enlarged view renders navigation, claim/return, More actions with Quality then Speed, then power, using the same controls and ConfirmDialog as Browser. No additional menu renderer is needed.

Declare provides.desktop: true, all five desktop/ API paths and desktop/vnc in provides.wsRoutes, then call ctx.registerDesktopProvider(provider) once. Browser is the account-scoped producer and Sandbox supplies managed Project displays. The optional prepare(sessionId,userId,signal,projectId?,kind?) hook runs before initial agent resolution and authenticated stream/ticket resolution. It is idempotent and bounded, and must authorize the session before any side effect. Agent preparation receives the active Project id and command kind and must reject a different Project before guest execution. Sandbox uses the kind to let a ready handover interrupt active input, while serializing a cold handover's ensure with starts and member stops; the human wait never holds that queue. Passive stream/ticket preparation runs beside the agent queue, so an agent waiting for a human lease cannot block viewer tickets or reconnect tickets. It rechecks authority and refuses an observation if the admitted display identity changed during its probe, including a member stop. Concurrent viewer probes of the same identity remain admitted. Core rechecks active-turn liveness after it settles. No hook means no preparation; heartbeat, takeover/release, WebSocket reconnect and passive stills never call it. Sandbox invokes its fixed guest ensure with a 70-second execution budget; Browser has no hook. The provider supplies a synchronous authorized description, private native RFB authentication and an abortable openRfb returning DesktopByteChannel: Node readable/writable streams, a closed promise and an awaited close(). It must honor write/drain and read pause/resume. Core never resolves producer socket paths or starts a desktop from a read.

displayId identifies the actual display and its epoch, not a tab or viewer. Managed descriptions carry Project id, environment generation and desktop epoch; every current Project member may watch and claim. All sessions naming a display share one daemon-owned controller. controller(displayId).runAgent and runUser share one serial settlement queue. runView is only for authorized passive producer reads, not input or application launch, and it runs beside that queue instead of in it: an agent operation may wait up to a minute for the UI, and a read changes nothing, so a still capture never waits for it. It works during a human lease without claiming control or changing its revision; it checks cancellation and display fencing before it starts, never holds a claim in handover-pending, and a fence awaits reads in flight as well as the queue. Sandbox runs one still capture per display at a time and shares it with every viewer asking meanwhile. Sandbox uses it for full-screen still capture at the smallest quarter scale that fills its 480x300 box, reusing its admitted GUI screenshot validation and cancellation with the cached reconcile observation, never fresh observation probes. A visible collapsed Sandbox card costs one guest capture plus one host resize per 1500 ms server refresh; hidden or off-viewport cards stop scheduling captures. Without a passive consumer no extra operation runs. A claim during an active operation returns handover-pending; input remains blocked until that operation actually settles. Tokens are private and bound to the authenticated account, view and control revision. A claim belongs to the holder's account and view id and to its current lease, not to one socket: when a socket of that account and view closes, carrying no lease or exactly that lease, held input is released at once and the view has a 15-second budget to rebind with its lease proof, after which that same lease, and only that one, returns to the agent. Because view ids are chosen by clients, a socket of another account or one carrying a stale lease never starts or ends anything. A new claim or an explicit release of that view cancels the budget; admission, permission and protocol failures release control at once. closeDisplay(displayId, reason) fences a producer-known display even before a viewer resolves a session. closeSession closes only that session's views and releases only its claimant, leaving other sessions on the same live controller. closeDisplay advances the monotonic connection generation, rejects old tickets and awaits every admitted operation before reopening the same controller. Producer cancellation must start before awaiting this fence. No owner means unavailable, never a compatibility implementation.

Optional agent.operations entries bind each declared mutation tool to its operation kind and a Zod input schema. The host validates the tuple before any admission, then passes the complete command and authenticated user id to agent.execute. Tools call the returned desktop.execute({ sessionId, requestId, tool, kind, input }); runners forward desktop.command to the daemon using the active turn, never payload identity. The host checks exact turn/account grants and rechecks Project/plugin authority at admission. The host passes its authenticated userId explicitly to openRfb and agent.execute; WebSocket and runner callbacks have no ambient plugin identity. Execute must settle only after input has stopped, even when aborted. hostCancel requests cancellation; the runner waits for settlement and never replays an uncertain request. Each runtime keeps a bounded 10,000-request no-replay ledger. Settled entries are reclaimed only after their host-owned caller turn retires; unresolved and active-turn unknown outcomes are never removed. Native turn scope and runner pending-turn liveness are authoritative, never payload flags. Input JSON is bounded to 64 KiB. Result JSON is bounded to 4 MiB plus 64 KiB, allowing a producer's bounded base64 image and report through the same daemon and active-turn RPC schema. Sandbox uses this for Desktop screenshots: its producer validates a PNG of at most 3 MiB, geometry and scale, and its model tool extracts image bytes into image content instead of text/details. The larger result bound does not widen input, permission, queue, cancellation or no-replay rules.

Native VncAuth is required for managed desktops. connectionAuth returns security type 2 and the producer's password of one to eight Latin-1 bytes only through the private authenticated, no-store ticket response. It is absent from URLs, SSE, descriptions and artifacts. Account Browser uses security type 1 on its private host Unix socket. Clipboard policy is explicit; disabled clipboard is removed in both directions. Core frames every client message and rechecks queued input, resize and clipboard mutations at write time. Held keys/buttons are released on revocation. Tracking is capped at 256 distinct keys per connection; overflow releases known inputs and closes the connection before forwarding the excess key. Pixel formats must be validated 8-, 16- or 32-bit true-colour formats with non-overlapping channels and matching depth; Tight framing uses the negotiated channel widths. Transport uses a 4 MiB high-water bound, 15-second admission and 5-second live permission checks. Core retains Cursor -239 and VMware alpha cursor 0x574d5664 in SetEncodings only when the connection's validated ticket carries a control lease, not merely because a client says it is interactive. Claim and release open a new connection. Producers therefore send controller frames without an embedded pointer and a cursor shape in the negotiated pixel format for Cursor -239 or fixed RGBA for the alpha encoding, while watch-only clients get the remote pointer in their framebuffer. Sandbox composites its signal-driven GNOME cursor for viewers and screenshots; Browser's x11vnc handles the same negotiation natively. A producer without cursor encoding still sends its normal framebuffer; core never invents a cursor shape.

Portable X11 startup remains in elowen-plugin-shared/x11Desktop for the host Browser producer only. Sandbox no longer imports or packages it: GNOME 50 Wayland, its portal/PipeWire agent and the fixed Node connector belong to the guest image. Their payload digest remains image-owned.