Every hook is handed $ as its first argument, and $ is the only way a mod reaches anything outside itself. What follows is that object in full, read out of the CoreEngineInterface declaration in build 2.1.278: 19 nouns carrying 69 verbs, each with the signature it is declared with and whatever its doc comment says.
Nothing on this page is written by hand. A verb with no description here has none in the declarations either. The prose about what these cost and which ones behave unexpectedly is in the guide.
$.plugin
#
2 verbs
This plugin, as loaded: its manifest name and its directory.
$.ui
#
13 verbs
Display: a line under an open dialog, a redraw or a repaint, a transcript or debug line, panes the surface places, a window or ring.
$.ui.notice #
(tool_use_id: string, text: string | undefined) => void
Shows `text` as one line under the dialog open for `tool_use_id`, or removes the line when `text` is undefined.
Core removes the line when the call resolves. A call that is not open is refused, as is another plugin's `$.tool.call` run.
tool_use_id- the call whose open dialog gets the line
text- the line to show; undefined removes it
$.ui.notice(e.tool_use_id, "checked by my-plugin"); return next(e)
$.ui.invalidate #
(event: InvalidatableEventName) => void
Re-runs an event whose results the engine caches: `ui.render` draws the instances this plugin may draw again; the others drop the cached answers.
A render hook whose state changed (a countdown) calls it for a redraw, at most ten a second, thirty for the shown pane and the band (calls sooner fold); a prompt section, context or attachment hook: dropped next turn.
event- `ui.render`, or a cached-answer event: `prompt.section`, `prompt.context`, `prompt.attachment`, `tool/command/config.describe`
$.ui.blit #
(args: UiBlitArgs) => Promise<UiBlitResult>
Repaints a mounted `Raster` this plugin's own render hook drew with new cells, or swaps a keyed `Image` it drew to a new source; no redraw.
The surface paints the cells or source at its next frame, so blits between frames fold into one: up to 120 a second taken, some sixty shown. An Image swap sends one small command; a resize is a redraw.
args- `requestId` (the site), `key`, and `cells` (RasterProps) or `source` (ImageSource); `columns`, `rows` (the mounted size)
Returns `{}` once the cells or source are its next frame, or `{ deny }` (not mounted, not this plugin's, another size, cells that do not decode, a bad source, or an Image drawing its alt there: no placeholder images, or a file this terminal cannot read)
$.clock.every(33, () => $.ui.blit({ requestId, key, cells: frame() }))
await $.ui.blit({ requestId: 'browser', key: 'view',
source: { shm: name, format: 'rgb', width, height } })
$.ui.resolve #
<E extends ResolveInput>(e: E) => Elements[E['surface']]
The elements of the surface `e` is drawn on (Elements[e.surface]): a frozen table of constructors, the JSX tags a render hook draws with.
A read, not a dispatch: the engine ran `ui.resolve` (the other hooks, then core) at load, per surface and component. Narrowed `e.surface`: that table exactly; unnarrowed: the union, so shared names type-check.
e- this hook's own `ui.render` argument (its surface and component pick the table)
Returns the surface's frozen element table (`Elements[e.surface]`)
const { Box, Text } = $.ui.resolve(e)
return <Box>{await next(e)}<Text dimColor>done</Text></Box>
$.ui.log #
(text: string, options?: UiLogOptions) => void
Appends one line to the transcript, drawn like a system notice (dim; not sent to the model), or with `{ to: "debug" }` to the debug log alone.
A row of its own at the next frame, in logging order; a `-p` or SDK host receives it as `ui_log`; the debug log has every line under this plugin's name. Raised as `ui.log`: a hook above may rewrite `e.to`.
text- the line's text
options- `to`: `transcript` (the default) or `debug`
$.ui.log(`prompt from ${e.origin.kind}: ${e.text.length} chars`)
$.ui.log(`cache miss for ${e.tool_use_id}`, { to: "debug" })
$.ui.ask #
(question: string, options?: readonly string[] | AskOptions) => Promise<string>
Asks the user `question` in the engine's own AskUserQuestion dialog and resolves to the label they chose, or the text typed under "Other".
A `tool.call` of `AskUserQuestion` through every hook but the calling one, drawn by `ui.render` on `AskUserQuestion`; a multi-select answer is comma-joined. Rejects when dismissed, and in a `-p` run (no one to ask).
question- the question, ending in a question mark
options- 2-4 option labels, or `{ options, header, multiSelect }`
Returns the label chosen, the chosen labels comma-joined, or free text typed under "Other" (so compare it with the labels exactly)
const mood = await $.ui.ask("How careful?", ["Bold", "Careful"])
$.ui.toast #
(text: string, options?: ToastOptions) => void
Shows `text` on the notification bar under the prompt for a few seconds, the way the engine's own "context left" notice appears.
It leaves the transcript and the model untouched.
text- the line to show; an unpaired surrogate half in it is drawn as U+FFFD
options- `timeoutMs`: how long it stays (default 4000)
$.ui.toast(`turn took ${Math.round(e.durationMs / 1000)} s`)
$.ui.status #
(text: string | undefined) => void
Pins `text` as this plugin's status line under the prompt, beside the engine's own pinned notices, until the next call replaces it.
One per plugin; `undefined` removes it.
text- the line to keep on screen (an unpaired surrogate half is drawn as U+FFFD); undefined clears it
$.ui.status("thinking..."); return next(e)
$.ui.open #
(pane: PaneOpenArgs) => Promise<void>
Opens a pane: a framed region the surface places, whose body this plugin draws by hooking `ui.render` for `{ component: "Pane" }`.
One instance per id (`requestId`): opening an open id retitles it; a hook on `ui.open` may refuse it. The keyboard is the person's; unasked, it waits undrawn below 144 columns (110 once asked), judged at each open.
pane- `id` (1-64 of letters, digits, `_`, `-`), `title`, `focus`, `closeOnEscape` and `holdToasts` (a dialog), `rows` / `columns` wanted
Returns settles once the pane is open (or retitled)
await $.ui.open({ id: "clock", title: "Clock" })
await $.ui.open({ id: "ask", focus: true, closeOnEscape: true, rows: 9 })
$.ui.close #
(pane: PaneCloseArgs) => Promise<void>
Closes one of the open panes; an id that is not open is left alone.
Every close raises `ui.close`, `e.origin` naming whose it is: this call (`plugin`), the person's mark or key (`person`), an unload (`unload`). A hook answering without `next` keeps the pane open, save on an unload.
pane- `id`: the id the pane was opened under
Returns settles once the pane is gone or a hook answered for it; rejects on a hook's `{ deny }`
onPress: () => $.ui.close({ id: "clock" })
$.ui.panes #
() => Promise<readonly UiPane[]>
Lists this plugin's own open panes (UiPane): each one's id and title, and whether it is shown, holds the keyboard, and is placed.
The engine's record, not the module's: a module reloaded while its pane stayed up finds it here. Another plugin's panes are not listed.
Returns the panes in open order, placed ones first; empty with none
const isUp = (await $.ui.panes()).some(pane => pane.id === "clock")
if (!isUp) await $.ui.open({ id: "clock", title: "Clock" })
$.ui.scroll #
(args: UiScrollArgs) => Promise<UiScrollResult>
Scrolls something into view as the DOM's `scrollIntoView` would: a render instance by `requestId`, an element by `key`, a site's edge.
A site of this plugin's (its pane, the band it draws into) moves under the event `ui.scroll`, origin `plugin`. A transcript row moves only while this call answers the person's own input, where one scrolls.
args- `to` (what), `in` (which site, required for `start` and `end`), `block` (where it lands; `nearest` by default)
Returns `{}` once it moved, or `{ deny }` saying why not
onPress: () => $.ui.scroll({ in: "log", to: "end" })
$.ui.focus #
(args: UiFocusArgs) => Promise<UiFocusResult>
Moves the focus ring of one of this plugin's sites onto an element it drew there, as the DOM's `element.focus()`, while it holds the keys.
Raised as the event `ui.focus`, origin `plugin`; the engine's inverse marks the element. The keyboard is the person's to give: a site not holding it, or holding it on another plugin's element, is `{ deny }`.
args- `requestId` (which site: a pane's id, the band's) and `key` (the element's, as drawn)
Returns `{}` once it moved, or `{ deny }` saying why not
onPress: () => $.ui.focus({ requestId: "files", key: "row:0" })
$.model
#
3 verbs
Completions through the session's own client and credentials.
$.model.complete #
(request: ModelCompleteRequest) => Promise<string>
Runs one text completion through the session's own API client and resolves to the reply's text.
No tools, no history, no system prompt beyond the CLI's identity block and `request.system`.
request- the model (an alias such as `haiku`, or a full id, resolved like a `--model` value), the prompt, and a token cap
Returns the reply's text
const reply = await $.model.complete({ model: "haiku", prompt: "Hi." })
$.model.fork #
(request: ModelForkRequest) => Promise<ModelForkResult | null>
Runs one tool-less completion over the session's OWN transcript, sharing the main thread's prompt cache; the reply's text and the fork's usage.
Not `complete`: sharing the cache prefix means the model and system prompt are the session's, read from its last turn's cache-safe snapshot, and tools are denied. Null on a cold snapshot or an API error.
request- the one user message the fork answers
Returns the reply's text with the fork's usage, or null on a cold snapshot or an API error
const reply = await $.model.fork({ prompt: "One line to learn?" })
if (reply !== null) $.ui.log(`${reply.usage.output_tokens} tokens`)
$.model.classify #
(text: string, labels: readonly string[], options?: ClassifyOptions) => Promise<string | undefined>
Picks one of `labels` for `text` with one completion over `$.model.complete` and a fixed classifier prompt.
`text` is data; the model answers with a label alone. It resolves `undefined` when the answer named none of `labels`. A failed request, an abort or a reply with no text rejects, naming the cause.
text- what to classify
labels- the labels to choose from (2 or more)
options- `model`: an alias or id; default the engine's small fast model
Returns the label the model named, or undefined when it named none; rejects when the request fails or the reply has no text
const kind = await $.model.classify(e.text, ["bug", "feature"])
if (kind === undefined) return next(e)
$.audio
#
2 verbs
Sound: clip playback and platform speech.
$.audio.play #
(clip: AudioClip, options?: PlayOptions) => Promise<void>
Plays one audio clip, starting now; clips are not queued, so two calls play together (a bed under speech).
`{ asset }` is the plugin's own file, loaded by the engine and played through the platform's player (`afplay` on macOS). Resolves when playback ends; rejects, naming the cause, when the clip cannot play.
clip- the plugin's own file (`{ asset }`), a URL the engine fetches, or the bytes as base64 with their MIME type
options- `shouldLoop`, `gain`, and an AbortSignal that stops the clip
$.audio.speak #
(text: string, options?: SpeakOptions) => Promise<SpeakResult>
Speaks `text` with the platform's own synthesizer (`say` on macOS). Plain text.
Utterances are queued among themselves; clips are not. Resolves when the utterance has ended; rejects, naming the cause, when there is no synthesizer, the voice is not installed, or the utterance failed.
text- what to say, as plain text
options- `voice`: the system voice's exact name (`Samantha`); absent, the synthesizer's default
Returns which synthesizer spoke, once the utterance has ended
$.mcp
#
1 verb
The engine's connected MCP servers.
$.mcp.call #
(server: string, tool: string, args?: Record<string, unknown>) => Promise<McpToolResult>
Calls `tool` on one of the engine's connected MCP servers with the engine's own connection and credentials.
A `cached` server is dialed on first use. No permission prompt: the plugin's call, seen by the hooks above it, is the grant. Positional, not the `{ tool: "mcp__server__tool", ... }` shape a `tool.call` hook sees.
server- the server's name as /mcp lists it (`claude.ai Gmail`; the tool-name spelling `claude_ai_Gmail` is accepted too)
tool- the tool's name on that server (`create_draft`)
args- the tool's arguments; none when absent
Returns the tool's result as MCP returns it: `content` blocks and `isError`
const { content } = await $.mcp.call("claude.ai Gmail", "create_draft", {
to: "[email protected]",
subject: "Release notes",
})
$.session
#
12 verbs
The running session, read as plain data, and compacting it.
$.session.messages #
() => Promise<SessionMessage[]>
Returns the transcript so far, one entry per user or assistant message; progress rows, `$.ui.log` lines and notices are not messages.
Each entry is a SessionMessage, `{ role, text, toolUses }` (a user message may add `toolResults`; a `toolUses` entry adds its `result` and `text` once answered). A long transcript answers its newest 4096.
const last = (await $.session.messages()).at(-1)
$.session.root #
() => Promise<string>
Returns the session's project root, absolute: where it started, or where `/cd`, a host's directory change or a worktree move took it.
A shell `cd` during the session does not move it; nested instruction files are read only beneath it.
$.session.turns #
() => Promise<number>
Returns how many prompts the user has sent this session (user turns in the transcript).
$.session.repo #
() => Promise<SessionRepo | null>
Returns the git repository the session runs in, read from the working copy on each call; null when the directory is not inside one.
const repo = await $.session.repo(); const publicRepo = !repo?.internal
$.session.surfaces #
() => Promise<readonly RenderSurface[]>
Returns every surface the session draws on, each once: `terminal` under the REPL first, then the remote ones in the order they attached.
A session may draw on several at once (a terminal and two phones): clients attach (`session.attach`) and detach, and a render hook still reads `e.surface` per ask. Empty in a plain -p run; never rejects.
const inApp = (await $.session.surfaces()).some(s => s !== "terminal")
$.session.surface #
() => Promise<RenderSurface | null>
Returns the first of `$.session.surfaces()`, or null where nothing draws.
$.session.usage #
(args?: SessionUsageArgs) => Promise<SessionUsage>
Returns the context window's fill, the rate-limit windows and the cost, as the status line has them; with `breakdown`, by category too.
The plain call costs nothing; `"full"` counts each category with the token-count API as /context does, `"summary"` estimates locally, and `context.breakdown` comes back in the SDK's `get_context_usage` shape.
args- `{ breakdown, columns }`: how the breakdown is counted and the width its grid is drawn in; nothing for the status line's figures
Returns `{ context, rateLimits, cost }` as the status line has them
const { context } = await $.session.usage()
if ((context.percent ?? 0) >= 85) await $.session.compact()
const usage = await $.session.usage({ breakdown: "full", columns })
for (const row of usage.context.breakdown?.gridRows ?? []) draw(row)
$.session.compact #
EventCalls['session']['compact']
Compacts the conversation: the event `session.compact` with `trigger` `plugin`, the same call `/compact` makes, between turns.
It runs through every hook but the calling one, then core: a summary and the kept messages in the transcript's place. Resolves `{ skip }` when a hook vetoed it; rejects while a turn runs.
const { skip } = await $.session.compact({ instructions: "the plan" })
$.session.authorize #
() => Promise<SessionAuthorization>
Holds the session's Anthropic credential on the host and answers an opaque handle and its kind; the secret never reaches the plugin.
The handle is spent through `$.http.fetch(url, { auth: handle })`, which sets the credential header, only for a first-party host. Null where the build or the provider has no first-party credential to hold.
await $.http.fetch(url, { auth: (await $.session.authorize())?.handle })
$.turn
#
1 verb
The running model turn: ending it.
$.turn.abort #
(input: OpEventOf['turn.abort']) => Promise<void>
Cancels the running model turn: the one whose id `turn.start` handed this plugin, its running tools stopped, no interruption marker.
The event `turn.abort`, seen by the hooks above; the prompt this plugin submits next is the context. Rejects, naming both ids, when `turnId` is not the running turn's; a hook may end its own turn.
input- `turnId`: the id `turn.start` carried
on("turn.start", ($, e, next) => { held = e.turnId; return next(e) })
$.prompt
#
4 verbs
Submitting a prompt the model reads as a user turn, and the person's prompt box: read as it stands, written, or proposed into.
$.prompt.submit #
EventCalls['prompt']['submit']
Submits a prompt: the event `prompt.submit`, the same call the engine makes for a typed prompt; `input.text` runs when the session is idle.
It goes through every hook but the calling one (the plugin's others see it) with `e.origin` `{ kind: 'plugin', name }`, the name the model reads it under unless a hook leaves it out of its answer.
void $.prompt.submit({ text: "List the TODOs you just mentioned." })
$.prompt.read #
() => Promise<PromptBox>
Returns the prompt box as it stands, the draft typed so far and the cursor's offset into it, so a `fill` can keep what the person typed.
Never rejects: `{ text: '', cursor: 0 }` where the session draws no box (a -p run, an SDK host) or none is mounted yet.
const { text, cursor } = await $.prompt.read()
$.prompt.fill #
(input: PromptFillArgs) => Promise<PromptFilled>
Puts `input.text` in the prompt box as the draft, by `mode`: `replace` (the default) over it, `append` after it, `insert` at the cursor.
The event `prompt.fill` through the other plugins' hooks; `isFilled: false` under a dialog or headless. To hand the model text WITH the next prompt instead, a `prompt.submit` hook adds `context` (second example).
await $.prompt.fill({ text: `> ${quote}\n`, mode: "insert" })
($, e, next) => next({ ...e, context: [...(e.context ?? []), hunk] })
$.prompt.suggest #
EventCalls['prompt']['suggest']
Proposes `input.text` as the prompt box's dim suggestion, Tab to take: the event `prompt.suggest`, as the engine's own guess after a turn.
It goes through every other plugin's hook with `e.origin` `{ kind: 'plugin', name }`, the engine's own suggestions on or off; `{ isShown: false }` while the box holds text, a turn runs, or headless (no box).
void $.prompt.suggest({ text: "run the tests you just wrote" })
$.tool
#
4 verbs
The tools the model has in this session, and running one.
$.tool.list #
() => Promise<ToolInfo[]>
Returns the tools the model can call now, built-in and MCP alike, in the order the model sees them.
const names = (await $.tool.list()).map(t => t.name)
$.tool.call #
EventCalls['tool']['call']
Calls a tool: the event `tool.call`, the same call the engine makes for the model's tool calls, under a `tool_use_id` of its own.
It runs through every hook but the calling one (the plugin's others see it), the permission check and its dialog, then the tool. Rejects when no tool has that name or the call is aborted.
const { text } = await $.tool.call({ tool: "Read", file_path: "a.md" })
$.tool.check #
EventCalls['tool']['check']
Asks the engine's permission decision for a tool call now: the event `tool.check`, resolved to `{ decision, reason?, rule? }`.
The hooks run (the calling hook's own frame skipped, `next.origin` this plugin, no `tool_use_id`); nothing runs, no dialog opens, no PreToolUse hook or classifier is asked.
const { decision } = await $.tool.check({ tool: "Read", input })
$.tool.register #
(tool: ToolSpec) => Promise<OpValueOf['tool.register']>
Declares a tool the model can call from the next prompt on: the name, description and input schema of `mcp__<plugin>__<name>`.
Serve it with a `tool.call` hook on `{ tool: "mcp__<plugin>__<name>" }` that returns the result (a call no hook answers fails); a name registered again is replaced. Rejects until the session binds, at `session.start`.
tool- `name`, `description` (what the model reads), `inputSchema` (a JSON schema object; default `{ type: "object" }`)
Returns `{ tool }`, the registered tool's full name `mcp__<plugin>__<name>`
await $.tool.register({ name: "weather", description: "Weather." })
$.command
#
3 verbs
The slash commands the person can run in this session, and running one.
$.command.list #
() => Promise<CommandInfo[]>
Returns the slash commands the person can run now, built-in, plugin and MCP alike, in the order the typeahead lists them.
const names = (await $.command.list()).map(c => c.name)
$.command.run #
EventCalls['command']['run']
Runs a slash command as if the person typed `/command args`: the event `command.run`, queued and run once the session is idle.
It runs through every hook but the calling one with `e.origin` `{ kind: 'plugin', name }`, its lines in the transcript. Rejects an unknown name, and inside a hook the turn is waiting on.
const { text } = await $.command.run({ command: "status" })
$.command.register #
(command: CommandSpec) => Promise<OpValueOf['command.register']>
Declares the slash command `/<name>` for this session, listed in the typeahead from the next keystroke on.
Serve it with a `command.run` hook on `{ command: "<name>" }` that returns `{ text }`; a run no hook answers says so as its output. Registering a name again replaces it; a built-in's name is refused.
command- `name`, `description` (what the menu shows), `argumentHint` (dim after the name), `immediate` (runs mid-turn)
Returns `{ command }`, the registered name
await $.command.register({ name: "hello", description: "Says hi." })
$.config
#
2 verbs
Every row of the settings menu (`/config`), the panel's own and each enabled plugin's `userConfig` fields alike: listing and changing them.
$.config.list #
() => Promise<ConfigRow[]>
Returns the rows the `/config` menu would draw now, in its order, each with its current value, its kind, its owner and its lock.
After every `config.describe` hook: a hidden row is left out, a relabelled one carries the new label.
const theme = (await $.config.list()).find(row => row.key === "theme")
$.config.set #
EventCalls['config']['set']
Changes one row as if the person did in the menu: the event `config.set` with `origin` `{ kind: 'plugin', name }`, then the writer.
Through the other plugins' hooks, this plugin's own skipped; `{ deny }` when a hook refused, the value does not fit, a trusted source owns the row or only its dialog changes it. Rejects a key no row has.
args- `key` (as `list` names it) and `value` (the row's kind)
Returns `{ value }` once written, or `{ deny }`
const { deny } = await $.config.set({ key: "verbose", value: true })
$.agent
#
3 verbs
Subagents.
$.agent.spawn #
EventCalls['agent']['spawn']
Spawns a subagent: the event `agent.spawn`, the same call the engine makes when the Agent tool starts one; the engine fills the rest.
It runs every hook but the calling one, then the Agent tool in the background under this call's origin: `{ model, agentId }` once the subagent started (its answer is its `turn.complete`), or `{ deny }`.
const { agentId } = await $.agent.spawn({ prompt: "Read README.md." })
$.agent.list #
() => Promise<AgentInfo[]>
Returns the session's subagents so far, the ones the model spawned and the ones plugins did alike.
$.agent.register #
(spec: AgentSpec) => Promise<OpValueOf['agent.register']>
Defines an agent type the Agent tool dispatches from the next turn on, named `<plugin>:<name>`: the event `agent.register`.
Every field takes effect as in an agent file; re-registered, a name is replaced; unloaded, a plugin's types go. `agent.offer` hides it from the model alone; any plugin's `$.agent.spawn` answers to `agent.spawn`.
spec- `name`, `description` (when to delegate), `prompt` (its system prompt), and any other field of an agent definition
Returns `{ agent }`, the full name; rejects until the session binds, on a spec the schema refuses (its reason), on a hook's deny
await $.agent.register({ name: "runner", description: "Runs a spec",
prompt: RUNNER_PROMPT, tools: ["Read", "Bash"], omitClaudeMd: true })
// runner-only: hidden from the model, spawned by this plugin's tool,
// answered by the subagent's turn.complete (matched by agentId)
on("agent.offer", { agent: "lab:runner" }, () => ({ isOffered: false }))
on("tool.call", { tool: "mcp__lab__run" }, async ($, e) => {
const { agentId, deny } = await $.agent.spawn({
subagentType: "lab:runner", prompt: e.spec, description: "run" })
return { result: deny ?? (await answerOf(agentId)) }
})
$.fs
#
6 verbs
The file system as the engine's own process reaches it; a relative path is under the session's working directory, and text is UTF-8.
An absolute path is used as given; where one may go is an `fs.*` hook's to say. A read or write over 4 MiB rejects, a foreign network location as spelled rejects untouched, and an OS refusal rejects with its errno.
$.fs.read #
FsReadCall
Reads a file and returns its text, or with `{ as: "bytes" }` its bytes as `{ base64 }`.
Rejects when missing, or over 4 MiB, which bounds what one read copies into the plugin's environment. A file the plugin ships is under `$.plugin.root`.
path- relative to the working directory, or absolute
options- `as`: `"text"` (the default) or `"bytes"`
Returns the file's text, or `{ base64 }`
const readme = await $.fs.read("README.md")
const { base64 } = await $.fs.read(
`${$.plugin.root}/hooks/weights.bin`, { as: "bytes" })
const weights = Uint8Array.fromBase64(base64)
$.fs.write #
(path: string, text: string) => Promise<void>
Writes `text` to a file, creating it and its directories as needed.
path- relative to the working directory, or absolute
text- the whole new content
$.fs.list #
(path?: string) => Promise<FsEntry[]>
Lists a directory: `{ name, kind, size, isLink }` per entry, by name, each entry as it stands (a symbolic link is `other` with `isLink`).
path- the directory's path; absent, the working directory
Returns the entries, `{ name, kind, size, isLink }` each
$.fs.exists #
(path: string) => Promise<boolean>
Returns whether the path exists; rejects only a network location as spelled, which no `fs` call reaches.
$.fs.stat #
(path: string, options?: FsStatOptions) => Promise<FsStat>
Returns `{ kind, size, mtimeMs, isLink }` of the path: what it leads to, and whether it is itself a symbolic link. Rejects when missing.
With `{ resolve: true }` also `realPath`, where the path lands, absent when it leads nowhere; a guard matches it and denies without it, since a spelling it cannot resolve (`~`, a file not there yet) the tool may open.
path- relative to the working directory, or absolute
options- `resolve`: also answer `realPath` (one more file system call)
Returns the stat, `realPath` with it when asked and resolvable; rejects `ENOENT` for a missing path
// ROOT was resolved the same way and SEP is its separator; an
// allow-list under it is the robust guard, a deny-list on spellings
// only best effort (a hard link or a case alias keeps its own)
on("tool.call", { tool: "Read" }, async ($, e, next) => {
const stat = await $.fs.stat(e.file_path, { resolve: true })
.catch(() => undefined)
const real = stat?.realPath
const isInside = real !== undefined && real.startsWith(ROOT + SEP)
return isInside ? next(e) : { deny: "outside the project" }
})
// a Write may name a file not there yet: `placed` answers where the
// path lands or undefined, and the guard denies on undefined or
// outside ROOT. Unplaceable by spelling first, with no file system
// call (drive-relative `D:x`, a `\\` or `//` network or device path,
// a name that is empty, ".", ".." or itself `C:...`); then the file
// if it stats; else its folder, cut after the last separator and
// keeping it so a drive or share root stays that root, plus the name
const placed = async (path) => {
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
const name = path.slice(cut + 1)
const isPlaceable = !/^[A-Za-z]:(?![\\/])/.test(path) &&
!/^[\\/][\\/]/.test(path) && !/^[A-Za-z]:/.test(name) &&
name !== "" && name !== "." && name !== ".."
if (!isPlaceable) return undefined
const own = await $.fs.stat(path, { resolve: true })
.catch(() => undefined)
if (own) return own.realPath
const folder = cut < 0 ? "." : path.slice(0, cut + 1)
const dir = await $.fs.stat(folder, { resolve: true })
.catch(() => undefined)
return dir?.realPath === undefined ? undefined
: `${dir.realPath.replace(/[\\/]$/, "")}${SEP}${name}`
}
const real = await placed(e.file_path)
const isInside = real !== undefined && real.startsWith(ROOT + SEP)
return isInside ? next(e) : { deny: "cannot place it, or outside" }
$.fs.ancestors #
(request: FsAncestorsRequest) => Promise<readonly FsAncestor[]>
Reads the named instruction files in every directory above the session's original working directory, the way the engine reads CLAUDE.md.
Root first, each `{ dir, name, content }` that exists, the content with its `@include`s after it; `of` and `below` together are the engine's own walk for a nested CLAUDE.md between root and read file.
request- `names`, relative `.md` file names (no `..`); `of`, the file walked down to; `below`, the directory the walk stays inside
Returns the files found, root first
const found = await $.fs.ancestors({ names: ["AGENTS.md"] })
const stack = await $.fs.ancestors({ names: ["AGENTS.md"], of: path })
const nested = await $.fs.ancestors({
names: ["AGENTS.md"],
of: e.file_path,
below: await $.session.root(),
})
$.store
#
4 verbs
This plugin's own key-value store, kept between sessions and hot reloads; values are JSON data.
A JSON file of the plugin's own under the user's Claude Code configuration directory.
$.store.get #
(key: string) => Promise<unknown>
Returns the value under `key`, or `undefined` when unset.
const count = Number((await $.store.get("count")) ?? 0) + 1
$.store.set #
(key: string, value: unknown) => Promise<void>
Sets `key` to `value`, which must be JSON data.
`get` reads back `JSON.parse(JSON.stringify(value))`: a Date is its ISO string, an `undefined` field is dropped, a Map or Set is `{}`. Rejects a function, a cycle, or a store over 4 MiB of JSON text in all.
$.clock
#
4 verbs
The time and timers, each an event through the host: `clock.now` reads the time; `clock.sleep`, `after` and `every` wait until it has passed.
A timer's callback is the plugin's own function, kept in its environment and run there when the wait resolves; a hot reload of the plugin cancels its pending waits with the old environment.
$.clock.now #
() => Promise<number>
Resolves milliseconds since the epoch, now.
const startedAt = await $.clock.now()
$.clock.sleep #
(ms: number, options?: SleepOptions) => Promise<void>
Resolves after `ms` milliseconds; rejects at once when `signal` aborts.
ms- how long, in milliseconds
options- `signal`: ends the wait early with a rejection (pass `next.signal` so a hook's wait ends with its dispatch)
await $.clock.sleep(500, { signal: next.signal })
$.clock.after #
TimerCall
Calls `fn` once after `ms` milliseconds; `cancel()` before then stops it.
One `clock.after` dispatch: `fn` runs when it resolves, and never when a hook refuses it.
$.clock.every #
TimerCall
Calls `fn` every `ms` milliseconds (at least 1) until `cancel()`.
One `clock.every` dispatch per period: `fn` runs when it resolves and the next period is asked; a refused period ends the interval.
const tick = $.clock.every(1000, () => $.ui.status("polling"))
$.http
#
1 verb
The network, through the host.
$.http.fetch #
(url: string, init?: HttpInit) => Promise<HttpResponse>
Fetches `url` through the host (never the plugin's own network) and resolves `{ status, ok, headers, text }` once the body is read.
http or https, to whatever the host process can reach, unless the administrator's policy switches refuse it; an `auth` handle from `$.session.authorize()` rides https only, to a first-party host.
url- the URL (http or https)
init- `{ method, headers, body, auth, socketPath }` (body a string; socketPath a Unix socket to go over instead of TCP)
Returns `{ status, ok, headers, text }` once the body is read
const { ok, text } = await $.http.fetch("https://example.com/status")
await $.http.fetch("http://bridge/reload", {
method: "POST",
socketPath: `${runDirectory}/bridge.sock`,
})
$.process
#
1 verb
Commands on the host, run as the user the session runs as. CLI only.
Local execution, not a network path: what a command of its own reaches is its own, as for the Bash tool and a settings `command` hook.
$.process.run #
(argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
Runs a command on the host by its argument vector (no shell) and resolves `{ exitCode, stdout, stderr }` once it exits, any exit code.
One shot: the whole output is read, so a background process left writing holds the call until the timeout. Rejects when the command cannot start or is still running then. Git runs with repo hooks off.
argv- the command and its arguments, `argv[0]` the executable
init- `{ cwd, env, stdin, timeoutMs }` (cwd the session's by default; timeout 30 s by default, ten minutes at most)
Returns `{ exitCode, stdout, stderr }` once the child exits
const { exitCode, stdout } = await $.process.run(["git", "status"])
$.settings
#
1 verb
What the settings files, `--settings` and managed policy hold, as the engine runs under it; read only.
Every key crosses as the source holds it, `env` and the helper commands included: nothing is filtered. The OAuth session and the global config (~/.claude.json) are not settings and are never read.
$.settings.read #
(args?: SettingsReadArgs) => Promise<Settings>
Resolves with the settings merged over every source, as the engine reads them, or with one source's settings as loaded (`{ source }`).
A snapshot in plain data each call; a source with no file answers `{}`. The sources (SettingsSource) rise in precedence from `user` to `policy`: the merge takes a key from the last source that has it.
args- `{ source }` to read one source; nothing for the merge
Returns the settings object, keyed as a settings.json is
const { permissions } = await $.settings.read()
const policy = await $.settings.read({ source: "policy" })
$.env
#
2 verbs
The environment of this process, the one every Bash child, MCP server and `$.process.run` command started after inherits.
`get` and `set` take the variable's name as a string literal, so what a module reads and writes is read off its source: `claude plugin validate` lists the names, and a name the module does not spell is refused.
$.env.get #
(name: string) => Promise<string | undefined>
Resolves with the variable's value, or `undefined` when it is unset.
`name` must be a string literal; `claude plugin validate` lists the names your module reads and writes.
const home = await $.env.get("HOME")
$.env.set #
(name: string, value: string | undefined) => Promise<void>
Sets the variable for this process and everything it starts after, or unsets it when `value` is `undefined`.
`name` must be a string literal; `claude plugin validate` lists the names your module reads and writes.
await $.env.set("GIT_PAGER", "cat")