NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Skills, commands, prompts and hooks
Developer reference

Skills, commands, prompts and hooks

Skills, commands, prompts, and hooks

Skills

A plugin skill is the native file-backed skill shape. Register it with ctx.registerSkill(skill). The merged live catalog records the owning plugin. An ownerUserId scope makes a skill visible and expandable only in that account's own sessions.

A plugin that manages skills must resolve them through the live skillCatalog control. visibleSkills() and visibleEntries() already apply account grants and ownership, and canonicalBaseDir(skill) returns the approved base directory. Do not build a second catalog by scanning one plugin's files.

The per-turn <available_skills> block lists each model-invocable skill as - name (N lines): description and tells the model to load it with SkillLoad by name. It carries no file paths: a session in a managed project container cannot open a host path. The registry reads the line count once, when the skill is registered or replaced through requestReload; a file edited on disk keeps its announced length until the next reload, while SkillLoad always reports the length of what it actually serves. A skill whose file could not be read at registration is listed without a length.

Slash commands

Register a command through ctx.registerCommand. Names are one to 32 lowercase letters, digits, and dashes (/^[a-z0-9][a-z0-9-]{0,31}$/, src/plugins/registry.ts:1534). They must not collide with built-in or other plugin commands. Bundled MCP and Statusline, plus registry Skills, Todo and LSP, own their picker registrations and CLI contributions. Disabling a plugin withdraws its commands through the same live registry that supplies prompt macros.

A prompt command uses kind: "prompt" and a non-empty prompt. The agent receives the expanded prompt. Supported substitutions include $ARGUMENTS, $@, $1 through $9, ${N:-default}, and ${@:N}.

A picker uses kind: "picker", carries no prompt, and is rendered locally by the CLI or web surface. It does not create a model turn. Use ctx.chatCommands(surface) when an adapter needs the complete command catalog, including the command kind and execution mechanism. CLI contributions add cliPicker: { scope, render }, with optional cardAction: 'task'. See the Complete seam catalog for the bounded descriptor, local authenticated request and authorization contract. adminOnly is applied to both publication and dispatch; a renderer is never an authorization grant.

The context's strings uses the same loaded manifest and language overrides as the plugin's browser view. Read a declared key directly, for example strings.signIn; do not maintain a second terminal dictionary. A command without declared view strings receives an empty record.

An explicit action may return { notice: strings.authOpened, openUrl: authorizationUrl }, as MCP sign-in does after the existing scoped OAuth API returns its URL. Core validates and normalizes the HTTP(S) URL, refuses embedded credentials and caps it at 4,096 characters. The CLI shows the URL beside the notice and invokes the existing browser opener once for that selection or key action. Initial picker rendering, expired application publications and older picker generations never launch it. Over SSH or without a display, copy the shown URL into a local browser. The notice is transient UI state rather than a model message or durable transcript; omitting openUrl leaves browser handling inactive. The browser callback and its authorization remain owned by the feature API.

Notice rows use PI's standard ANSI-aware wrapping rather than truncating long words. The empty start screen, transcript viewport and suggestion geometry consume the same wrapped rows. If an empty screen cannot fit the notice beside its composer and status, the existing scrollable transcript viewport displays the transient notice without creating any history message; PageUp/PageDown and the wheel can reach the whole URL. The history indicator does not overwrite notice rows.

Prompt context and templates

registerSystemPromptFragment appends stable plugin instructions to the system prompt.

registerTurnContext runs once during prompt composition and returns before-user or after-user text. The default is before-user; use after-user for data qualifying the current request. Core stores accepted contributions as hidden elowen.turn-context native journal records in the same transaction as their user or host anchor. Provider replay restores those records through the canonical projection; visible chat, exports, titles and curator input exclude them. Compaction follows PI's retained branch, and cold clearing retires eligible records as elowen.context-cleared without changing user words. A missing provider adds no frame; a failed render adds no successful contribution. Retained text costs input tokens and composition performs the existing bounded provider dispatch. Runtime-context is a real consumer. Changing data belongs here rather than in registerSystemPromptFragment, whose system prompt is the cached prefix.

registerInputTransform runs before PI's input handlers and may change only the current turn's text. It requires capabilities.mutates: ["turnContext"]; without a registered transform, input is unchanged:

ctx.registerInputTransform(({ text }) => text.trim());

This is not a prompt overlay, a history rewrite, or a permission mechanism. The transformed text still travels in the current provider request and costs input tokens.

