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.319: 22 nouns carrying 89 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
In the first published surface (2.1.259)
This plugin, as loaded: its manifest name and its directory.
$.plugin.name #
In the first published surface (2.1.259)
string
From plugin.json; debug-log and $.ui.log lines carry it.
$.plugin.root #
In the first published surface (2.1.259)
string
The plugin's directory (the one holding plugin.json), absolute.
$.ui
#
18 verbs
In the first published surface (2.1.259)
Display: a line under an open dialog, a redraw or a repaint, a log line, panes the surface places, a window or ring, a surface's clipboard.
$.ui.notice #
In the first published surface (2.1.259)
(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 #
In the first published surface (2.1.259)
(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 redraw: ten a second at most, thirty in the terminal for a shown pane, expanded band and prompt hint (sooner calls fold). A cached answer drops next turn; a changed tool or section text rewrites what a thread sent, which loses its thinking and the prompt cache, so do not call it for these once a thread has sent.
eventui.render, or a cached-answer event:prompt.section,prompt.context,prompt.attachment,tool/command/config.describe
$.ui.blit #
Added in 2.1.271
(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.
argsrequestId(the site),key, andcells(RasterProps) orsource(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 #
In the first published surface (2.1.259) · changed in 2.1.267
<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.renderargument (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 #
In the first published surface (2.1.259) · changed in 2.1.275
(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 it either way, to 10000 characters. Raised as ui.log: a hook above may rewrite e.to.
text- the line's text, of any length: the terminal draws its first 2000 characters, a remote surface 10000
optionsto:transcript(the default) ordebug
$.ui.log(`prompt from ${e.origin.kind}: ${e.text.length} chars`)
$.ui.log(`cache miss for ${e.tool_use_id}`, { to: "debug" })
$.ui.ask #
In the first published surface (2.1.259) · changed in 2.1.268
(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 #
In the first published surface (2.1.259)
(text: string, options?: ToastOptions) => void
Shows text for a few seconds under the plugin's name: a small box on the stack of plugin toasts over the transcript's top right corner.
A click takes it off, the pointer over it holds it. Where the transcript is printed into scrollback (nothing to float over) it is one line on the notification bar. It leaves the transcript and the model untouched.
text- the line to show, its first 2000 characters drawn (10000 remotely); an unpaired surrogate half in it is drawn as U+FFFD
optionstimeoutMs: how long it stays (default 4000)
$.ui.toast(`turn took ${Math.round(e.durationMs / 1000)} s`)
$.ui.notify #
Added in 2.1.295
(text: string, options?: NotifyOptions) => Promise<UiNotifyResult>
Raises a native notification of text through the person's own notification channel, the one their preferredNotifChannel names.
Their Notification hooks run first, reading plugin_notification, then the channel writes. Raised as ui.notify; nothing limits how often. With no terminal the hooks alone run; unbound or exiting, none.
text- the notification's body, sent as given, of any length
optionstitle: what heads it (default: the plugin's name)
Returns { isSent: true, channel }, or { isSent: false, reason }: turned off (disabled), no-channel, no-surface, refused
const sent = await $.ui.notify("tests passed", { title: "CI" })
$.ui.status #
In the first published surface (2.1.259)
(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, its first 2000 characters drawn (10000 remotely); undefined clears it
$.ui.status("thinking..."); return next(e)
$.ui.open #
Added in 2.1.265 · changed in 2.1.280
(pane: PaneOpenArgs) => Promise<UiOpenResult>
Opens a pane, a framed region the surface places and this plugin draws by hooking ui.render for { component: "Pane" }; says if it is drawn.
One per id (an open id retitles; a ui.open hook may refuse). Asked, the person's command, prompt or press behind it (not focus), it is placed at any width; unasked, from 144 columns, 110 for one once asked.
paneid(1-64 of letters, digits,_,-),title,focus,closeOnEscapeandholdToasts(a dialog),backgroundColor(what is painted under its body),rows/columnswanted
Returns { isPlaced: true } once drawn (or retitled), else { isPlaced: false, reason } (UiOpenResult); dock or inline is on Pane props
await $.ui.open({ id: "clock", title: "Clock" })
await $.ui.open({ id: "ask", focus: true, closeOnEscape: true, rows: 9 })
const opened = await $.ui.open({ id: "clock" })
if (!opened.isPlaced) $.ui.log("clock: " + opened.reason)
$.ui.close #
Added in 2.1.265
(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.
paneid: 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 #
Added in 2.1.275
() => 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.place #
Added in 2.1.314
(args: UiPlaceArgs) => Promise<UiPlaceResult>
Places one row at the transcript's end, which scrolls with the conversation and draws what ui.render answers for Placed.
A hook reads e.props.id and e.props.plugin. Display only: the model and the transcript file never read it, and --resume brings none back. A hook that passes draws nothing; the same id answers the row that stands.
await $.ui.place({ id: "build" })
$.ui.scroll #
Added in 2.1.271
(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.
argsto(what),in(which site, required forstartandend),block(where it lands;nearestby default)
Returns {} once it moved, or { deny } saying why not
onPress: () => $.ui.scroll({ in: "log", to: "end" })
$.ui.focus #
Added in 2.1.271
(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 ui.focus, origin plugin; the engine's inverse marks it. A site not holding the keys, or holding them on another's element, is { deny }; an element not yet drawn is awaited, bounded: no retrying.
argsrequestId(which site: a pane's id, the band's) andkey(the element's, as drawn)
Returns {} once it moved, or { deny } saying why not
await $.ui.open({ id: "files", focus: true })
await $.ui.focus({ requestId: "files", key: "row:0" })
$.ui.copy #
Added in 2.1.280
(args: UiCopyArgs) => Promise<UiCopyResult>
Puts text on the clipboard of a surface the session draws on, as the DOM's navigator.clipboard.writeText, and says whether it took.
surface names which (a press hook passes e.surface); left out, the session's first. Raised as ui.copy with that target on e. The terminal writes as /copy does; a remote surface has no path yet.
argstext, what the person pastes;surface, where
Returns { isCopied: true }, or { isCopied: false, reason }
onPress: press => $.ui.copy({ text: url, surface: press.surface })
$.ui.selection #
Added in 2.1.288
() => Promise<UiSelection | undefined>
Returns what the person last selected with the mouse: the text as a copy would take it, and the transcript row it lies in.
A key or a click takes the highlight down before a command or a press runs, so the answer stays what the person last selected, until they select again, dismiss it, or their next prompt or command has run.
Returns the selection; undefined with nothing selected, and where none is seen: fullscreen off, -p, a surface that answers none
const selected = await $.ui.selection()
$.ui.view #
Added in 2.1.300
(view: SiteView) => Promise<UiViewResult>
Puts an agent's transcript in the main view, as the person's own switch from the tasks list does; {} goes back to the main one.
Only while the call answers the person's own input (a press, a pick, their prompt or command): the viewed agent takes the prompt's next Enter. A site reads the view in force as e.props.view.
viewagentId: the agent'sidin$.agent.list(); absent for the main conversation
Returns {} once the view shows it, or { deny } saying why not
onPress: () => $.ui.view({ agentId: agent.id })
onPress: () => $.ui.view({})
$.theme
#
2 verbs
Added in 2.1.319
The session's one theme: how the app looks behind and around the conversation. One theme stands at a time, the latest set, whichever plugin set it; there is no stack.
$.theme.set #
Added in 2.1.319
(attributes: ThemeSetArgs) => Promise<ThemeResult>
Sets the session's theme from one object of named top-level attributes. backdrop is drawn behind the conversation by what ui.render answers for Backdrop: full window on desktop under its frosted panels, full screen on mobile under one veil (tint and opacity, not yet blur). Display only: --resume brings none back and a plugin that unloads takes its own with it, so set it at session.start.
attributesbackdrop:id,glass(blur,tint,opacity) orfalse, andcolorScheme(lightordark)
Returns { isDrawn: true, requestId }, or isDrawn: false and why
await $.theme.set({ backdrop: { id: "lagoon", glass: { blur: 14, opacity: 0.55 } } })
$.theme.clear #
Added in 2.1.319
() => Promise<ThemeResult>
Clears the session's theme, back to the app's own look.
IT CLEARS WHATEVER THEME STANDS, WHICHEVER PLUGIN SET IT: there is one theme, so a clear is the session's and not the caller's own. Clear on a person's ask (a command, a button), never at session.start or on a reload: a plugin that clears there wipes another plugin's theme each time.
Returns isDrawn: false and why
await $.theme.clear() // a preview ends with no theme to go back to
$.model
#
3 verbs
In the first published surface (2.1.259)
Completions through the session's own client and credentials.
$.model.complete #
In the first published surface (2.1.259) · changed in 2.1.280, 2.1.284
(request: ModelCompleteRequest, options?: ModelCompleteOptions) => Promise<ModelCompleteResult>
Runs one text completion through the session's own API client and resolves a result: the reply's text and cost, or why there is no text.
No tools, no history, no system prompt beyond the CLI's identity block and request.system; ModelCompleteRequest says the rest. Only a request the engine refuses to send (a blocked model, a bad cap) rejects.
request- the model (an alias such as
haiku, or a full id resolved like--model), prompt, cap, effort, time limit optionssignal: the plugin's own AbortSignal; aborting it cuts the call while it runs, which then resolvesaborted
Returns the reply (isAnswered, text, usage), else the reason: api-error with status and error, empty-reply, aborted
const r = await $.model.complete({ model: "haiku", prompt, effort })
// one handler starts it, a later one cancels it while it runs
let stop = new AbortController()
const start = async () => {
stop = new AbortController()
const r = await $.model.complete(ask, { signal: stop.signal })
if (!r.isAnswered && r.reason === "aborted") $.ui.log("cancelled")
}
const cancel = () => stop.abort()
$.model.fork #
In the first published surface (2.1.259) · changed in 2.1.268, 2.1.280
(request: ModelForkRequest) => Promise<ModelForkResult>
Runs one tool-less completion over the session's OWN transcript as the main thread last sent it, so the API serves that prefix from its cache.
The main thread's last request again (its model, system prompt, tools, messages) with prompt after it: every tool denied, its own tail never cached, the prefix billed afresh once the entry lapsed or after /model. It thinks as hard as the session does unless effort says otherwise; ModelForkRequest says what holds that level and when it costs the cache.
request- the one user message the fork answers, and its effort
Returns always a result, never null: the reply (isAnswered, text, usage, the effort sent), or why there is no text (reason: nothing-to-fork before the first request and after /clear, api-error with its HTTP status and error kind, empty-reply, or aborted when the turn whose hook forked was interrupted); a request of the wrong shape (no prompt, an effort that is no level) rejects instead
const reply = await $.model.fork({ prompt: "One line to learn?" })
$.ui.log(reply.isAnswered ? reply.text : `no reply: ${reply.reason}`)
// a quick side job in a session the person runs at max
const cut = await $.model.fork({ prompt: "Shorter.", effort: "low" })
$.model.classify #
In the first published surface (2.1.259)
(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)
optionsmodel: 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
In the first published surface (2.1.259)
Sound: clip playback and platform speech.
$.audio.play #
In the first published surface (2.1.259)
(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; afplay plays it on macOS, and a Linux or Windows terminal, having no player, plays nothing. Resolves once played or skipped; 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 optionsshouldLoop,gain, and an AbortSignal that stops the clip
await $.audio.play({ asset: "sounds/done.wav" })
$.audio.speak #
In the first published surface (2.1.259)
(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
optionsvoice: the system voice's exact name (Samantha); absent, the synthesizer's default
Returns which synthesizer spoke, once the utterance has ended
$.mcp
#
2 verbs
In the first published surface (2.1.259)
The engine's MCP servers: calling a connected one's tools, and connecting one the plugin lists itself.
$.mcp.call #
In the first published surface (2.1.259)
(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 spellingclaude_ai_Gmailis 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",
})
$.mcp.connect #
Added in 2.1.285
(server: string) => Promise<McpConnectResult>
Connects one of the MCP servers this plugin's own manifest lists, if the session has not already: a listed server joins it at its start.
So listing a server defers nothing: to hold its tools back, refuse their calls at tool.check. The same server run under another name answers with that name; a refusal resolves with reason, message.
server- the server's key in this plugin's manifest
Returns connected, with the name call takes, or why not
const browser = await $.mcp.connect("browser")
if (browser.isConnected) $.mcp.call(browser.server, "open", { url })
$.session
#
16 verbs
In the first published surface (2.1.259)
The running session, read as plain data; compacting it; and sending a message from it to another agent or session.
$.session.messages #
In the first published surface (2.1.259) · changed in 2.1.280
SessionMessagesCall
Returns the transcript so far, one SessionMessage per user or assistant message; progress rows, $.ui.log lines and notices are not messages.
With { agentId }, one of this session's agents' instead (a subagent, a fork, a teammate in this process): what the session saved for it joined with what the engine holds. With { as: "api" }, either as ApiMessage.
argsagentId, the idtool.call,turn.completeand$.agent .list()carry;as: "api"for ApiMessage; hooks see both
Returns the newest 4096 entries, { role, text, toolUses } (a user message may add toolResults; a toolUses entry adds result and text once answered, an Agent tool use its agentId), or with as { role, content }, blocks intact, at most 4096 opening on a user message; for an agentId the session cannot read, { deny } (SessionMessagesDeny), never main
const last = (await $.session.messages()).at(-1)
const request = await $.session.messages({ as: "api" })
await $.http.fetch(AUDIT_URL, {
method: "POST",
body: JSON.stringify({ session: await $.session.id(), request }),
})
on("turn.complete", async ($, e, next) => {
if (e.agentId === undefined) return next(e)
const found = await $.session.messages({ agentId: e.agentId })
if (!("deny" in found)) $.ui.log(`${found.length} messages`)
return next(e)
})
$.session.cwd #
In the first published surface (2.1.259)
() => Promise<string>
Returns the directory the session runs in, absolute.
A timer that outlives its hook reads the session's directory; keep the value read inside the hook if you need the hook's (a subagent's own).
$.session.root #
Added in 2.1.275
() => 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.model #
In the first published surface (2.1.259)
() => Promise<string>
Returns the main loop's model, as /model shows it.
$.session.turns #
Added in 2.1.268
() => Promise<number>
Returns how many prompts the user has sent this session (user turns in the transcript).
$.session.id #
In the first published surface (2.1.259)
() => Promise<string>
Returns the session's id (the transcript file's name).
$.session.repo #
In the first published surface (2.1.259)
() => 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 #
Added in 2.1.269
() => 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 #
In the first published surface (2.1.259)
() => Promise<RenderSurface | null>
Returns the first of $.session.surfaces(), or null where nothing draws.
$.session.usage #
Added in 2.1.267 · changed in 2.1.271
(args?: SessionUsageArgs) => Promise<SessionUsage>
Returns when the session began, and the context window's fill, the rate-limit windows and the cost as the status line has them, itemized.
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 { startedAt, context, rateLimits, cost } as the status line has them
const { context } = await $.session.usage()
if ((context.percent ?? 0) >= 85) await $.session.compact()
const isOlder = stat.mtimeMs < (await $.session.usage()).startedAt
const usage = await $.session.usage({ breakdown: "full", columns })
for (const row of usage.context.breakdown?.gridRows ?? []) draw(row)
$.session.version #
Added in 2.1.281
() => Promise<SessionVersion>
Returns the version of the engine the session runs on, the release it is built from, and when it was built.
The same three values the engine's own analytics rows carry, answered in every mode and build; base is absent when the version is not spelled as a release, builtAt in a run from source that stamps none.
Returns the full version, its release base (2.1.280, or 2.1.280-dev for a development build) and an ISO builtAt
const { version, base, builtAt } = await $.session.version()
row.env = { version, version_base: base, build_time: builtAt }
const isRelease = !(await $.session.version()).base?.endsWith("-dev")
$.session.compact #
Added in 2.1.267
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.send #
Added in 2.1.280
EventCalls['session']['send']
Sends a plain-text message to another agent or session: the event session.send, the model's SendMessage tool's own call and delivery.
to is the tool's spelling (a name, an agent id, a received from address) or { sessionId } / { agentId }; framed at the receiver as a peer's, origin.plugin naming this plugin there. Resolves once queued.
const sent = await $.session.send({ to: { sessionId }, text: "ok" })
await $.session.send({ to: { agentId }, text: "stop after this file" })
$.session.append #
Added in 2.1.285
(args: SessionAppendArgs) => Promise<SessionAppendResult>
Appends a row to a conversation of the session: the event session.append, the engine's own call for every row it keeps.
A user-role row the person does not see as typed or a notice, of text blocks alone in this release (else refused with the reason): listed at once. { deny: reason } when a plugin above refused it, nothing stored.
await $.session.append({ message: { type: "user", content: [note] } })
$.session.authorize #
Added in 2.1.268
() => 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 with no first-party credential (a 3P provider, a gateway, no login).
await $.http.fetch(url, { auth: (await $.session.authorize())?.handle })
$.session.identity #
Added in 2.1.299
() => Promise<SessionIdentity>
Returns how the session is signed in, to a mod the organization's managed settings install; { deny } to any other. No request is made.
The account (organization, plan, email) only where managed forceLoginOrgUUID lists its organization; else kind alone. A contract, not a wall: any mod can read the person's own config file.
const { kind, organizationId, email } = await $.session.identity()
$.turn.abort #
In the first published surface (2.1.259)
(input: OpEventOf['turn.abort']) => Promise<void>
Cancels the main loop's running model turn (the id its 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.
inputturnId: the id the main loop'sturn.startcarried
on("turn.start", ($, e, next) => {
if (e.agentId === undefined) held = e.turnId
return next(e)
})
$.prompt
#
7 verbs
In the first published surface (2.1.259)
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 #
In the first published surface (2.1.259)
EventCalls['prompt']['submit']
Submits a prompt: the event prompt.submit, the same call the engine makes for a typed prompt; a turn of its own, once 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 #
Added in 2.1.275
() => 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.media #
Added in 2.1.299
(args: PromptMediaArgs) => Promise<PromptMedia>
Returns one image or document of the prompt being submitted, by the id e.attachments lists it under: its bytes, base64, 4 MiB a call.
For a prompt.submit hook; else { deny }, as once the submission has settled. The engine's own read, no event: no hook answers for it. { unreadable }: no image.
const media = id ? await $.prompt.media({ id }) : undefined
$.prompt.fill #
Added in 2.1.268 · changed in 2.1.275
(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.paint #
Added in 2.1.311
(input: PromptPaintArgs) => Promise<PromptPainted>
Sets this plugin's own decorations over the draft and nothing else.
No text, caret or undo entry, no prompt.change or prompt.edit. Hooks may rewrite or { deny } it. isPainted: false, the box handed back, when the draft no longer reads text, or another plugin hooks its read.
await $.prompt.paint({ text, decorations: [{ start, end, bold: true }] })
$.prompt.suggest #
Added in 2.1.268
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" })
$.prompt.compose #
Added in 2.1.286
EventCalls['prompt']['compose']
Returns the system prompt's sections for facts: the event prompt.compose, the call the engine makes for every prompt it sends.
A fact left out is the session's own (its model, its tools). Through every other plugin's hook, over the engine's own composition; composed for nobody to send, so nothing the session holds is written.
const ids = (await $.prompt.compose()).sections.map(s => s.id)
$.tool
#
4 verbs
In the first published surface (2.1.259)
The tools the model has in this session, and running one.
$.tool.list #
In the first published surface (2.1.259)
() => 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 #
In the first published surface (2.1.259)
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 #
Added in 2.1.269
EventCalls['tool']['check']
Asks the engine's permission decision for a tool call now: the event tool.check, resolved to its verdict ({ decision, reason?, ... }).
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 #
In the first published surface (2.1.259)
(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.
toolname,description(what the model reads),inputSchema(default{ type: "object" }),isDeferred(ToolSpec)
Returns { tool }, the registered tool's full name mcp__<plugin>__<name>
await $.tool.register({ name: "weather", description: "Weather." })
$.command
#
3 verbs
Added in 2.1.265
The slash commands the person can run in this session, and running one.
$.command.list #
Added in 2.1.265
() => 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 #
Added in 2.1.265
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, inside a hook the turn or the session's start waits on, past a Stop or a host's limit.
const { text } = await $.command.run({ command: "status" })
$.command.register #
Added in 2.1.265
(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.
commandname,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
Added in 2.1.269
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 #
Added in 2.1.269
() => 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 #
Added in 2.1.269
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.
argskey(aslistnames it) andvalue(the row's kind)
Returns { value } once written, or { deny }
const { deny } = await $.config.set({ key: "verbose", value: true })
$.telemetry
#
2 verbs
Added in 2.1.284
Analytics: first-party rows and feature marks for Anthropic, and records for the telemetry collector the session's operator configured.
$.telemetry.log #
Added in 2.1.284
(entry: TelemetryLogArgs) => Promise<void>
Logs one record to the destination entry.to names, the event telemetry.log; e.to is pinned, and anthropic when left out.
A first-party row is queued by the built-in plugin that hooks the event, for built-ins and the engine alone. A collector record reaches the collector only when the engine raised it; any plugin may hook.
entry- a first-party row, or a collector record
Returns once the hooks have answered; rejects when one denies
await $.telemetry.log({
to: "anthropic",
event: "survey_answered",
props: { answer: 2, page: { value: "ready", of: ["ready", "later"] } },
})
$.telemetry.mark #
Added in 2.1.284
(entry: TelemetryMarkInput) => Promise<void>
Marks one use of a feature as the CLI's own feature events do, the event telemetry.mark; it takes no to and always means anthropic.
The engine does nothing with it: the built-in plugins that hook the event queue the row. They serve built-ins and the engine alone; any plugin may hook it.
entry- the feature, how its use went, why, and its properties
Returns once the hooks have answered; rejects when one denies
await $.telemetry.mark({ feature: "learn_page", kind: "sad",
reason: "blocked" })
$.agent.spawn #
In the first published surface (2.1.259)
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 }.
{ deny } is a hook's refusal, or the engine's own with its reason: past a Stop or a host's limit nothing is spawned, as for a prompt.
const { agentId } = await $.agent.spawn({ prompt: "Read README.md." })
$.agent.list #
In the first published surface (2.1.259)
() => Promise<AgentInfo[]>
Returns the session's agents so far, subagents and teammates: the ones the model spawned and the ones plugins did alike.
One entry an agent, until the engine drops its task: as a teammate ends or seconds after, a subagent's later. Loops the engine tracks as agents with no agent.spawn (a forked skill) are here; a workflow's are not.
$.agent.register #
Added in 2.1.274
(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.
specname,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
In the first published surface (2.1.259)
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, the implementation beneath the hooks rejects a network location untouched, an OS refusal with its errno.
$.fs.read #
Added in 2.1.267 · changed in 2.1.277
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
optionsas:"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 #
Added in 2.1.267
(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 #
Added in 2.1.267
(path?: string) => Promise<FsEntry[]>
Lists a directory by name: { name, kind, size, mtimeMs, isLink } per entry, each entry as it stands.
A symbolic link is other with isLink; size and mtimeMs are a regular file's own and 0 for every other kind ($.fs.stat has those).
path- the directory's path; absent, the working directory
Returns the entries, { name, kind, size, mtimeMs, isLink } each
const logs = await $.fs.list("logs")
const newest = [...logs].sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
$.fs.exists #
In the first published surface (2.1.259)
(path: string) => Promise<boolean>
Returns whether the path exists; rejects only a network location, which the implementation beneath the hooks never touches.
$.fs.stat #
In the first published surface (2.1.259) · changed in 2.1.277
(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
optionsresolve: also answerrealPath(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 #
In the first published surface (2.1.259)
(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 @includes after it; of and below together are the engine's own walk for a nested CLAUDE.md between root and read file.
requestnames, relative.mdfile 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
In the first published surface (2.1.259)
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 #
In the first published surface (2.1.259)
(key: string) => Promise<unknown>
Returns the value under key, or undefined when unset.
const count = Number((await $.store.get("count")) ?? 0) + 1
$.store.set #
In the first published surface (2.1.259)
(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.
$.store.delete #
In the first published surface (2.1.259)
(key: string) => Promise<void>
Removes key from the store.
$.store.keys #
In the first published surface (2.1.259)
() => Promise<string[]>
Returns every key set, in insertion order.
$.state
#
2 verbs
Added in 2.1.281
Named values held by the host for the session, each with a version: plain data that survives a hot reload of the plugin's code.
A get made while a ui.render hook draws subscribes that instance: a later set draws it again, nobody calling $.ui.invalidate. Any plugin reads any value; its owner alone writes it. Persist through $.store.
$.state.get #
Added in 2.1.281
<P extends keyof PluginState & string, K extends keyof PluginState[P] & string>(ref: StateRef<P, K>) => Promise<StateRead<StateValue<P, K>>>
Resolves the value under ref and the version it stands at; a value never written is undefined at version 0.
Every get of one dispatch reads one moment, whatever is written meanwhile. plugin and key must be literals in source (claude plugin validate lists them); only a family member's id may be computed.
ref{ plugin, key }as the owner's contract declares it in PluginState, withidfor a StateFamily key
Returns { value, version }
const workers = { plugin: "swarm", key: "workers" } as const
const { value = [] } = await $.state.get(workers)
$.state.set #
Added in 2.1.281
<P extends keyof PluginState & string, K extends keyof PluginState[P] & string>(ref: StateRef<P, K>, value: StateValue<P, K>, options?: StateSetOptions) => Promise<StateSetResult>
Writes value under ref, which must be this plugin's own; the sites that read it while drawing are drawn again, at the redraw rate.
Refused while a ui.render hook draws (write from onPress or another event) and for another plugin's value (hook its state.set and rewrite e.value). JSON data, as $.store.set takes; never undefined.
ref{ plugin, key }, withidfor a StateFamily keyvalue- the value, of the type the contract declares
optionsifVersion: write only while it stands at that version
Returns { isSet, version }; isSet false when ifVersion missed
await $.state.set(count, held.value + 1, { ifVersion: held.version })
$.clock
#
4 verbs
In the first published surface (2.1.259)
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 #
In the first published surface (2.1.259) · changed in 2.1.271
() => Promise<number>
Resolves milliseconds since the epoch, now.
const startedAt = await $.clock.now()
$.clock.sleep #
In the first published surface (2.1.259)
(ms: number, options?: SleepOptions) => Promise<void>
Resolves after ms milliseconds; rejects at once when signal aborts.
The wait is the hook's own time and its budget runs on through it, as through no other $ call: a turn.step generator that polls with it pays every sleep out of its one budget (next.budget.remainingMs).
ms- how long, in milliseconds
optionssignal: ends the wait early with a rejection (passnext.signalso a hook's wait ends with its dispatch)
await $.clock.sleep(500, { signal: next.signal })
$.clock.after #
In the first published surface (2.1.259)
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 #
In the first published surface (2.1.259)
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.fetch #
In the first published surface (2.1.259)
(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 reaches, unless the organization's web-fetch policy refuses it. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC refuses a built-in's request, and any plugin's that carries auth.
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
#
2 verbs
Added in 2.1.260
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 #
Added in 2.1.260
(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 }, each stream's first 4194304 bytes (isStdoutTruncated, isStderrTruncated say when cut)
const { exitCode, stdout } = await $.process.run(["git", "status"])
$.process.spawn #
Added in 2.1.280
(request: ProcessSpawnRequest) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>
Starts a command on the host by its argument vector (no shell) and streams what it writes, piece by piece, then how it ended.
The loop is the child's life: leaving it, return() on the stream, next.signal aborting or the module unloading kills the child, and nothing else does, a hook's return included: end the loop to end it.
request{ argv, cwd, env, input },$.process.run's rules;efor a hook onprocess.spawn(a generator, its budget per piece)
Returns the pieces { stream, text } (text as it came, not lines), then { code, signal }; rejects its first pull if it cannot start
// inline: the hook's dispatch waits on the loop; leaving it kills make
const make = $.process.spawn({ argv: ["make"], cwd: "build" })
for await (const { stream, text } of make) {
if (stream === "stderr" && text.includes("error")) break
}
// a child for the session's life: the loop runs on after the hook
// returns, and ends with the child or with the module
on("session.start", async ($, e, next) => {
const started = await next(e)
void (async () => {
const bridge = $.process.spawn({ argv: ["bridge", "watch", e.cwd] })
for await (const { text } of bridge) $.ui.log(text, { to: "debug" })
})()
return started
})
// above another plugin's spawn: redact each piece before it reads it
on("process.spawn", async function* ($, e, next) {
for await (const chunk of next(e)) {
yield { ...chunk, text: chunk.text.replaceAll(token, "***") }
}
})
$.settings
#
1 verb
Added in 2.1.267
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 #
Added in 2.1.267 · changed in 2.1.304
SettingsReadOverloads
Resolves with the settings merged over every source, with one source's settings as loaded ({ source }), or with the value of one top-level key ({ key }), undefined when the settings hold none.
A snapshot in plain data each call; a source with no file answers {}. Every read raises settings.read once, its layers on e (SettingsReadInput); a read of one key is the read of the object, projected, and raises no event of its own.
The sources (SettingsSource) rise in precedence from user to policy; with no hook the object is the engine's merge: a scalar from the last source that has it, lists joined, objects merged.
Raised by a plugin's call, and by the engine for its own merge once the loaded plugins or the settings have changed, next.origin then the engine. What the hooks answer there is what every reader of the merged settings reads, and a changed answer takes effect as a changed settings file does.
No key is closed to a hook, the keys that decide which plugins load included: those are read before any plugin is loaded, so the first set loads by the files' own values and an answer applies from the load after. A merge that never settles, an answer changing which plugins load and those answering otherwise, is told in the settings error list, and the files' own value stands for that key.
args{ source }for one source,{ key }for one key's value, nothing for the merge
Returns the settings object, keyed as a settings.json is, or the key's value
const { permissions } = await $.settings.read()
const policy = await $.settings.read({ source: "policy" })
const days = await $.settings.read({ key: "cleanupPeriodDays" })
$.env
#
2 verbs
Added in 2.1.267
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 #
Added in 2.1.267
(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 #
Added in 2.1.267 · changed in 2.1.301
(name: string, value: string | undefined) => Promise<EnvSetResult>
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.
Returns {} once it is set or unset, or { deny } saying why not
await $.env.set("GIT_PAGER", "cat")