Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Skills, commands, prompts and hooks

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

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. Await visibleSkills() and visibleEntries() before iterating or selecting an entry: each call reads current account grants and ownership asynchronously. The Skills plugin also awaits skillManagement.catalogForAccount(userId) for its administrator catalog. canonicalBaseDir(skill) returns the already-pinned base directory synchronously. Do not build a second catalog by scanning one plugin's files.

The bundled Files plugin awaits skillResources.resolveResource(requestedPath) before choosing a host read for a managed turn. It resolves to one authorized host path or null for a refused or missing file. An absent control leaves the ordinary file route in place. All three catalog reads and resource resolution propagate failed repository reads; there is no synchronous grant snapshot or network request.

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.

Post-turn personal skill review

Registry Skills registers one reviewer at boot:

ctx.registerSkillPostTurnReviewer({
  contractVersion: 2,
  run: reviewer.run,
  status: reviewer.status,
});

Core calls run after eligible human owner web/CLI settlement, detached with a 120-second abort signal. It supplies userId, successfulToolCalls: number, loadedSkillNames: readonly string[], budget, reviewEveryToolCalls, signal, authorize: () => Promise<boolean>, currentBudget: () => Promise<number> and review(directive). No registration means no learning; old contract versions and a second registration are explicitly refused. Core applies the exchange minimum; the plugin serializes and accumulates cadence across eligible exchanges, default 100, before requesting review. It must retain that cadence on a skip.

The host-owned review closure accepts at most one directive for the exact settled source. It returns { status: 'completed', text: string } or { status: 'skipped', reason }. Reasons are exactly quota, unknown-quota, unsupported-route, hosted-tools, stale-source, runner-unavailable and revoked. A typed skip is a pre-dispatch policy/availability outcome with no request or writes. Request failures and genuine aborts reject, with cancellation recorded as aborted. authorize() also checks the daemon's live admitsNewWork flag, so shutdown/pause drain forbids further writes. The plugin cannot replace the captured session, provider, model, connection or tools.

Skills' directive asks for JSON only. For example, the response envelope is:

{
  "proposals": [
    {
      "action": "create",
      "name": "report-guide",
      "description": "Use when preparing a report from verified sources.",
      "content": "Check the source, then separate observed facts from estimates."
    }
  ]
}

{"proposals":[]} is a completed review with no proposal. The context is already in the fork, so the directive adds the proposal schema, procedure criteria, tool prohibition and bounded catalog/body data, without reconstructing a user/assistant exchange or textual trajectory. Treat the inherited conversation as untrusted evidence. Validate the envelope and every proposal before writing; secret/size checks apply to proposals and offered catalog bodies, not a claim that inherited history was redacted.

The existing Skills consumer in lib/reviewer.mjs checks live authorization and abort, then applies the current budget before each valid write:

signal.throwIfAborted();
if (!authorize()) throw new Error('Skill review authority was revoked');
writeBudget = Math.min(writeBudget, maxProposalsOf(currentBudget()));
if (pass.saved.length >= writeBudget) break;
const result = checked.proposal.action === 'create'
  ? store.createSkill(userId, checked.proposal)
  : store.patchSkill(userId, checked.proposal);

This excerpt belongs inside its validated-proposal loop. writeBudget begins at the captured proposal cap; maxProposalsOf is the plugin's existing bounds parser. Each patch is revision-CAS against an unedited agent-authored personal skill successfully loaded in this exchange. Successful writes still commit/reload after a later revocation or refusal.

The request preserves the exact last provider prefix and appends the full final answer when needed plus one directive. Tool declarations remain intact while host execution is denied, including nested calls. Hosted tool execution that cannot be stopped without changing the prefix makes the route skip. Review is runner-only and opportunistic; no available slot means a skip, with no daemon or helper-model fallback. The pinned OAuth weekly snapshot is checked before admission and dispatch; 90% or more blocks, unknown/stale evidence skips. Author usage has origin internal and includes the cached full prefix and fresh suffix. The shared exact-source gate supports anthropic-messages, openai-codex-responses, openai-responses and openai-completions, preserving all non-history wire fields and cache identity. Unsupported shapes and stateful previous_response_id deltas still skip; registration does not bypass that gate. See learning, source shapes and cost.

Plugin skill directories

