PluginContext and workflow results
PluginContext
register(ctx) receives one PluginContext. The public registration methods
are:
| Method | Contract |
|---|---|
registerTool | Add a PI tool, optionally scoped to an account or Project. |
registerSkill | Add a file-backed markdown skill, optionally scoped to an account. |
registerBrainStatusProvider | Add read-only fields to GET /brain/status. |
registerCommand | Add a prompt macro or a surface-local picker. |
registerSystemPromptFragment | Append stable instructions to the system prompt. |
registerTurnContext | Add per-turn context as hidden, anchored native journal records. |
registerInputTransform | Transform only the current turn's raw model input. |
registerStepContext | Add short hidden context during long turns, persisted by core in the native journal before model delivery. |
registerSkillPostTurnReviewer | Review a settled turn in the background to create or improve skills. One reviewer per daemon; a second registration is refused. Bounded to 120 s. |
registerHook | Observe a typed lifecycle event and return supported gated patches. |
registerPlatform | Register a chat transport adapter. |
registerNotificationDestinationProvider | Contribute proactive notification targets. |
host.voice | Linked-account speech and daemon-only third-party call media. |
host.usage | Idempotent external costs billed to an explicitly granted account. |
registerHttpRoute | Register a public webhook. |
registerApiRoute | Register an authenticated HTTP route. |
registerWebSocketRoute | Register a ticket-authenticated or plugin-authorized public WebSocket route. |
issueWebSocketTicket | Mint a one-use ticket for this plugin's socket routes. |
registerService | Register host-managed start and stop lifecycle work. |
registerInterval | Register an unref'd periodic callback. |
registerControl | Publish a live domain control. |
control | Resolve a sibling control at call time. |
registerPrompts | Register shipped markdown prompt templates. |
registerBootReconcile | Reconcile durable plugin state on every daemon start. |
registerUserRemoved | Clean account-owned state when an account is deleted. |
registerProjectRemoved | Clean Project-owned state when a Project is deleted. |
publishEvent | Publish a host event, gated by mutates: ["events"]. |
deleteEventsForTarget | Remove the plugin's activity rows for a deleted target. |
registerEventRowResolver | Map plugin events to persisted activity rows. |
subscribeEvents | Subscribe 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. |
registerProjectIndicators | Add bounded plugin-owned rows to the Project register. |
registerReadinessCheck | Add rows to the first-run readiness report. |
registerMcpTool | Add a tool to Elowen's authenticated MCP server. |
registerUiVisibility | Hide this plugin's account or Project panels per account. |
registerNavBadge | Add a synchronous count to this plugin's navigation entry. |
requestReload | Ask 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. |
registerDesktopProvider | Contribute a desktop transport provider. Requires provides.desktop in the manifest. |
registerConfigChanged | Run a serialized callback after live configuration is applied. Requires the manifest.liveConfig opt-in. |
registerConfigValidation | Pure, synchronous validation before live configuration is persisted. Return null or a bounded rejection reason. |
The context also exposes these scoped runtime surfaces:
ctx.configis this plugin's instance configuration slice.ctx.userConfig()reads the current account's own configuration or returnsnulloutside an account context.ctx.instanceSecrets()andctx.userSecrets()access encrypted, plugin-namespaced secret bags. User secrets returnnullwithout an account. A bag exposesget/has/set/deleteandawait 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 asnull; 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 withreads: ["db"]. Use plugin-owned migrations and namespace tables withp_<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(), andctx.currentAccess()describe the current execution scope. A default working directory is not proof that the turn is bound to a Project.currentAccess().projectManagementis set only while core calls a control for the agent'sProjecttool (runAsProjectManagement): the turn's account is managing a Project it names, the way the HTTP API does. A provider that refuses every Project exceptprojectRefskips only that comparison when it is set, and keeps narrowing byprojectIds/admin,readOnlyand membership. Absent, nothing changes. Sandbox's environmentauthorizeis the consumer. It is not a grant and no plugin can set it. During such a callprojectIdsalso lists the Projects the managing conversation created or began deleting after its turn's scope was resolved.ctx.currentIdentity(),ctx.currentContributionUserId(), andctx.currentAccountUserId()expose verified identity and ownership scope. UsecurrentAccountUserId()as the single account resolver for account-owned state.ctx.currentModel(),ctx.listModels(),ctx.timezone(), andctx.currentSessionId()expose live turn metadata.ctx.notify(),ctx.askUser(),ctx.answerQuestion(), andctx.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; unlikeemitCard, it is not bound to a running turn.ctx.alerts.raise({ key, reporting, scope, severity, message, url?, data?, holdMs? })andctx.alerts.clear(key)manage durable bell rows for a plugin's own source. Thereads: ["alerts"]capability is required.scopetargets one user, a Project's members plus administrators, or administrators; the host validates bounds, same-origin URLs, parameters, and the declared scope shape.holdMsdelays a raise until the condition has been reported without a break for that long (see seam 52). Every producer must providereporting:{ 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 fromAlertReporting. 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.processesexposes 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 fromforegroundtojob. Spawn, detach, blocked reads, exit and removal all refresh the daemon'sprocessesstatus/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.imagesrenders or edits images through host-owned providers without exposing credentials. Provider ids must be in the plugin's own config or the plugin must declarereads: ["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 PIModelRuntimecatalog and callModelRuntime.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 existingPluginImageUsageshape. This seam does not record image cost or origin usage. Reported mixed text/image models work in both picker kinds. Requests contain onlyproviderId, the full providermodelID,promptand optionalsignal; edits addimages. The model chooses output size. There are nosize,quality,backgroundorncontrols 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'spngfield contains raw bytes in the reportedformat, not necessarily PNG; provider-reportedsizeandqualityremain result metadata. Registry image plugins save.png,.jpgor.webpaccordingly, 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.embeddingsuses the host's shared embedding service and configuration. It is unavailable unlessreads: ["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. Preferctx.imageswhen the goal is image work.ctx.publicWebUrl()returns trusted deployment metadata, not request headers.ctx.authoritativeProcessistrueonly 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.loggeris 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 withreads: ["agent-files"]; no grant or missing wiring throws. On an in-memory database it returnsundefined, so built-ins can be read but an editor write is unavailable. The plugin loads built-ins from its ownagents/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 byreads: ["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: nullis 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;hasIconpoints consumers to the existing/plugins/:name/iconroute. 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 requiresmutates: ["users"].linkOrProvisionreturns an existing binding unchanged; a new non-admin account requires an explicitprojects: { kind: "existing", projectIds: [id] }orprojects: { 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'sssoDefaultProjectsarray or explicitssoNewPersonalProjectsetting 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 pluginregisterPromptsoverlays, and reads raw template text. Gated byreads: ["prompts"].relayClient()anddefaultInference()use the shared PI provider runtime throughpiInferenceClient, withreads: ["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 carryInferenceFailure.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 bynetwork: true.git()provides read-only Project checkout snapshots and diffs withreads: ["git"].push()sends web-push messages withreads: ["push"]. ItsPushPayloadtakes theturn_doneoralertkind, a title, body, optional collapsetag, actions and the app path a tap opens. The service worker opens that path for every action.projectFiles()provides the canonical Project file guard withreads: ["project-files"].conversationFiles()provides managed visitor upload and exact-session ShareImage/ShareFile reads, gated byreads: ["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 returnnull, blanksundefined. 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 inallowed_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 newMemorySearch-like core tool likewise needs an administrator's grant for every account, including the administrator's own.ToolSearch,exec, andwaitare transport envelopes that remain available, but they cannot authorize a nested tool the account does not hold.access.denyToolsremains an independent platform/delegated turn restriction; for example, the msteams adapter'sRELAY_DENIED_TOOLSstill 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 retiringusers.disabled_toolsis 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.