registerStepContext is for a long turn. The host calls it after a tool result at the configured mid-turn interval. Core persists the bounded result as a hidden native-journal meta entry before model delivery. It is not a human chat or platform message. Its frozen retained prefix follows the active journal, compaction and recovery; compaction does not imply deleting archived journal rows. Resolve the session and identity synchronously before the first await, keep the result short, read-only, and return an empty string when no reminder is needed. Core clamps a longer result to 1024 bytes (STEP_CONTEXT_MAX_BYTES, src/brain/session/stepContext.ts:85).

registerPrompts({ dir, entries }) requires capabilities.mutates: ["prompt"]. Each entry names a markdown file in dir using only { name }. Names are lowercase and may contain slashes, dashes, and underscores. Core template names and duplicate plugin names are refused. Registered names resolve to shipped plugin files; other names resolve to shipped core files. rawTemplate(name) reads the template and renderTemplate(name, vars) substitutes variables supplied by the caller. Code Mode registers codex-work this way (plugins/code-mode/src/index.ts:106). No per-account overrides are stored.

Platform prompt fragments are separate from registered templates. Place ordered markdown files in the plugin's prompt/ directory and declare the platform names in provides.platforms. The loader applies fragments only to platforms the plugin both declares and registers. It reads at most 16 files, 8,000 characters per file, and 32,000 characters in total (src/plugins/loader.ts:67-69).

Status and image seams

These seams are intentionally narrow. They use the existing host response shape and image store.

A status provider returns only fields from the stable brain-status envelope:

ctx.registerBrainStatusProvider(({ isAdmin }) => ({
  mcp: isAdmin ? [{ name: "docs", status: "disconnected", auth: { state: "needs-sign-in" } }] : null
}));

The provider is called while GET /brain/status is assembled. Null and omitted values remain meaningful. Do not turn a missing dependency into false or an empty object.

MCP rows contributed through registerBrainStatusProvider include name, status, and auth: { state }. The host validates and projects the authentication state without credentials, retaining the existing administrator-only visibility. Unknown fields are dropped. Valid states are none, signed-in, needs-sign-in, error and unsupported (PLUGIN_MCP_AUTH_STATES, src/plugins/api.ts:1628). The MCP rail consumes this projection and performs no additional server reads. Absent MCP data omits the section.

Hooks

The hook name union contains the live session, turn and run lifecycle, permitted tool calls, and process shutdown points (src/plugins/api.ts:189-193):

  • brain.session.beforeSpawn
  • brain.session.afterSpawn
  • brain.run.beforeSettle
  • tools.call.before
  • tools.call.after
  • plugin.shutdown

brain.turn.contextBuilt and appendContext are retired. Per-turn context uses registerTurnContext providers and the before-user or after-user placement returned through the canonical live turnContext path. With no provider no provider frame is added. registerInputTransform remains the raw-input mutation seam and still requires mutates:turnContext; the Skills plugin consumes it for live slash expansion. Historical journal records of kind hook remain readable but are never produced by current turn composition. The admin hook-audit buffer remains available to its other consumers.

Other gated hooks, including emitBlocking, emitPersona and emitRunBoundary, run sequentially in registration order. Observational emit hooks run concurrently and discard returned values. The default per-hook budget is two seconds; tools.call.before and brain.run.beforeSettle use three seconds and tools.call.after uses twelve seconds. Timed-out work is logged and cannot change the settled result. The retired context hook does not restore a context-mutation path.

plugin.shutdown fires once, during the daemon's pause, for cleanup the operating system will not do when this process exits: external machines, stdio children, leases (src/brain/brainService.ts:744). It replaced the plugin.reload.* pair, which fired for a hot registry swap that no longer exists — a plugin change now restarts the daemon. The pause bounds the whole teardown, so a hook that hangs is abandoned rather than allowed to hold the exit; the durable checkpoint is already written by then. Do NOT use it to settle delegations or workflows terminal: those are checkpointed by the restart and resumed at the next boot.

Hooks are observers by default. A handler may return patch, annotations, and audit. Supported mutations are:

  • denyToolCall from tools.call.before with mutates: ["tools"].
  • persona from brain.session.beforeSpawn with mutates: ["prompt"]. The first non-empty persona wins for that session.
  • boundary from brain.run.beforeSettle with mutates: ["runBoundary"]. Appends entries to the settling conversation and may ask for one more agent run; see "Run boundary" below.

Hook failures and timeouts fail open. Critical authorization belongs in the tool or route itself.

Run boundary

brain.run.beforeSettle fires once per settle attempt inside the running agent loop, after the last assistant message and before the run settles — in the turn's own scope, also in sub-agents and delegated runs. The payload is { sessionId, outcome, continuationAllowed, continuationsUsed }; the patch is { boundary: { entries, continue } }:

