Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · PluginContext and workflow results

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

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.config is the committed synchronous instance configuration snapshot. Database-backed host calls are asynchronous (Shared API 13 and later): await userConfig(), currentIdentity(), currentAccountUserId(), currentAccess(), currentWorkDir(), workDir(), defaultCwd(), allowedRoots(), assertPathAllowed(), isAdminSession(), provider() and forkParentContext(). Await the returned handles' database-backed methods, including host stores, external users, skill catalogs and secrets. Factories such as host.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 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 awaited get/has/set/delete/getOrCreate 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.
  • await ctx.db() returns the async PostgreSQL handle only with reads: ["db"]. Use plugin-owned awaited migrations and quote the host-selected db.schema separately 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(), 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(), and ctx.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 /runtimeContext factory createTimezoneResolver(readTimezone, warn) returns an async resolver; its callback returns Promise<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 uses runtimeClock(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(), and ctx.emitCard() provide notification, interactive questions, and live conversation cards. Interactive transports await ctx.answerQuestion(id, answers): it resolves to true only after the host accepts the pending id and exact answers; false leaves the local prompt available for retry. An unwired host resolves false, 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; 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 50). 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. A delegated child's running job handle (or one without a mode) holds the child's delegated call open, and its exit wakes the child inside that call; a service handle 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.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. 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 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. 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, label and the service type — 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. 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 (PluginContext.authoritativeProcess).
  • 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.
  • ctx.subagentRuns(limit) and DelegatedChildBridge.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 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, Project-access, account, installed-plugin and conversation projections (projects, userProjects, usersRead, pluginsRead, conversationsRead), 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.
  • await ctx.host.relayClient(config) resolves the attributed inference client before it can be used; attribution reads may perform database IO and reject. relayClient() and defaultInference() use the shared PI provider runtime and the single completeInferenceAttempt transport in brain/piInference.ts, 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. Its explicitly supplied endpoint and key prepare a private, transient runtime and one attempt, with the host payer admission and usage accounting preserved. defaultInference() uses piInferenceClient to 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 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"].
  • projectFiles() provides the canonical Project file guard with reads: ["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 }) and write(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 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. 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.