NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · PluginContext and workflow results
Developer reference

PluginContext and workflow results

PluginContext

register(ctx) receives one PluginContext. The public registration methods are:

MethodContract
registerToolAdd a PI tool, optionally scoped to an account or Project.
registerSkillAdd a file-backed markdown skill, optionally scoped to an account.
registerBrainStatusProviderAdd read-only fields to GET /brain/status.
registerCommandAdd a prompt macro or a surface-local picker.
registerSystemPromptFragmentAppend stable instructions to the system prompt.
registerTurnContextAdd per-turn context as hidden, anchored native journal records.
registerInputTransformTransform only the current turn's raw model input.
registerStepContextAdd short hidden context during long turns, persisted by core in the native journal before model delivery.
registerSkillPostTurnReviewerReview a settled turn in the background to create or improve skills. One reviewer per daemon; a second registration is refused. Bounded to 120 s.
registerHookObserve a typed lifecycle event and return supported gated patches.
registerPlatformRegister a chat transport adapter.
registerNotificationDestinationProviderContribute proactive notification targets.
host.voiceLinked-account speech and daemon-only third-party call media.
host.usageIdempotent external costs billed to an explicitly granted account.
registerHttpRouteRegister a public webhook.
registerApiRouteRegister an authenticated HTTP route.
registerWebSocketRouteRegister a ticket-authenticated or plugin-authorized public WebSocket route.
issueWebSocketTicketMint a one-use ticket for this plugin's socket routes.
registerServiceRegister host-managed start and stop lifecycle work.
registerIntervalRegister an unref'd periodic callback.
registerControlPublish a live domain control.
controlResolve a sibling control at call time.
registerPromptsRegister shipped markdown prompt templates.
registerBootReconcileReconcile durable plugin state on every daemon start.
registerUserRemovedClean account-owned state when an account is deleted.
registerProjectRemovedClean Project-owned state when a Project is deleted.
publishEventPublish a host event, gated by mutates: ["events"].
deleteEventsForTargetRemove the plugin's activity rows for a deleted target.
registerEventRowResolverMap plugin events to persisted activity rows.
subscribeEventsSubscribe to the generic host event bus, gated by event mutation authority; private voice_ring and voice_ring_end events use an account-only lane and never reach plugins or the activity recorder. No plugin subscriber yet.
registerProjectIndicatorsAdd bounded plugin-owned rows to the Project register.
registerReadinessCheckAdd rows to the first-run readiness report.
registerMcpToolAdd a tool to Elowen's authenticated MCP server.
registerUiVisibilityHide this plugin's account or Project panels per account.
registerNavBadgeAdd a synchronous count to this plugin's navigation entry.
requestReloadAsk the host to apply plugin-owned files written to disk: reload replaces this plugin's skills in place, restart restarts the daemon (a shared 2.5-second quiet window, then the normal checkpoint-and-pause). The host never swaps plugin code in place.
registerDesktopProviderContribute a desktop transport provider. Requires provides.desktop in the manifest.
registerConfigChangedRun a serialized callback after live configuration is applied. Requires the manifest.liveConfig opt-in.
registerConfigValidationPure, synchronous validation before live configuration is persisted. Return null or a bounded rejection reason.