ctx.registerHook({
  name: 'brain.run.beforeSettle',
  run: (payload) => payload.continuationAllowed
    ? { patch: { boundary: { entries: [{ kind: 'message', text: 'TASK 7 is still in_progress' }], continue: true } } }
    : undefined,
});

An entry is { kind: 'message', text } (model-visible, never drawn in the transcript) or { kind: 'record', type, data } (invisible to model and transcript, durable). A patch carries at most four entries (RUN_BOUNDARY_MAX_ENTRIES); a message note is capped at 1024 bytes and a record at 4096 bytes (src/brain/session/runBoundary.ts:42-48). continue needs an accepted message, is ignored unless the run ended completed, and is capped at one per run (RUN_BOUNDARY_MAX_CONTINUATIONS, src/brain/session/runBoundary.ts:42): a granted continuation is one more full-context agent run, up to one step ceiling of steps, billed to the same origin. The hook fires again after a continuation, so append nothing once continuationAllowed is false and the note was already spoken. Requires mutates:['runBoundary'] (operator consent); without the grant the patch is dropped. Hooks are not filtered by account grants: a user-grantable plugin checks ctx.currentAccess() itself. An abort during a slow hook can still leave one small note, and the cap is per run: every automatic run gets its own continuation.

The brain.turn.beforeContext, brain.turn.beforeSend and brain.turn.afterResponse names never fired and are gone.

Interactive questions and answer records

ctx.askUser(questions) parks the current interactive prompt and resolves index-aligned AskAnswer values. Each option carries an optional recommended boolean; keep the recommendation out of the label, which remains the value posted by every surface. Single-select options may carry sanitized Markdown previews. There are one to four questions with two to four options in the bundled AskUserQuestion schema (plugins/askuser/index.mjs:74-75 and 202); custom input defaults to enabled and headers are only clipped by the CLI, not the web.

The bundled plugin returns textResult(modelText, { questionAnswer: { questions } }) (plugins/askuser/index.mjs:213-222). Each result question contains question, header, selected: string[], optional other, and outcome: 'answered' | 'expired'. The host permission gate uses the same record with outcome: 'once' | 'always' | 'deny' | 'expired' and structured approval facts. Timeout answers carry outcome: 'expired' from core, so a producer need not infer expiry from a sentinel sentence. The questionAnswerOf boundary (src/brain/messageView.ts:140) validates these details through src/shared/questionAnswer.ts in both live event conversion and history projection. Nested permission approvals carry the same record through ToolTraceCall, live settlement and durable trace hydration. ShowView results and trace records retain both the view payload and questionAnswer; interactiveViewOf is their common validated view boundary and the shared transcript keeps the approval beside the view. Web and CLI render an expandable record from that projection, alongside the tool output for permission decisions, without extra database rows, migrations, client storage or model-prose parsing. A result without the details keeps the existing ordinary tool row; malformed records fail validation. The permission gate prepends its decision to any answers returned by the tool, so a four-question batch may have a fifth permission entry; Project's internal destructive confirmations also retain their decision on success or refusal. An approved execute that throws becomes an explicit error tool result carrying its approval. The record costs the actual prompt and answer text in the already persisted tool result. Nested trace shrinking always retains answer records in the mandatory minimal slot, even when the optional display byte budget is full; their bytes count against admission of further rows.

Cards

ctx.emitCard(card) pushes a structured card to the current conversation's clients, keyed by card.id. Re-emitting the same id replaces the card, and emitting an empty card removes it. Web and Discord render every card; the CLI shows only pinned cards, in its fixed panel above the status bar, so a non-pinned card never surfaces there. The call is a no-op outside an interactive prompt turn; cron and worker sessions wire no emitter. A BrainCardItem may provide startedAt (epoch milliseconds) while running or durationMs (elapsed milliseconds) when completed: both are optional display-only fields, so older cards render without a clock. The Todo plugin emits these fields; clients call elowen-plugin-shared/duration (or the web runtime's utils.formatDuration) rather than placing a formatted duration in text. The emitter pays for one bounded card normalization and persistence write per update; there is no automatic timer outside a live client.

Rows for work the model did not call

A tool the model calls produces a provider tool event, and every surface already draws it. Work performed without such an event draws nothing: a code-mode script calling tools.* leaves the user with a single opaque row. src/brain/toolTrace/ turns that work into ordinary tool rows, and the codeMode control is its first consumer. The host composition lives in src/brain/service/codeModeComposition.ts: it passes fully gated nested tools, their shared deferred previews, trace sinks and per-turn principal callbacks through the existing codeMode control. It validates the final direct visibility after wrapper composition and resumes principal-scoped notifications on native agent_start. An absent or ineligible control leaves the direct tool surface; these functions introduce no plugin API or additional tool catalog.

