PluginContext and workflow results
Runner local execution bridge
Only Sandbox receives optional ctx.localExecutionRpc(request,{signal,onResult}?) and ctx.localExecutionTurn() in a runner. These are absent in an authoritative daemon. The first forwards canonical local file/process operations through the existing runner IPC. The second returns {target,accountUserId,signal} from the active dispatched call: daemon-supplied target metadata, its admitted contribution account and the lifetime aborted by the daemon's existing abort frame or call settlement. Missing context refuses local work. Neither accessor opens a WebSocket or grants a new connection; the daemon's core localExecution host control owns the sockets.
Tool calls run under runWithExecutionTool, so the host stamps the real tool and plugin owner before forwarding. Ownership is read at call time from the loader's merged toolOwner index, the same source as ctx.toolNames(), rather than the Sandbox context's isolated staging registry. A tool with no registered owner throws before RPC; plugin-supplied provenance cannot replace the execution scope. Terminal's Bash therefore carries plugin:'terminal' even though Sandbox forwards the operation. The lookup adds no I/O or extra RPC. An ordinary tool-less call is reserved for core startup instruction reads: plugin:'sandbox', a read-only file operation and an instruction-source path. Sandbox passes its localFiles input's sessionId as RPC metadata for startup reads. Ordinary calls inherit the executing session. Daemon admission verifies that a requested descendant belongs to the dispatched root, retains the same account and local target, and satisfies every inherited tool policy plus current account grants. Operations still use the dispatched root's original connection pin. The admitted consumers are Files, Terminal, LSP and MCP, plus Sub-agent's WorkflowStart for read-only definition file operations. In read-only scopes, LSP/MCP must own a tool in the live registry's readOnlyTools; only argv process launch, protocol stdin and file reads cross that boundary. Bash keeps its existing shell permission check and empty-stdin restriction, and ordinary file mutations remain refused.
The separate core-only sandboxArtifacts.artifactFiles control carries typed artifact:true through this same bridge. The Sandbox host context preserves that intent with plugin:'sandbox' and no tool attribution, since capture and cleanup are core bookkeeping even inside a read-only Bash call. The daemon checks the active caller, descendant account and target, then invokes its own sandboxArtifacts control on the root connection pin. That existing owner confines paths and destinations to the selected computer's ~/.elowen and uses scoped file I/O. Ordinary localFiles reconstructs its request without the marker, so a model-selected path never acquires artifact privilege. The real consumer is streamed Terminal capture beyond the inline cap; absent artifact control refuses persistence. Each operation uses the existing RPC once, without another socket or provider request.
This bridge has no generic RPC deadline or replay. Cancellation forwards the original request cancellation; intermediate results reach onResult until the final result settles the existing call. IPC loss rejects with structured LOCAL_EXECUTION_LOST and mutationOutcome:'unknown' for dispatched work. Sandbox is the real consumer; absence makes local runner work unavailable.
Browser transport revocation
issueWebSocketTicket({ userId: req.auth.userId, payload }) keeps its existing plugin API.
Core captures actual browser-login provenance in the authenticated dispatcher, never from a request
header or plugin argument. Ticket minting and final upgrade admission check the same process-local
account generation; auth-security invalidation bumps it synchronously after committed revocation.
The existing registry retains account/provenance on live sockets, aborts browser handlers immediately
and closes them with code 1008. Browser/VNC is a real consumer. Revoking one login deliberately
disconnects all plugin browser sockets for that account; another valid login can fetch a new ticket.
API, paired-computer, public integration and background plugin authorities retain their own policies.
Tickets and generations are ephemeral; restart drops sockets and outstanding tickets.
Core SSE owners and the authenticated plugin SSE dispatcher use browserStreamGuard in
src/api/browserTransport.ts: subscribe before the initial credential recheck, gate queued delivery while
invalidated, and dispose on stream teardown. Plugin SSE rechecks after its route handler returns and
refreshes the original credential before every frame, including plugin-owned heartbeats. Revocation or
expiry aborts both the response stream and the signal passed to sse(send, signal), releasing the plugin's
subscriptions; Desktop's control/action stream is a real consumer. A still-valid login resumes after
account invalidation. Already-admitted model work is not cancelled.
Non-login credentials do not become browser sessions. Browser call/dictation engines retain this
provenance in their existing session state and revokeBrowserSessions(userId) terminates only their
browser peers immediately, then drains normal usage settlement; public/plugin media is preserved.
No extra connection registry, timer or model request is introduced. Browser plugin API and voice-start
requests pay one additional credential read; browser SSE pays it at initial attach and invalidation, plus
core heartbeats or every plugin frame. Public hooks and non-login credentials keep their own policies.
Platform conversation pickers
PlatformControlApi.listContext(ref, senderPlatformId, opts) returns a Promise of the existing
page or null. Await it before inspecting items; the shared chatCommands context picker is the
consumer. Core resolves the sender's linked account, excludes the bare default conversation and
reads complete listing metadata on its existing read worker. Unlinked senders still resolve to
null; ownership and binding guards are unchanged, and read failures reject.
Delegated continuations
ctx.continueSubagent(sessionId, text, callId, onEvent?, model?, promote?) takes the executing tool call's id as its required third argument. The bundled DelegateContinue passes its own progress-row id. Core validates that the row names this exact parent and child before persisting a read-only continuation's minted scope; plugin progress cannot supply or overwrite that authority. Handle and admission refusals reject before any job is registered or progress is emitted. Catch those errors and return their text. For accepted work, raise the running progress row after invoking continuation and before awaiting its promise: raising the row first would make an idle child appear active. The same local bridge runs in the daemon and the sub-agent runner; continuation admission does not cross host RPC. Read-only continuations hold their clamp for every turn of the call, including result delivery and restart recovery. A Build caller must wait until that call finishes. See delegation runtime.
Delegation completion and delivery
The bundled Subagent plugin captures ctx.delegationCompletionEmitter() in the parent turn before
spawning a child or scheduling workflow nodes. This single host-only sink accepts the exported
DelegationCompletion union: kind: 'subagent' carries one child's terminal result, and
kind: 'workflow' carries one whole-workflow summary, including its run on resume.
Core owns this union and DelegationCompletionEmitter beside the other turn emitters in
plugins/policyContext.ts; the public plugin API re-exports them without a reverse dependency.
Node results remain workflow progress; they do not become separate parent messages.
The accessor returns null outside a wired turn; sinkless workflows retain foreground execution.
const emitCompletion = ctx.delegationCompletionEmitter(); // Capture in the parent scope.
if (emitCompletion) {
const outcome = await emitCompletion({
kind: 'workflow', id, toolCallId, status: 'done', result: summary, run,
});
if (outcome.status === 'rejected') throw new Error(outcome.reason);
}
The sink atomically persists the completion in the durable result inbox and its terminal run
projection before publication or waking the parent. Its promise returns the same typed outcome as
progress: accepted or already-terminal confirms completion; rejected or a thrown error leaves it
recoverable. Do not emit a second terminal progress update. The shared Subagent completion helper
awaits pending progress and handles child results and whole-workflow summaries through one spill path
before calling this sink. Above ctx.toolResultInlineBytes() UTF-8 bytes, the captured
captureToolOutputWriter() stores the byte-identical full text in the receiving session's spill namespace;
formatToolOutputPlaceholder() supplies the bounded path, original byte count and preview that the
parent can follow with Read. Results at or below the threshold stay inline. The completion and parent
custom_message carry only that placeholder, not a second copy of the full text. A write failure becomes
an explicit error with a DelegateRead recovery hint, never a silently shortened success. This stores
output once; it does not request a model completion.
Completion acceptance is not delivery proof. Only a settled parent custom_message journal entry
with details.resultId proves delivery (settledResultIds); core appends it and acknowledges the
inbox row in the same transaction, including failed boot recovery results. A recovery failure wakes
the parent so it can verify the last step and decide whether to continue its child. Queue presence and live progress are not durable acknowledgement.
A running parent receives results through PI's queue in FIFO order with user steers, after the current tool round; results do not preempt a tool or code-mode cell. An idle parent receives a follow-up turn. Keep doing independent work and end the turn when none remains; no sleep or polling is needed. A delegated child call settles only after its turn ends, with no live helpers and no unprocessed helper results. Child model work and the parent's next request retain their ordinary token costs; delivery introduces no separate polling request.
PluginContext
register(ctx) receives one PluginContext. The public registration methods
are:
| Method | Contract |
|---|---|
retryPolicy() |
Strict asynchronous read of non-secret instance retry policy from the host source; unavailable settings reject. See retry policy declarations. |
onRetryPolicyChanged(listener) |
Committed-policy subscription returning an idempotent disposer; subscribe before read and fence revisions. |
registerTool |
Add a PI tool, optionally scoped to an account or Project. |
registerSkill |
Add a file-backed markdown skill, optionally scoped to an account. |
registerSkillDirectory |
Load instance-wide skills from a directory with PI's loader, under this plugin's source identity. Returns the loaded count. |
registerSurfaceFact(surface, resolve) |
Register a panelVisibility, navBadge, brainStatus, projectIndicators or readiness fact in the shared request-time resolver. The surface selects its existing response shape, scope and budget. See surface facts. |
registerCommand |
Add a prompt macro or a surface-local picker. |
registerCommandProvider |
Add live, account-scoped input aliases, read in the authenticated API or turn scope. |
registerText(placement, value, options) |
Contribute model text at system-prompt, turn-context, step-context or input-transform. Placement selects stable instructions, anchored turn records, frozen 1,024-byte mid-turn records or current-input transforms. See prompt context. |
registerSkillPostTurnReviewer |
Register { contractVersion: 2, run, status } for detached personal skill learning. Core supplies successful-call counts, loaded names, live authority/budget and a one-shot review(directive) fork pinned to the settled source; explicit skips retain cadence. status(userId) is the account's cheap local status read. One reviewer, 120 s abort deadline; incompatible registrations are refused. See the contract and JSON example. |
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. Core binds browser request provenance and revokes browser tickets/connections account-wide on login-session revocation; API, paired, public and background authorities stay distinct. |
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"]; the input type excludes host-authoritative retry-policy and computer-connection events. |
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. |
Retry policy declarations
PluginContext declares required retryPolicy(): Promise<RetryPolicy> and
onRetryPolicyChanged(listener: (policy: RetryPolicy) => void): () => void.
RetryPolicy is the same type exported by elowen-plugin-shared/retry and
the plugin API; there is no plugin-local policy schema. buildPluginContext
delegates both methods to the process-owned RetryPolicySource passed through
createPluginHost/PluginHostWiring. A process without that source throws
unavailable, never a default policy. The same source serves authenticated
/events; actual platform/MCP adapter adoption and runner IPC remain separate.
See strict source and distribution.
The read returns only non-secret instance timing/budget and rejects
unavailable settings rather than supplying defaults. No new configuration-read
grant is declared. A subscription delivers committed policy revisions and
returns an idempotent disposer. Subscribe before
reading, pass both results through the same strictly-newer-revision check and
dispose through the plugin's service.stop lifetime:
const dispose = ctx.onRetryPolicyChanged(acceptNewerRevision);
try {
acceptNewerRevision(await ctx.retryPolicy());
} catch (error) {
dispose();
throw error;
}
// After successful startup, the registered service's stop callback calls dispose().
The revision check prevents a late initial read from replacing a newer event. The contract does not restart an active retry episode or mutate its captured policy. Construction of the host source subscribes immediately but performs no initial read; each policy read still checks strict settings availability. Registering no plugin listener adds no plugin subscription. Reads/subscriptions add no model call or reconnect timer. Do not replace unavailable settings with optional-method fallbacks or a previously successful policy.
publishEvent takes
Exclude<ElowenEvent, { type: 'retry-policy' | 'computer-connection' }>: instance policy and computer observations are
host-authoritative, not plugin-publishable. The context also rejects forged
retry-policy and computer-connection publication at runtime; a generic event
mutation grant cannot impersonate those host sources.
Live workflow continuation and daemon-local dispatch
PluginContext.continueWorkflowNode(input: WorkflowNodeContinueInput) and
DelegatedChildBridge.continueForWorkflow(parentSessionId, input, currentAccess)
share canonical WorkflowNodeContinueInput/WorkflowNodeContinueResult from the
plugin API. The input extends native WorkflowRecoveryAdmission with optional
onEvent: (event: SubagentProgressEvent) => void and fallbackPrompt. Results are
answered/continued with reply, empty, or waiting with the ACTUAL committed
ComputerPrerequisite, never a guessed token or success-shaped empty wait.
The host still proves the actual parent and native planned-child relation; input
is not authority to substitute another child or reset a workflow budget.
const result = await ctx.continueWorkflowNode({
...nativeAdmission,
onEvent,
fallbackPrompt,
});
The live implementation forwards that ONE input unchanged through
DelegatedChildBridge.continueForWorkflow(parentSessionId, input, access) to
BrainService.continueWorkflowNodeFromTurn(parentSessionId, input, access).
Bundled-Subagent restriction, the actual ALS parent, process/brain availability
and awaited currentAccess() remain the existing gates; the principal travels
inside that access, not as another argument. Optional child reference, native
claim, callback and fallback prompt retain identity. All four typed outcomes and
synchronous/asynchronous errors pass through without a fabricated success value.
No call means no work; forwarding adds only existing scope/access resolution,
not a new store, retry or provider request. Native service/engine code owns its
further authorization, admission and execution cost. This forwarding does NOT
invoke runWithWorkflowNodeDispatch, add startFresh/a connection predicate to
public input or establish actual native service/facade composition.
The same canonical live input accepts optional computerUnavailable?: true as
PARK-ONLY failure intent, never dispatch authority. After an actual typed
LOCAL_TARGET_OFFLINE/LOCAL_EXECUTION_LOST cause during dependency preparation
or the original node operation, report
await ctx.continueWorkflowNode({ ...nativeAdmission, computerUnavailable: true }).
The native owner must reprove parent/node/planned child, generation/version and
current scope/Project authority, commit/repark the actual prerequisite and return
waiting WITHOUT dispatch, even if the socket already reconnected. The flag
cannot reset a budget, fabricate a row or authorize an unclaimed retry. It adds
no callback, alias or owner. The bundled engine now reports this intent only for
actual typed preparation/operation loss and requires a committed waiting result;
a rejected/non-waiting park surfaces as a node error, without operation replay.
Declaration/engine tests alone do not prove native/facade/provider composition.
The old positional child/text method is removed, not overloaded. Only
WorkflowRecoveryControl.resumeInterrupted.hooks.continueNode extends this input
with optional startFresh: () => Promise<string>. The targeted resumeWaitingNode
callback derives from that same daemon-local hook type. Neither live PluginContext
input nor runner RPC contains this callback or a connection predicate. Native
admission must be re-proved before invoking a fresh callback; accepting a hook
alone neither launches a child nor charges an attempt. Actual missing-child
park/dispatch and engine adoption belong to the native service and workflow engine.
policyContext.HostWorkflowNodeDispatch carries the exact native admission and
optional actual ComputerPrerequisiteWake.isCurrentConnection predicate through
runWithWorkflowNodeDispatch(dispatch, run) /
currentWorkflowNodeDispatch(). These are host-internal accessors on the existing
turn ALS, not PluginContext methods or a serializable field. The scoped wrapper
preserves the current policy, identity, session, execution-tool and SAME turn token,
including a scope whose token is its own object. Nested/concurrent callbacks stay
isolated and restore the outer proof; return values/promises/errors pass through.
There is no second ALS, store, callback registry, lifetime or plugin-settable proof.
An exact protected ALS parent/child match requires the existing platform host
assertWorkflowNodeDispatch(dispatch, childSessionId) callback before any async
preparation. Absence fails loudly; the native owner retains proof/claim authority.
Later preparation awaits recheck the same connection predicate, and the existing
dispatcher selects daemon placement only for that matching automatic recovery.
See ordering and placement limits.
Only matching native automatic missing-child recovery may select the existing daemon in-process dispatch branch, like stored-child recovery. This is not a runner-error fallback: ordinary live configured runner execution, skill review and runner startup failure retain their existing owners. Provider admission, not the declaration/ALS wrapper, owns final native CAS and charging. Without a callback invocation the wrapper does no I/O, provider work or timer; scoped cost is one existing ALS scope. Component contracts do not establish all native/engine/daemon caller composition or make native reservation and a filesystem journal atomic.
Explicit workflow lifecycle reservation
The required bundled-engine method
ctx.advanceWorkflowGeneration({toolCallId,workflowId}) derives its input and
result from DelegationRepository['advanceWorkflowGeneration'] in elowen-db.
Call only for explicit WorkflowResume after the existing workflow-origin and
original-principal checks. A plugin supplies no parent session, principal,
expected generation or authority. The existing DelegatedChildBridge carries
host-proven parentSessionId and current DelegatingTurnAccessContract; the host
captures ALS session/access/principal at invocation and applies its durable-parent
and current-access ceiling before the native repository call. There is no new
principal column, reader or independent authorization authority.
buildPluginContext implements this member only for the bundled subagent
plugin. Each invocation requires a current host ALS session, wired delegated-child
bridge and awaited currentAccess() with a nonempty principal; outside that live
turn, in another plugin/process or without a brain it rejects explicitly. The
same context resolves changed caller authority again, rather than retaining a
principal from registration. Minimal engine use remains
await ctx.advanceWorkflowGeneration({workflowId, toolCallId}) after its existing
authWorkflow checks.
createDelegatedChildren forwards the host-resolved parent, exact input and
current access to brain.advanceWorkflowGeneration(parentSessionId, input, access).
Its host type is the existing narrow Pick<BrainService, ...> seam. Native receipt
object, undefined refusal and synchronous/asynchronous errors pass through unchanged;
there is no fallback metadata, plugin-supplied generation or second principal store.
These are context/daemon forwarding checks, not the durable-parent/current-access
checks owned by the delegated service or atomic reservation owned by storage.
The native terminal-to-running reservation returns
{workflowGeneration,recoveryAdmissionVersion} or undefined. It advances the
lifecycle once, cancels obsolete waits and resets the explicit-resume recovery
budget atomically. Undefined refuses duplicate/stale/unavailable native state:
do not invent metadata or mutate the engine's nodes on refusal. Boot, automatic
transport recovery and whole-DAG replay must not call this method. The workflow
engine must persist accepted native recovery metadata before node mutation or
dispatch; its wf.run remains completion numbering, not native authority.
This implementation extends the existing bridge, not a new workflow service. Bundled Subagent WorkflowResume is its consumer; no call means no reservation or I/O. Host forwarding and durable-parent validation remain their native owners. The declaration alone does not make native reservation and filesystem journal publication atomic. See the recovery control contract.
Bundled WorkflowResume implements a staged journal handoff: prepare the complete
next DAG without terminal-engine mutation, prove the old terminal generation,
then durably write that complete journal BEFORE native advance, using existing
writeJsonAtomic file-before-rename and directory-after-rename fsync. The accepted
metadata handoff uses the same durable option. A refused or
unacknowledged advance removes staging only after an old-generation terminal CAS
proves non-commit; otherwise the file remains for boot with native recovery proof.
Accepted metadata is journaled before node execution. This is crash-safe ordering,
not a DB/filesystem transaction or plugin-minted authority. See explicit Resume
handoff.
ctx.workflowEmitter() remains the existing turn-bound publication seam. Its
required awaited result distinguishes created-with-native-recovery-proof, updated
and stale; later writes pass expected native generation. The engine awaits the
first created proof before node preparation. This is not a new BrainEvent or a
second receipt/control registry; boot emission uses the same WorkflowEmitter.
See native publication.
Scoped runtime surfaces
The context also exposes these scoped runtime surfaces:
ctx.configis the committed synchronous instance configuration snapshot. Database-backed host calls are asynchronous (Shared API 13 and later): awaituserConfig(),currentIdentity(),currentAccountUserId(),currentAccess(),currentWorkDir(),workDir(),defaultCwd(),allowedRoots(),assertPathAllowed(),isAdminSession(),provider()andforkParentContext(). Await the returned handles' database-backed methods, including host stores, external users, skill catalogs and secrets. Factories such ashost.stores()stay synchronous. Resolve current authorization at each call boundary; do not cache a grant for a turn. Bundled Files and Subagent and registry Parts and LSP are consumers.await 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 exposes awaitedget/has/set/delete/getOrCreateandawait 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.await ctx.db()returns the async PostgreSQL handle only withreads: ["db"]. Use plugin-owned awaited migrations and quote the host-selecteddb.schemaseparately from each table. See persistence.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(), andctx.currentSessionId()expose live turn metadata.await ctx.timezone()resolves the current configured operator IANA zone before rendering clock context or calculating a schedule. The shared/runtimeContextfactorycreateTimezoneResolver(readTimezone, warn)returns an async resolver; its callback returnsPromise<string>. Each resolution awaits that live read before trimming and validating the zone. An absent reader, empty setting, failed read or invalid zone retains the server-zone fallback, with UTC when the server zone is unavailable. Failed reads and invalid zones retain the existing warn-once behavior. Runtime-context usesruntimeClock(await ctx.timezone())in its awaited turn provider. Resolution performs one settings read and no model request; without a call it does no work.ctx.notify(),ctx.askUser(),ctx.answerQuestion(), andctx.emitCard()provide notification, interactive questions, and live conversation cards. Interactive transports awaitctx.answerQuestion(id, answers): it resolves totrueonly after the host accepts the pending id and exact answers;falseleaves the local prompt available for retry. An unwired host resolvesfalse, and persistence failures reject. Discord, Teams, Telegram and WhatsApp await this result before retiring their question state or showing success.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 50). 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. A delegated child's runningjobhandle (or one without a mode) holds the child's delegated call open, and its exit wakes the child inside that call; aservicehandle never does. Register the mode the process really has: a server or watcher registered as a job keeps its delegation open until it exits or is stopped (docs/modules/brain/turns.md, the delegated-call settlement).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. Who the render runs for, and which account of the provider serves it, is established by the host from the caller's own identity and the provider's routing state; a plugin cannot name either, and a paused or unconnected account refuses inside the seam instead of falling back to another one. 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. A missing key, provider refusal or response without an image is an error, not an empty successful result.ctx.provider(id)answers a configured provider's public identity —id,labeland the servicetype— through the same deny-by-default provider gate, and nothing else: no key, no endpoint secret, no account. It is what a plugin needs to name a provider in its own behavior, defaults and logs (for example the ChatGPT image model default). Reading it authorizes nothing; every request that USES a provider is authorized by the host per request, against the exact operation and model that request carries. 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 (PluginContext.authoritativeProcess).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.ctx.subagentRuns(limit)andDelegatedChildBridge.runs(parentSessionId, principal, limit)return promises. Await them before mapping or filtering children. They use the shared read worker on file databases, with the same parent/account/spawning-principal restrictions applied before the page limit. There is no synchronous listing overload.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, Project-access, account, installed-plugin and conversation projections (projects,userProjects,usersRead,pluginsRead,conversationsRead), 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.await ctx.host.relayClient(config)resolves the attributed inference client before it can be used; attribution reads may perform database IO and reject.relayClient()anddefaultInference()use the shared PI provider runtime and the singlecompleteInferenceAttempttransport inbrain/piInference.ts, 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. Its explicitly supplied endpoint and key prepare a private, transient runtime and one attempt, with the host payer admission and usage accounting preserved.defaultInference()usespiInferenceClientto authorize each configured account and owns account failover; the explicit relay has no stored account to select or fail over to. 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"].projectFiles()provides the canonical Project file guard withreads: ["project-files"].safe(root, path, forWrite)throws for a path outside the Project, for a symlink that resolves outside it, and for a leaf that is a dangling symlink.read(path, { maxBytes })andwrite(path, bytes, { overwrite })move binary files in and out of the current conversation's execution target, host or managed guest, for the loader-approved transfer plugins (Teams, Image-gen, Image-edit); see seam catalog rows 33 and 51.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. Without a platform deny the account grant and any other execution limits still apply.
Host source selection
PluginContext.currentAccess() and PluginContext.defaultCwd() expose the active execution scope and its bound working directory. Files uses this scope for project reads and writes; explicit paths still pass through assertPathAllowed. Managed operations use the authorized Sandbox filesystem channel and retain per-operation membership checks.
Git object ids
Import { isGitSha } from 'elowen-plugin-shared/gitSha'. isGitSha(value: string): boolean accepts only four through forty hexadecimal digits, case-insensitively. Minimal use: refuse the operation when !isGitSha(requestedCommit).
The helper is a pure local check for an object id before it is interpolated into Git operations. An invalid or absent string returns false; it does not resolve a ref, allow a branch name, run Git or touch the filesystem.
Retry-After parsing
Import { retryAfterMs } from 'elowen-plugin-shared/httpClient'. retryAfterMs(value: string | null | undefined, nowMs = Date.now()): number | null returns delay-seconds or the nonnegative delay to a parsed HTTP date, in milliseconds. Explicit zero remains zero; absent, blank, negative numeric or invalid values return null. Minimal use: const delay = retryAfterMs(response.headers.get('retry-after'));
This is only a pure header parser. Calling it does not sleep, retry, fetch or
change method idempotency. Callers retain their own fallback, reset-header
policy and caps. The core consumer is sendProblemBatch in
src/licence/reports.ts.
Abort-aware waits
Import { waitUnlessAborted } from 'elowen-plugin-shared/waitUnlessAborted'. waitUnlessAborted(ms: number, signal?: AbortSignal): Promise resolves true on elapsed delay and false on cancellation. Minimal use: if (!await waitUnlessAborted(30_000, signal)) break;
Already-aborted signals return false without a timer or listener. A
nonpositive delay otherwise succeeds immediately. With no signal the wait
expires normally; when unused it creates no work. A positive wait owns one
timer and at most one abort listener and disposes both on either outcome.
There is no retry or error translation in this module. Current consumers are
the SSE heartbeat loops in src/api/routes/events.ts and
src/api/routes/brainStream.ts, and the CLI connect-retry delay in
src/cli/clientConnect.ts.
In-flight work coalescing
Import { Singleflight } from 'elowen-plugin-shared/singleflight'. new Singleflight<T>().run(key: string, fn: () => Promise<T>): Promise<T> coalesces work already running for the same key. Minimal use is flight.run(String(accountId), () => fetchUsage(accountId)). Joined callers share the first factory and Promise, including rejection; different keys remain independent. The entry is removed when the Promise settles, so a later call starts fresh work. This is not a result cache or serialized queue. An unused instance schedules no work; each active key retains one Promise until settlement. Core providerUsage (src/brain/providerUsage.ts) is the current consumer.
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.
ctx.persistToolOutput, ctx.formatToolOutputPlaceholder and ctx.toolResultInlineBytes use
core's spill store. 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 target; failed spilling preserves the full result. Local plans, streamed captures and workflow output use core's artifact channel under the paired computer's home .elowen, not server paths. captureToolOutputWriter() captures the original asynchronous context, so a background result retains its recipient, account and execution target. Core recovery callers without that context must establish the receiving session's execution context before calling the internal registry writer.
Files Read awaits the current ctx.toolResultInlineBytes() at each text read to bound even explicit
pages, including line numbering and paging notices. Oversized pages refuse without read authorization;
long lines require targeted extraction or a bounded Bash slice. ctx.formatToolOutputPlaceholder
uses core's shared renderer, which includes the same long-line guidance.