The context also exposes these scoped runtime surfaces:

  • ctx.config is this plugin's instance configuration slice.
  • ctx.userConfig() reads the current account's own configuration or returns null outside an account context.
  • ctx.instanceSecrets() and ctx.userSecrets() access encrypted, plugin-namespaced secret bags. User secrets return null without an account. A bag exposes get/has/set/delete and await bag.withLock(key, async () => { /* re-read, refresh, save */ }). The host reuses its kernel lock implementation, namespaced by plugin, owner and credential key, with the same durable key path in daemon and runners. Locks release on success or throw; never nest a lock on the same key. Acquisition is bounded to 15 seconds. Missing host vault wiring, unavailable keys, memory-only vaults and lock contention fail explicitly. Vault initialization propagates unexpected key-file read and permission errors with their original cause; an unreadable key is not treated as absent material. An absent secret reads as null; a lock does not create a credential. Each acquisition costs one short helper process and one held descriptor, no model tokens. MCP OAuth is the concrete consumer: load the current credential inside the lock before deciding whether a refresh is still needed.
  • ctx.dataDir() returns the plugin's writable persistent data directory, creating it on first use.
  • ctx.db() returns the host's main database only with reads: ["db"]. Use plugin-owned migrations and namespace tables with p_<plugin>_. The prefix is a convention checked during marketplace review, not by the runtime.
  • ctx.pluginDirs() returns the host's plugin scan roots (bundled first, then installed), whether or not enabled. Read-only discovery only, e.g. indexing sibling plugins' docs/ folders. Never import code from these paths.
  • ctx.assertPathAllowed(path, { intent }) resolves and checks a path against the current session policy and symlink rules. Never reproduce this guard.
  • ctx.defaultCwd(), ctx.workDir(), ctx.allowedRoots(), and ctx.currentAccess() describe the current execution scope. A default working directory is not proof that the turn is bound to a Project. currentAccess().projectManagement is set only while core calls a control for the agent's Project tool (runAsProjectManagement): the turn's account is managing a Project it names, the way the HTTP API does. A provider that refuses every Project except projectRef skips only that comparison when it is set, and keeps narrowing by projectIds/admin, readOnly and membership. Absent, nothing changes. Sandbox's environment authorize is the consumer. It is not a grant and no plugin can set it. During such a call projectIds also lists the Projects the managing conversation created or began deleting after its turn's scope was resolved.
  • ctx.currentIdentity(), ctx.currentContributionUserId(), and ctx.currentAccountUserId() expose verified identity and ownership scope. Use currentAccountUserId() as the single account resolver for account-owned state.
  • ctx.currentModel(), ctx.listModels(), ctx.timezone(), and ctx.currentSessionId() expose live turn metadata.
  • ctx.notify(), ctx.askUser(), ctx.answerQuestion(), and ctx.emitCard() provide notification, interactive questions, and live conversation cards. ctx.writeCard(sessionId, card) persists and publishes a card for one explicitly named conversation from an authenticated plugin API route; unlike emitCard, it is not bound to a running turn.
  • ctx.alerts.raise({ key, reporting, scope, severity, message, url?, data?, holdMs? }) and ctx.alerts.clear(key) manage durable bell rows for a plugin's own source. The reads: ["alerts"] capability is required. scope targets one user, a Project's members plus administrators, or administrators; the host validates bounds, same-origin URLs, parameters, and the declared scope shape. holdMs delays a raise until the condition has been reported without a break for that long (see seam 52). Every producer must provide reporting: { kind: 'local-only' } keeps the condition local, { kind: 'already-reported' } prevents duplication of its original emitter, and { kind: 'problem', category } selects a fixed typed category from AlertReporting. Missing or invalid decisions are rejected. Only a problem decision that creates, escalates or reopens a visible warning or critical row emits a problem event, once per raise rather than per recipient. The host attributes its category code to the stamped plugin source and exports no rendered alert text, parameters or dynamic keys. Held-but-not-due, unchanged, dismissed, info and clear transitions emit none. Reporting Off collects nothing; no extra transport, timer or reporting permission is needed.
  • ctx.processes exposes the daemon process registry to integrations that own background process handles. Register once with the launch account and session, then re-register the same handle when its mode changes from foreground to job. Spawn, detach, blocked reads, exit and removal all refresh the daemon's processes status/SSE projection; do not emit process cards or build a separate UI list. Terminal's explicit background and Ctrl+B/deadline paths use this seam. No handles means an empty list. Registration costs no model tokens; list projection is bounded by the per-session process cap. Runner IPC mirrors metadata in the daemon and clears it on runner exit.
  • ctx.images renders or edits images through host-owned providers without exposing credentials. Provider ids must be in the plugin's own config or the plugin must declare reads: ["providers"]. API-key providers use the OpenAI Images API; ChatGPT uses its account image route. OpenRouter image requests select an image descriptor from the shared PI ModelRuntime catalog and call ModelRuntime.generateImages. PI resolves credentials and authenticated request metadata; the host neither fabricates descriptors nor supplies a separate image key. An id absent from the runtime image catalog fails before dispatch. For example, ctx.images.generate({ providerId: "openrouter", model: selectedImageId, prompt }) returns rendered bytes and token usage in the existing PluginImageUsage shape. This seam does not record image cost or origin usage. Reported mixed text/image models work in both picker kinds. Requests contain only providerId, the full provider model ID, prompt and optional signal; edits add images. The model chooses output size. There are no size, quality, background or n controls in the public request contract or the provider transports. Unknown fields are rejected before a paid request on every provider. Cancellation and the host timeout are honored. The result's png field contains raw bytes in the reported format, not necessarily PNG; provider-reported size and quality remain result metadata. Registry image plugins save .png, .jpg or .webp accordingly, preserving matching explicit extensions such as .jpeg. Release Image Gen 0.2.12 and Image Edit 0.2.14 on core 0.29.52 and update installed copies before releasing the core contract cleanup. Their floor names the existing OpenRouter transport, so plugin-first rollout works without a compatibility shim; old requests fail after the core cut. The normal boot settings cleanup removes obsolete size keys from those plugins' stored config. A missing key, provider refusal or response without an image is an error, not an empty successful result.
  • ctx.embeddings uses the host's shared embedding service and configuration. It is unavailable unless reads: ["embeddings"] is declared. Its requests go to the provider like any other but are not attributed to an account, so they do not appear in the provider's Statistics.
  • ctx.resolveProvider() resolves centrally configured provider credentials only through the same provider gate and the host's single endpoint resolver. Stored API-key entries win; connected OpenRouter account routes also resolve because their OAuth credential is a permanent API key. Other OAuth account tokens stay in host-owned refreshable transports and are never exposed through this lookup. Prefer ctx.images when the goal is image work.
  • ctx.publicWebUrl() returns trusted deployment metadata, not request headers.
  • ctx.authoritativeProcess is true only in the daemon. A sub-agent runner shares the database and the plugin data directory with the daemon but must not settle or repair state the daemon owns, so gate boot reconciliation and similar claims on this flag (src/plugins/api.ts:2732).
  • ctx.logger is the plugin-scoped logger.