Native ShowView is model-only and is excluded from scripts and automatic deferral. Its wait belongs to the native call and returns a click as tool output, never as a user turn. Historical trace records still parse stored view and viewId through the existing live/history projection; this does not make new nested view calls available.

Two halves, and a producer owes both:

  • Live. The host hands the producer a sink per producer id (request.trace(producerId) on the codeMode composition request). sink.call(name, args, execute) opens the row, runs the call, settles the row with the result, and records the durable twin; sink.note(text) adds a progress line. The callback receives the ROW ID and should pass it as the call id the tool sees, so anything the tool keys on its call id (delegated sub-agent progress, a workflow run) lands on the row the user is watching.
  • Durable. sink.drain(reportingCallId) returns the records not yet reported; put them on the details.toolTrace of the result of reportingCallId, the tool call that reports them (the toolCallId PI passed to its execute). The host expands them in place of that call's own row on reload, and keeps the call's own row when it recorded nothing — live and after a reload alike, decided from that same payload. A call whose own row would say nothing the reader asked for sets details.ownRow: false and then draws no row at all, drained progress notes included: code mode declares it for every wait (the model returning to a running cell) and for a successful exec, while a failed exec omits it so the error keeps somewhere to live. details never reaches the model, so the payload costs no tokens and no prompt-cache churn. A call still running when its record is drained (a cell that yielded mid-call) is reported with pending: true; only a record without it proves the call finished, so a consumer such as the post-turn skill reviewer never counts an unfinished call. Such a record is never drained again. When the call settles, the host replaces it in place, inside the result of the reportingCallId it was drained for (ToolTraceSettlements in src/brain/toolTrace/durable.ts): in the live message PI holds and in the stored journal entry, with no later wait needed. Only details.toolTrace of that result changes, never its content or call id, so the model's history and the provider cache are untouched; the row keeps one stored copy, so every history page, a restart and the cold tool-result clearing read the finished row where it first appeared. A call that settles before PI has appended the reporting result waits for the journal to change and is written then. An entry the journal no longer holds (a removed reply) or has discarded is not rewritten, and nothing is ever appended. Without a journal (the sink's third argument is omitted) a late settle shows only live.

The existing sink owns nested execution timing: call stamps startedAt once and measures the settled durationMs with monotonic host time. Pending records retain the start timestamp across exec/wait reports; late settlement replaces the same record with its final duration. Direct model-issued calls instead use PI's native tool-result durationMs. Both paths feed the same id-keyed tool row metadata and settle events, including results without text. CLI and web calculate live elapsed display locally from the supplied start time; they do not poll the host or send ticking values into the prompt. A record without timing displays no duration, not zero. Timing remains bounded host-only metadata under the existing trace budget and introduces no provider request or second persistence store. Code Mode's tools.Bash is a real consumer: its running row shows the current output tail and then freezes the duration when the call settles, including after the wrapper yielded.

Nested image results are externalized by the host sink when they settle, using the same content-addressed image store as direct results. Each call retains its own images array; imageOrder records completion order within the producer, independently of start order, and imageTimestamp preserves actual completion time across reporting calls and late in-place settlements. An image authored by the wrapper completes after its earlier nested images. The scoped latest-image lookup reads this record log during the script, and BrainStore.latestToolImage reads the records after the reporting result reaches the journal. For example, await tools.Desktop({ action: 'screenshot' }); await tools.ShareImage({ latest: true }); shares that screenshot immediately, including when repeated in one script. ShareImage and ShareFile still deliver through each record's sharedImage or sharedFile, with the existing live events and reload segments. Images returned only to the model do not automatically become chat shares. The sink must receive the session's image directory; without storage it retains no image references, as on an in-memory session. Byte writes and compact reference metadata cost no provider tokens; the original result passed to the worker is unchanged. References participate in attachment authorization and sweeping. Ordinary image references are best-effort: storage failure or the 100-record / 128 KiB producer budget drops them without changing the successful result returned to the worker. Completion order and latest-image selection advance only for retained references. Explicit sharedImage/sharedFile delivery metadata is required and its budget failure remains an error, never a claimed delivery. Nested scopes keep the original turn token; each share tool reserves its slot before yielding and releases it on refusal or failure, so five concurrent calls still produce at most four successful shares.