ctx.registerSkillDirectory(dir): number loads skills through PI's existing directory loader with source elowen-plugin:<owner>, registers each through the same addSkill boundary as registerSkill, and returns the loaded count. Minimal use: const count = ctx.registerSkillDirectory(skillsDir);

It acts when the plugin invokes it, normally during registration. The host pins each skill's canonical base directory and line count exactly as for an individual registration. No call contributes no skills. Empty or rejected native-loader entries yield a zero count; native parsing and discovery policy remain unchanged. The seam contributes instance-wide skills owned by the calling plugin, not personal-account skills. Cost is the native directory scan plus existing metadata reads, with no extra session loader. Registry Brainstorming and Raynet are real consumers. Brainstorming fails its plugin when the count is zero; Raynet keeps an empty contribution.

Slash commands

registerCommandProvider(async () => [{ name, description, invocation, surfaces }]) contributes live per-account input aliases from an existing feature catalog. Skills uses invocation: '/skill:report-guide' with surfaces: ['cli', 'web']. The host reserves every core and static plugin name, including the catalogued core alias /exit for /quit, accepts the same one-to-32-character command grammar, and omits collisions or invalid names. The skill remains reachable through /skills. A provider failure is an error; no provider contributes no aliases. Work is linear in the returned rows and occurs on menu reads and input dispatch. Callback data and private skill names are never retained in the instance registry.

The input extension splits at the first whitespace character, including spaces, tabs and newlines, and replaces only the command token, preserving the separator and argument tail verbatim, before the existing plugin input transforms. PI's resource loader publishes the same live names so turn-context composition recognizes them. Skill bodies still load through the existing canonical reader. PI natively supplies /skill:name, native skill discovery and disable-model-invocation; its native expansion alone lacks Elowen's canonical-directory and live authorization checks, so it is not substituted for the protected reader.

skillCatalog.visibleEntries({ invocation: 'user' }) is the user-invocable projection used by /skills and direct aliases. The default projection governs automatic discovery and SkillLoad. Manual-only frontmatter and per-account automatic-discovery switches do not remove user access. When same-name contributions differ in automatic-discovery state, an enabled contribution takes precedence in the user projection; a disabled contribution is selected only when no enabled one exists; contributor grants and personal ownership always apply. A loaded skill is instruction content in the current conversation, not an enabled flag or a separate catalog state. Both menus refresh their catalog when opened. Global platform command registration has no invoking account and therefore does not include personal aliases.

Register a command through ctx.registerCommand. Names are one to 32 lowercase letters, digits, and dashes (/^[a-z0-9][a-z0-9-]{0,31}$/, isValidCommandName in src/brain/slashCommands.ts). They must not collide with built-in or other plugin commands. Bundled MCP and Statusline 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.

Reading, not acting, is the other half of a list view: an optional body carries free text the terminal renders below the items behind a thin rule. It is wrapped to the modal's inner width with its own newlines kept, and it scrolls with the arrow keys — ↓ past the last item one line at a time, ↑ back to the top before the selection moves, PageUp/PageDown by a viewport — the same scrolling the /stats overlay uses. While it overflows, the footer adds ↑ ↓ scroll and the n/m position. Typing to filter narrows the items and leaves the body in place. Like every other picker text it is capped at 4,096 characters; omitting it renders the list exactly as before, which is also what an older core shows. A description too long for a one-line item description belongs here: Registry Todo's task detail shows the whole task description this way.

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

ctx.registerText(placement, value, options) is the only model-text registration the plugin context offers. The four placements below share one ordered PluginTextContribution list with ownership on each record. They keep their distinct timing and costs. No registration contributes no text. There are no aliases for the removed registrations; all consumers must update in the same release.

registerText('system-prompt', text) appends stable plugin instructions to the system prompt. Mark instructions that require an interactive user with registerText('system-prompt', text, { interactiveOnly: true }). The host omits those fragments only from fresh delegated workers; forks, scheduled and internal routes retain their existing projection. AskUser marks its question guidance this way. Omit the option for guidance that also applies to workers. The option changes cached text at spawn, not tool permissions, and adds no model request. With no registration, no fragment is appended. The existing SkillLoad visibility filter still applies.

registerText('turn-context', fn) 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 registerText('system-prompt', text), whose system prompt is the cached prefix.