Host infrastructure is under ctx.host. Each accessor is separately deny-by-default and throws when its read grant or host wiring is absent:

  • elowenCli() (reads: ["elowen-cli"]) is a host integration bridge.
  • conversations() (mutates: ["conversations"]) sends unattended turns in a plugin's keyed conversation within a granted account. Daemon-only.
  • usage().reportCost() (reads: ["usage"]) reports an idempotent external cost against an explicitly granted account.
  • voice() (reads: ["voice"]) provides speech for linked senders and daemon-only third-party call media.
  • subagentAgentDir() gives only the Subagent plugin a fixed user-definition directory with reads: ["agent-files"]; no grant or missing wiring throws. On an in-memory database it returns undefined, so built-ins can be read but an editor write is unavailable. The plugin loads built-ins from its own agents/ directory and an immutable user-file snapshot per registry generation.
  • stores() exposes read-only Project, account, access, user, installed-plugin, event and conversation projections, gated by reads: ["stores"]. usersRead.mayUsePlugin(userId, name) resolves the core grant predicate live and also requires the plugin to be loaded and currently enabled. Administrators need the same grant; userId: null is open mode, with the same instance checks. Unknown accounts and unavailable plugins return false. pluginsRead.list() reads the existing discovery catalog used by Settings: { name, label, labels, hasIcon }, including plugins without a browser UI. Labels are the manifest display name and its locale overrides; hasIcon points consumers to the existing /plugins/:name/icon route. It grants no access and returns no configuration or secrets. Listing scans local manifests and icon existence synchronously, without network access; call it for a detail view, never for each badge item. Without the stores capability the accessor throws. Changelog uses the predicate for listing, details and badges, and the catalog only when an opened note contains visible plugin items.
  • externalUsers() provides the narrow external identity link and lookup seam. Account mutation requires mutates: ["users"]. linkOrProvision returns an existing binding unchanged; a new non-admin account requires an explicit projects: { kind: "existing", projectIds: [id] } or projects: { kind: "personal" }. Account and active memberships commit together, and a personal Project uses core's managed creation and account limit. Missing choices, empty selections or inactive Projects refuse creation without leaving an account or identity binding. Microsoft consumers use the administrator's ssoDefaultProjects array or explicit ssoNewPersonalProject setting through the same core provisioner for browser SSO and Teams onboarding. A plugin without a creation choice may only resolve or bind an existing account. This is a synchronous database transaction after catalog resolution, without environment startup or network calls. The Microsoft Teams account-linking consumer is the example; chat startup never repairs missing account memberships.
  • prompts() renders host prompt templates, including plugin registerPrompts overlays, and reads raw template text. Gated by reads: ["prompts"].
  • relayClient() and defaultInference() use the shared PI provider runtime through piInferenceClient, with reads: ["inference"]. Each completion owns its cost meter and reconciles a reported charge, including zero, before returning usage. relayClient({ baseUrl, apiKey, model }) keeps its explicit model identity and bounded 60-second deadline; it does not change a registered conversation endpoint. OAuth credentials cannot be sent to an explicit endpoint override. Missing routes return no client; failed or empty completions reject rather than returning a successful empty answer. Dispatched failures carry InferenceFailure.usage, allowing the host origin wrapper to record paid attempts once even when no answer is returned; a transport failure supplies only known charges, not guessed tokens.
  • publicHttp() is the outbound transport gated by network: true.
  • git() provides read-only Project checkout snapshots and diffs with reads: ["git"].
  • push() sends web-push messages with reads: ["push"]. Its PushPayload takes the turn_done or alert kind, a title, body, optional collapse tag, actions and the app path a tap opens. The service worker opens that path for every action.
  • projectFiles() provides the canonical Project file guard with reads: ["project-files"].
  • conversationFiles() provides managed visitor upload and exact-session ShareImage/ShareFile reads, gated by reads: ["conversation-files"].