Both share tools use src/brain/shareSource.ts for the administrator check on all-access turns, guarded host path resolution, file stat, pre-read byte limit and byte read. They retain their own denial text, 10/25 MiB limits and storage logic (MAX_SHARED_IMAGE_BYTES in src/brain/chatImages.ts:17 and MAX_BYTES in src/brain/tools/shareFileTool.ts:13). Managed turns continue through readManagedGuestArtifact, without host fallback.

The delivery order is bytes, nested trace admission, durable ownership, success, live event. Within the host-only runWithToolImages async scope (src/plugins/policyContext.ts:243), ShareImage and ShareFile call reserveNestedShare (src/plugins/policyContext.ts:248) with the same share details they return. ToolTraceLog.reserveShare requires an admitted row and uses the existing strict share parser and 200-character caption cap (SHARE_CAPTION_CHARS, src/brain/toolTrace/record.ts:22) to reserve the exact minimal delivery record synchronously. Outstanding reservations count against new rows, notes and display upgrades across concurrent calls, under the 100-record / 128 KiB producer limits (MAX_TRACE_RECORDS in src/brain/toolTrace/types.ts:95 and MAX_TRACE_BYTES in src/brain/toolTrace/types.ts:98). Settlement consumes the reservation; failure releases it while retaining the errored row. A budget refusal occurs before ownership and consumes no successful per-turn allowance. Without a nested reservation callback, direct shares follow their ordinary ownership path. Timing and display output may be omitted to keep delivery; completed approval answers retain their existing mandatory-state exception to the display byte cap and cannot revoke an admitted share. The public CodeModeTraceSink.call signature is unchanged. For example, concurrent tools.ShareFile({ path, caption }) calls cannot spend the same available delivery bytes. Latest-image sharing verifies that the stored bytes can still be read before claiming delivery. Both share tools call BrainStore.recordSharedAttachment(sessionId, { sharedImage }) or { sharedFile } (src/store/brainStore.ts:2613) before returning a successful result. The method validates the reference and commits ownership in the existing session journal as an off-branch custom entry, customType: 'elowen.shared-attachment' (SHARED_ATTACHMENT_ENTRY, src/store/attachmentSql.ts:11), whose data.details contains only the stored reference and download metadata. It leaves the active model branch unchanged and duplicates no caption or visible transcript. Existing attachment readers project this entry through the same collectors as tool results; the route can therefore authorize the first fetch even while exec is still running. Ownership survives restart and turn cuts, participates in the daily sweep and disappears with conversation deletion. Leaf restoration excludes these off-branch ownership entries; rewind excludes them from its branch-linearity proof and preserves them. If only ownership metadata remains, the journal has no active model branch. A missing session, invalid reference or commit failure refuses delivery. An already owned reference requires no additional ownership entry. Refusals use isError: true so their reason survives into the trace, and ShareImage/ShareFile rows keep their individual targets when rendered.

Three invariants a producer must honour, all three of them enforced by the host but easy to defeat:

  1. Report each record exactly ONCE. One sink serves a producer that reports across several calls (an exec and then each wait on the same cell), and drain() is what keeps a row from being drawn twice, including a call it reported pending: the host settles that record where it already is.
  2. Never publish a row you did not record. A live-only row works until the user reloads.
  3. Let the host mint the ids. They travel inside the record; recomputing one on the reading side is how the streamed row and the reloaded row stop being the same row.

The payload is capped (100 records, 128 KiB per producer) and truncation is stated in a visible note rather than dropped silently.

Status notes for a wrapper the model is writing

While the model writes a tool call, the CLI and web show the call's reason beside the spinner (the tool_authoring event). A code-mode wrapper has no reason of its own: exec takes only the script. CodeModeControl.authoringReason(toolName, partialArguments) lets the owning plugin say what the call is doing from its partial arguments.

  • Use. Return the note, or undefined while there is none. The bundled code-mode plugin tokenizes the partial exec source with acorn and returns the last reason (Bash: description) written on any tools.<Name>({...}) call so far. A later call written without a note keeps the previous call's, so the hint does not fall back to the generic label once the model has said what it is doing; it returns undefined only while no call has a note yet.
  • When. On toolcall_delta for a tool name the composition returned, in the daemon and in sub-agent runners alike. Other tools keep reading their own reason.
  • Absent. Without the method, or while no call has a note yet, clients show the tool's localized label from LONG_TOOLS in src/shared/chatPresentation.ts (and its web mirror): "Running tools…" for exec (src/shared/chatPresentation.ts:146).
  • Cost. The whole partial argument is read on each call, so the host calls it at most once per 250 ms per streaming call (AUTHORING_THROTTLE_MS, src/brain/events.ts:464), including when nothing changed. Nothing reaches the model.