Operators add in-process extensions as plugins. A plugin is one
directory, one plugin.json, and one assembly in its own load context.
It can contribute tools, a protocol client, approval classifiers, slash
commands, hooks, raw hooks, and prompt placeholders. After the host has
loaded plugins, external tools, and skills, it calls Attach with a
read-only snapshot of those catalogs, then AttachSession with the live
session and review model. A plugin that implements IPluginModelClient
also receives clients for those models. A plugin with an admitted status
field then receives AttachStatus.
The host owns catalog registration, approval, workspace fencing, output
truncation, and timeouts. Plugin code does not bypass
ToolInvocationPolicy.
| Mechanism | Role |
|---|---|
PluginRegistry |
First-party in-process contributions (built-in tools and the six wire protocols). |
| External tool sets | Model-callable tools under tools/. They do not implement this contract and have no hooks. See external-tools.md. |
| Skills | Markdown instructions loaded through the skill tool. |
| MCP | Deferred. It is not a plugin runner. |
The plugin has no JSON identity field. The plugin is its directory
under plugins/.
~/.crystal/plugins/<directory>/plugin.json
<workspace>/.crystal/plugins/<directory>/plugin.json
Runtime data lives separately under data/plugins/<directory>/ in the same
two trees; see Data directories.
Overlay key: the directory name. A project directory replaces the home
plugin of the same name as a whole. Directory names are 1–64 characters,
start with a letter, then A–Z a–z 0–9 _ . -.
IPlugin.Name is the name shown by /plugins. It must be non-empty.
Two loaded plugins cannot share it.
config.json field plugins enables discovery (default true). When
true, the field is omitted from the written file. When false,
manifests are not scanned. crystal run --plugins on|off overrides that
switch for one process and is not written back.
plugin.json field enabled turns one plugin on or off (default
true). "enabled": false omits that plugin after overlay, with no
operator note. A missing, unreadable, or invalid manifest skips that
plugin and records an English note. The session still starts.
/plugins lists loaded plugins and any directory that is not loaded.
/plugins on, /plugins off, and /plugins reload persist the discovery
switch when it changes and reload catalogs. /plugins enable <directory>,
/plugins disable <directory>, and /plugins show <directory> change or
show one manifest. Prefix home or project to choose that tree. Without
a prefix, a project directory wins over Home. The command reloads catalogs
after a change. It does not create a directory. A broken manifest is left
unchanged.
crystal plugins list, show, enable, and disable do the same outside
a session. --source home|project chooses the tree. --workspace chooses
the project root. --home chooses the data directory. The default output
is a Spectre.Console table. --format text prints the same fields as
aligned plain text. These commands write enabled in plugin.json and
do not write config.json. Turning one plugin on does not turn discovery
on. crystal run --plugins remains a process-only override.
plugin.json is JSON. Unknown fields are ignored.
{
"enabled": true,
"assembly": "Acme.Plugin.dll",
"type": "Acme.AcmePlugin"
}assembly is a path inside the plugin directory. type is the public
entry type. It implements CrystalCode.Plugins.IPlugin and has a public
parameterless constructor.
Reference CrystalCode.Plugins. Reference CrystalCode.Tools only when
a tool needs ToolHostContext. Do not reference CrystalCode.Engine,
CrystalCode, CrystalCode.Display, or CrystalCode.Providers.
Contribute returns tools, client factories, classifiers, commands,
hooks, raw hooks, prompt placeholders, and status fields. A null entry is omitted with a note. One bad plugin does not
stop the others.
Attach runs after Contribute, once plugins, external tools, and skills
are loaded, and again when those catalogs reload. It runs before the
session-start hook. The default method does nothing. Reading the snapshot
inside Contribute is too early: the host has not finished the other
catalogs.
A tool implements IPluginTool: a model-facing Name, Catalogs,
an ITool, and an optional IMultimodalTool with the same name.
Catalogs is PluginToolCatalogs.Plan, Work, or PlanAndWork.
A tool must name at least one catalog. The same choice applies to its
multimodal tool.
Names match ^[A-Za-z][A-Za-z0-9_-]*$ (1–64 characters) and must not be
a built-in name. The definition name must match Name. Later duplicates
are omitted with a note.
Catalog order is built-in plugin tools, disk plugin tools, external
tools, then skill when enabled. A disk tool name is reserved against
external tools.
The plugin constructs the tool. Host facts arrive per call when the tool
implements IHostTool or IHostMultimodalTool. The tool does not
receive the question prompt.
IPluginClientFactory.CanCreate returns true only for protocols that
factory owns. A factory that returns true for deepseek, openai,
responses, anthropic, gemini, or ollama is omitted. Built-in
adapters stay first. A custom protocol is a token of 1–64 characters:
a letter, then letters, digits, _, or -. Put that token in
providers.json as protocol.
PluginClientRequest carries the endpoint, model sampling, and API key
for that call. Do not log the key. It also carries declared audioInput
and videoInput. Those flags do not make Create or CreateMultimodal
accept audio or video. CreateMultimodal is used only when
the selected model accepts image input and no built-in factory owns the
protocol.
IPluginClassifier runs only after the built-in switch misses. It
assigns risk and authority for an unknown tool. It does not reclassify
read, bash, or the other built-in names.
IPluginCommand adds a /name verb. Built-in verbs and their aliases
win. Names use the same pattern as tool names. IPluginOutput.Write
and Fail become session notes.
IPluginPlaceholder adds a {{name}} token. The name is 1–64 characters:
a letter, then letters, digits, or _. Matching is case-insensitive. The
host rejects a name it already owns. Two plugins cannot share a name; the
first in load order wins and the later one is omitted with a note.
The host calls Resolve each time it binds a template, including
attachments, Review, compaction, and image description. The context carries the mode,
workspace, session id, approval mode, provider, model, the current
catalog snapshot, and the live session and review model. Strings the host
does not have yet are empty. The
returned text is inserted as-is. Tokens inside that text are not expanded
again. A throw, or a missing return, leaves the original token and records
an English note.
Before the first Attach, the snapshot on that context is empty.
IPluginStatusField names one custom status-line field. Add it with
PluginContribution.WithStatusFields. The original constructor stays
unchanged. The name starts with a letter, then letters, digits, or single
hyphens, at most 32 characters. Admission is case-insensitive and the host
stores the lowercase form. A built-in status field, on, off, reset,
an illegal token, or a duplicate is omitted with a note. The first loaded
plugin wins.
The host does not place the field on the adaptive status bar, the progress
row, /status, or /stats. The operator adds the lowercase name to
statusLine or /statusline. A saved name is kept when the plugin is not
loaded; the custom line omits it and /statusline marks it unavailable.
After data directories, the host calls AttachStatus with
IPluginStatusUpdates when at least one of this plugin's fields was
accepted. That call happens once for the loaded instance. Reloading tools
or changing tool approval does not call it again. A later plugin reload
retires the previous sink first.
Set replaces that field's text. Clear, or blank text, removes it. The
plugin pushes. The host does not poll and does not run a status timer. A
plugin may call Set from any thread it already has, including a timer it
owns. The host keeps one line: control characters and escapes are dropped,
newlines and tabs become spaces, and text longer than 64 characters is cut.
The description shown by /statusline is at most 80 characters. The same
text is not published again. A name this plugin did not register is ignored,
and that name is noted once. A sink from an older catalog ignores later
calls. If AttachStatus throws, that sink is closed and any text it already
published is removed. The plugin does not choose color, markup, or an extra row.
IPluginEnvironment is read-only. It does not enable, disable, invoke, or
reorder anything, and it does not declare a load dependency.
| List | Contains |
|---|---|
Plugins |
Every discovered plugin directory, including this plugin. Directory, source (home or project), display name, enabled, effective, loaded, and skip reason. No assembly path, type name, or manifest path. |
ToolSets |
Every discovered external tool set, with the same directory fields. |
ExternalTools |
Tools that loaded: name, set, source, and whether the tool is in Plan, Work, or both. |
Skills |
Skills the skill tool can load right now: name and description. One entry per name. Empty when Skills is off. No file body and no path. |
enabled is null when the manifest could not say. Display name is empty
until that plugin loads. Built-in tools are not listed.
AttachSession runs with Attach, after Contribute, and again when
that plugin instance is loaded again. The object stays live. /model,
/approval model, /approval thinking, and /thinking show up on the
next read. The host does
not call AttachSession again for those changes.
Session carries the provider, protocol, model name, context window,
maximum tokens, temperature, top-p, image input, declared audio input,
declared video input, and the current thinking gear. Audio and video
flags copy the model entry. They do not make a turn, a tool result, or
CreateMultimodal accept audio or video.
default and off are host sentinels. Other values are the
portable names minimal, low, medium, high, and maximum, or
another effort token. Tokens are stored in lowercase.
Review.Independent is true only when review uses its own model. That
model's thinking gear is its stored thinkingEffort, default when
unset. While the switch is off, review uses
the session model, Model is null, and a saved provider or model name is
omitted. The facts include no API key, endpoint, organization, or project.
PluginPlaceholderContext.Models is the same live view. It is empty until
the session publishes it.
A model call spends the operator's credentials. It does not enter the
transcript, the approval path, or the turn budget. The entry type receives
clients only when it implements IPluginModelClient. The host names that
plugin when it loads, for example Plugin 'Acme' can call the session and review models. Built-in plugins do not receive these clients.
AttachClients runs after AttachSession. The clients are new instances
on the same provider and model. They are kept apart from the instances
inside a turn or a review.
| Member | Meaning |
|---|---|
Session |
Text client for the session model. |
Images |
Image client for the session model, or null when that model does not accept images. |
IndependentReview |
True only when review uses its own model. Reading it does not create a client. |
Review |
Text client for the review model while IndependentReview is true. Null while review uses the session model. |
When IndependentReview is true and the review client cannot be created,
reading Review fails. The host leaves the session client in place.
The first read creates a client. Later reads return that instance until the host drops it. The host drops the session and image clients when it rebuilds the session client, and drops the review client when it rebuilds the review client or the switch turns off. Read the property again after that.
IPluginClientFactory remains the other direction. It builds a client for
a protocol the built-in adapters do not own.
A plugin that keeps its own runtime state implements
CrystalCode.Plugins.Data.IPluginDataDirectory. The host calls
AttachDataDirectories(globalDataDirectory, projectDataDirectory) after
Attach, and again when the plugin instance is loaded again. The interface
is opt-in, like IPluginModelClient.
The global directory is {home}/data/plugins/<directory> and is shared
across workspaces. The project directory is
<workspace>/.crystal/data/plugins/<directory> and belongs to the current
workspace. The host creates both before that call; a directory that
cannot be created is not reported, so create it when it is missing. They are
separate from the plugins/ install directory and are not a secret store:
do not write credentials there, and the project directory is inside the
workspace.
A plugin tool that implements IHostTool or IHostMultimodalTool also
receives the same two directories on ToolHostContext.
Hooks run in plugin load order. The next hook sees the previous replacement. The same pipeline wraps built-in tools, other plugins, and external tools. A hook that throws is skipped with an English note.
| Hook | May | May not |
|---|---|---|
OnSessionStartedAsync / OnSessionEndedAsync |
Read workspace root, session id, and approval mode | Prompt the operator, or write config.json |
OnPrompt |
Append text to the instruction block | Replace Work, Plan, or Review |
OnUserMessageAsync |
Replace the user message before it is stored | Leave the stored message blank, or run for a side question |
OnTurnStartedAsync / OnTurnFinishedAsync |
Read the stored user text, mode, and, when the turn ends, the stop reason | Change the transcript |
TransformModelAsync |
Change user, assistant, or tool-result text for one outbound call, or drop image references already on an item | Reorder, add, or drop items, edit a system message, add an image, or write the archive |
OnModelResponseAsync |
Read the purpose, finish reason, returned items, and usage of one completed work, plan, side, or compaction call | Change the transcript, the archive, or the response the host uses |
OnToolCallAsync |
Replace the name or arguments | Change the call id, skip the call, or skip approval |
OnToolResultAsync |
Replace the text result and, on an image-capable turn, its images | Mark an approval as passed |
OnApproval |
Raise risk, or require another prompt | Lower the host risk, or auto-pass |
OnCompaction |
Append facts to the summary prompt or the summary body | Drop history, or replace the compactor |
A rewritten tool call is classified and approved again before it runs.
Text-only turns ignore images returned by OnToolResultAsync. Image
types are image/png, image/jpeg, image/gif, and image/webp.
TransformModelAsync sees the model transcript for a work, plan, or side
request, and the older turns about to be summarized, after every raw hook
has run. It does not see the approval review. Returned image lists may only
name attachments already on that item. The stored transcript and the
archive stay as they were, except for text replaced by
OnUserMessageAsync.
OnModelResponseAsync runs after one of those calls returns and before the
host commits that candidate or runs its tools. Approval review does not
call it. The host keeps the candidate it received.
TransformModelAsync changes only the items sent on that one call. The
stored transcript and the archive stay as they were. Rewriting user,
assistant, or tool-result text can make the model answer from a
conversation the operator did not store. A replacement that reorders,
adds, or drops items, changes a role or a tool call, or edits a system
message is skipped with an English note. Structural changes belong to raw
hooks.
Session start runs after the plugin load, and again when another coding
session becomes active. Session end runs when the session closes, when
/clear, /resume, or /fork replaces it, and before a /cd or
/space reload. The end callback still carries the workspace and session
being left. A resume that also changes workspace is one end and one start.
crystal run closes the session when the process finishes.
A raw hook is a privileged, low-level extension point. IPluginRawHook
is a separate interface. A plugin registers raw hooks on
PluginContribution.RawHooks, apart from Hooks. The operator does not
approve them. The host writes one note for each plugin that registers
one, for example Plugin 'Acme' registered a raw hook.
Raw hooks run in plugin load order. The next raw hook sees the previous replacement. A raw hook that throws is skipped with an English note. The host does not hold a raw hook to the rules that bind ordinary hooks. It refuses only what it cannot represent.
| Raw hook | May | May not |
|---|---|---|
RebuildModelAsync |
For one outbound call, drop, reorder, add, or rewrite items of any kind. That includes the live system prompt, roles, tool calls, tool results, and reasoning, and it may split a call from its result | Repeat an item id, return empty reasoning text, name an image the session does not hold, add an image to a request that cannot carry images, or write the archive |
RewritePrompt |
Replace the composed system text for work, plan, review, or compaction with any string, including an empty one. Work and plan text becomes the live system message and is archived |
Write ~/.crystal/prompts |
RewriteCompaction |
Replace the compaction user prompt, or the summary body that is stored, with any string, including an empty one | Choose which history is folded, or replace the compactor |
RewriteApproval |
After ordinary approval hooks, set any risk, any authority, any summary, and either prompt requirement. The approval policy then uses that classification | Skip that policy |
RebuildModelAsync sees the same requests as TransformModelAsync and
runs first. The stored transcript and the archive stay as they were.
- Ids. An item keeps the id the host gave it. A new item needs an id the request does not use. An id that returns with a different kind of item is a new item.
- New items. A new tool result ignores its
Name, which the host derives from the call. New reasoning carries text only, with no provider state. Rewriting the text of existing reasoning drops that state too. - Images. The host stores image bytes in the session and references
them by marker. A raw hook can keep, drop, or move an attachment that is
already in the request, with the same media type. It cannot create an
image.
PluginModelImagecarries no bytes. Support for new images is planned. Only user messages and tool results carry images to the model. Images on assistant and system messages become plain text.PluginModelRequest.AcceptsImagesis true only for a work or plan call on an image-capable model. Text-only turns, side questions, and compaction never send images, so the host skips a replacement that adds one there. - Provider rules. The host does not check what a provider accepts. A
request that splits a tool call from its result, or moves the system
prompt, can be rejected by the provider. When a work or plan call fails
after a raw hook changed its request, the host writes a note such as
Raw hook 'Acme' changed this model request. The failure may be related.The note is a hint, not a diagnosis. - Prompt text.
OnPromptstill only appends.RewritePromptthen replaces the composed system text for that mode. Any returned string is used, including an empty one. Work and plan text is what the session stores. The files under~/.crystal/promptsstay as the operator saved them.RebuildModelAsynccan still change the system item of one outbound call after that, without writing the archive. - Compaction text.
OnCompactionstill only appends.RewriteCompactionthen replaces the full compaction user prompt or the stored summary body. An empty replacement is kept. - Approval.
OnApprovalcan still only raise risk or require another prompt.RewriteApprovalthen replaces the classification and may lower risk, change authority, change the summary, or clear the prompt requirement. The approval policy uses the result.
More hook methods will be added later. A new method joins IPluginHook or
IPluginRawHook when its behavior is defined. This build does not reserve
empty methods for them. Whether plugins declare dependencies on one another,
and related loading questions, are under consideration and are not decided.
Each plugin gets one non-collectible AssemblyLoadContext.
Shared assemblies come from the host: Crystal, Crystal.Tools,
CrystalCode.Tools, and CrystalCode.Plugins. Other dependencies must
resolve inside the plugin directory. The host does not probe the NuGet
cache or the workspace.
CrystalCode, CrystalCode.Engine, CrystalCode.Display, and
CrystalCode.Providers are refused, including as the entry assembly.
The context is not collectible. There is no hot reload, signing check,
or marketplace.
- MCP.
- Collectible unload and hot reload.
- Replacing a built-in protocol, built-in tool, or built-in slash verb.
- Hooks on external tool sets.
- A module system below plugins and raw hooks. A later one may reach the host through reflection or another mechanism. Its behavior and ownership are not defined, and this build does not reserve types for it.