Two catalogue helpers sit directly on the context, not under ctx.host:

  • ctx.toolNames() combines built-in and live plugin tools.
  • ctx.normalizeBrainModelSpec(raw) validates canonical provider/model specs. The Subagent editor uses both before writing definitions; these pure calls perform no model request. Invalid models return null, blanks undefined. This catalogue lists possible names, not an account grant. Register new plugin tools through the existing registry seam; an account must explicitly hold the name in allowed_tools, and grant-gated plugins still require their separate plugin grant. With no account grant the tool is invisible and cannot execute, including through ToolSearch or a code-mode nested call. A new MemorySearch-like core tool likewise needs an administrator's grant for every account, including the administrator's own. ToolSearch, exec, and wait are transport envelopes that remain available, but they cannot authorize a nested tool the account does not hold. access.denyTools remains an independent platform/delegated turn restriction; for example, the msteams adapter's RELAY_DENIED_TOOLS still blocks unsafe relay operations even when an account holds the tool grant. The chatbot plugin may drop its duplicate memory deny list only after the core commit retiring users.disabled_tools is deployed, database migration v36 has succeeded, and every daemon and delegated runner uses the new core. Until then keep the registry plugin's deny list. Without a platform deny the account grant and any other execution limits still apply.

Workflow results and captured tool output

The existing ctx.persistSharedWorkflowResult({ workflowId, nodeId, text }) and ctx.shareWorkflowOutputs({ parentSessionId, workflowId, nodeId, childSessionId }) share core's src/brain/session/workflowArtifacts.ts writer. The bundled Subagent workflow engine uses the first for a node's assistant result and the second for host-selected terminal captures. The text call returns { path, bytes, contentHash }; record bytes/hash in durable node state, never treat a path as authority. Captured sources come only from the exact child's durable tool results after child/DAG authorization, not from prose, supplied paths or inherited references.

Both calls publish content-addressed files without replacing existing content. Host writes stage then link, and managed writes use account/project-bound chunked uploads. A regular file, exact byte count and matching SHA-256 are required before returning or registering a reference, including adoption after a create-only collision. Failed registration removes only a file newly created by that call; adopted files remain. Missing managed providers refuse rather than write to the host. Without a call no file or grant is produced. Captured output keeps the existing 8 MB ceiling (MAX_TOOL_OUTPUT_BYTES, 8,000,000 bytes, src/shared/toolOutput.ts:3) and partial-output metadata; stream publication holds one bounded chunk and verifies the complete published file once. The PluginContext signatures, dependency grants and artifact retention rules do not change.

ctx.persistToolOutput, ctx.formatToolOutputPlaceholder and ctx.toolResultInlineBytes retain their contracts. Core's src/brain/session/toolResultSpillStore.ts, src/brain/session/toolResultPlaceholder.ts and src/brain/session/deliverySpill.ts own their storage, exact placeholder/marker bytes and live delivery threshold respectively. An inline delivery never resolves or touches the managed guest; failed spilling preserves the full result.