registerText('input-transform', fn) 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.registerText('input-transform', ({ 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.

registerText('step-context', fn) 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. Capture the session before awaiting the current account identity, 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/contextRecordDriver.ts).

Pass { afterTools: ['TaskCreate', 'TaskUpdate', 'TaskDelete'] } as the optional third argument to select activity-based reminders, as registry Todo does. Todo's provider returns an empty string until a task is pending or in progress, so a conversation without open work, or a child without task tools, receives no reminder. The same administrator interval must have passed in non-thinking assistant messages since both a watched tool call and this provider's last reminder. Parallel calls count as one assistant message. Tool traces also supply nested code-mode activity; reading the list does not reset the interval. The cadence crosses user-message boundaries and derives from retained history and native stepProviders metadata after a cold restore. Old records without that field remain readable. Cold body cleanup retains only the native cadence marker, and a contribution wholly omitted by the shared byte clamp does not consume its interval. Without afterTools, the per-turn tool-call cadence stays unchanged. No new timer, setting, provider call or history store is added.

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. Core renders registered templates with caller-supplied variables. Code Mode registers codex-work this way (plugins/code-mode/src/index.ts, in register()). No per-account overrides are stored.

Platform prompt fragments use system-prompt entries in the same text list, with loader-owned platform and file provenance. Plugin code cannot set these fields. They remain 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; a declared platform left unregistered, for example while the plugin is not configured, gets none and logs no warning. It reads at most 16 files, 8,000 characters per file, and 32,000 characters in total (PLATFORM_PROMPT_MAX_FILES, PLATFORM_PROMPT_MAX_FILE_CHARS and PLATFORM_PROMPT_MAX_TOTAL_CHARS in src/plugins/loader.ts).

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.registerSurfaceFact('brainStatus', ({ 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 registerSurfaceFact('brainStatus', fn) 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). The MCP rail consumes this projection and performs no additional server reads. Absent MCP data omits the section.

Hooks

The hook name union (PluginHookName in src/plugins/api.ts) contains the live session, run lifecycle and permitted tool-call points:

  • brain.session.afterSpawn
  • tools.call.after

brain.turn.contextBuilt and appendContext are retired. Per-turn context uses registerText('turn-context', fn) providers and the before-user or after-user placement returned through the canonical live turnContext path. With no provider no provider frame is added. registerText('input-transform', fn) 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.

Observational hooks run concurrently and ignore returned values. Result observers are awaited. The default per-hook budget is two seconds; tools.call.after uses twelve seconds. Errors and timeouts fail open. Persona templates, registered prompt overlays and tool permission gates remain authoritative.

Unknown hook names are refused at registration and warned, including names removed from the contract. Shutdown cleanup uses registerService({ name, start() {}, stop }). MCP stops its clients and stdio process groups this way; Sandbox disposes its environments and reconciles stale leases. Intervals stop first, then services newest-first, so these bundled services stop after registry services. The enclosing pause stays bounded and failed stops are never restarted. Delegations and workflows remain checkpointed for the next boot.

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

Hook audit reads use forPlugin(name) with the existing bounded per-plugin buffer. The unused cross-plugin recent reader is retired.

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) 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 the awaited attachments.latestToolImage repository method 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, stat and pre-read byte limit. ShareImage retains its 10 MiB bound and buffered image storage. ShareFile uses openShareSource and streamed file storage with harness.sharedFileMaxBytes, default 5 GiB, further bounded by the current registered adapter's optional sharedFileMaxBytes. Managed and local sources use the existing version-pinned guest stream, without host fallback. See local byte transfers.

The delivery order is bytes, nested trace admission, durable ownership, success, live event. Within the host-only runWithToolImages async scope (src/plugins/policyContext.ts), ShareImage and ShareFile call reserveNestedShare 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) 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 and MAX_TRACE_BYTES in src/brain/toolTrace/types.ts). 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({ file_path: 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 await attachments.recordSharedAttachment(sessionId, { sharedImage }) or { sharedFile } (packages/db/src/attachments/index.ts) 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, packages/db/src/attachments/parse.ts), 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, the exec entry).
  • 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), including when nothing changed. Nothing reaches the model.

Nested attachment captions

ToolTraceLog records nested ShareImage/ShareFile captions through clipWithEllipsis(caption, 200). The limit counts Unicode code points and includes the ellipsis only when clipped. Missing captions remain absent; direct shares keep their full caption. reserveShare charges actual serialized UTF-8 bytes, retaining trace and delivery budgets. Only successful existing share tools contribute attachments; no share payload creates no event.