External tools
Operators add model-callable tools as tool sets. A set is one
directory, one tools.json, and one runner (one executable prefix, or
one assembly and one load context). Each set contributes one or more Crystal
tool values. Dotnet sets may contribute native ITool, IMultimodalTool, or
combined implementations.
The host owns catalog registration, workspace fencing of model-supplied
paths, approval classification, output truncation, and timeouts.
Operator code never bypasses ToolInvocationPolicy.
Invocation channels:
- exec + stdin JSON: the fenced argument object for that call is written to the child stdin, then stdin is closed.
- exec + argv: listed scalar properties become extra process arguments. Operator-authored command arrays are never interpolated.
- exec content: when a tool sets
"output": "content", stdout is one JSON object (text, optionalimages) instead of plain text. Logs stay on stderr. - dotnet: a class library whose public
Crystal.Tools.IToolandCrystal.Multimodal.Tools.IMultimodalTooltypes are loaded in one isolatedAssemblyLoadContext. OptionalCrystalCode.Tools.IHostToolandIHostMultimodalToolimplementations receive host facts on each call.
Exec may use stdin, argv, or both. Dotnet does not use stdin or argv.
What this is not
Section titled “What this is not”| Mechanism | Role |
|---|---|
IPlugin / PluginRegistry |
First-party in-process contributions (built-in tools and the six wire protocols). |
~/.crystal/plugins/ |
Operator plugins. They use CrystalCode.Plugins, not this manifest. See plugins.md. |
| Skills | Markdown instructions loaded through the skill tool. They do not become extra ITool entries. |
| MCP | A separate, deferred integration; it is not an external-tool runner. |
First-party code contributions stay on PluginRegistry. Operator tools
are discovered as tool sets. Dotnet runners load class libraries from
the set directory only, with the dependency rules below. That is not
a general plugin loader.
Identity
Section titled “Identity”The tool set has no JSON identity field. There is no root name and no
id. The set is its directory under tools/.
Overlay key: the directory name. <workspace>/.crystal/tools/Acme.Tools/
replaces ~/.crystal/tools/Acme.Tools/ as a whole set. Lists are not
merged. Directory names may include uppercase. Pattern: 1–64 characters,
start with a letter, then A–Z a–z 0–9 _ . -. No space, no
/. On Windows the filesystem is case-insensitive; two folders that
only differ by case are the same set.
Root name is not a field of this format. Like any other unrecognized
root key, it is ignored.
Each tool has a model-facing name: exec tools[].name, or dotnet
ITool.Definition.Name. That is what Crystal and the provider send. It
is not <directory>/<tool>. A slash is not a legal OpenAI-compatible
function name. The host does not compose a qualified id. Tool names
match ^[A-Za-z][A-Za-z0-9_-]*$ (1–64 characters) and must not be a
built-in name. --disable-tool can omit a built-in for one process.
The name stays reserved, so a plugin or external tool still cannot take it.
One-tool exec shorthand (no tools array): the tool name defaults to
the directory name. A different model-facing name requires a
one-element tools array. Shorthand therefore requires a directory
name that is also a legal tool name (no . in the directory).
Tool names must be unique in the process catalog. Built-ins (read,
list, tree, glob, grep, search, todowrite, todoread, question, edit, write, bash,
skill) win. An exec set whose tool name is reserved is skipped. A
dotnet type whose Definition.Name is reserved is omitted. Two sets
that both contribute deploy collide the same way: the later name is
omitted with an English note. If authors want a prefix, they put it in
the tool name (Acme_deploy).
Discovery
Section titled “Discovery”Crystal-owned paths only. OpenCode and Claude trees are not scanned for tools.
~/.crystal/tools/<directory>/tools.json<workspace>/.crystal/tools/<directory>/tools.jsonEvery immediate subdirectory that contains tools.json is a candidate
set. The session enters the terminal frame first, shows Loading Tools
on the progress row, then scans and loads. /cd reloads the same way
from the new workspace.
Runtime data is separate from the install tree:
~/.crystal/data/tools/<directory>/ global data<workspace>/.crystal/data/tools/<directory>/ project dataThe host resolves both and creates them lazily on the first call.
config.json field externalTools enables the feature (default
true), matching skills. When true, the field is omitted from the
written file. When false, manifests are not scanned.
tools.json field enabled turns one set on or off (default true).
"enabled": false omits that set after overlay, with no operator note.
A project set can disable a home set of the same directory name this
way. The manifest is still parsed; an invalid disabled file is skipped
with a note.
/tools enable|disable|show <directory> changes or shows one set. Prefix
home or project to choose that tree. Without a prefix, a project
directory wins over Home. A change reloads the catalog. It does not create
a directory, and a broken manifest is left unchanged. /tools on|off|reload
remains the discovery switch for every set.
crystal tools list, show, enable, and disable do the same outside
a session. --source home|project chooses the tree. The default output is
a Spectre.Console table. --format text prints the same fields as aligned
plain text. These commands write enabled in tools.json and do not write
config.json. Turning one set on does not turn discovery on.
A missing, unreadable, or invalid manifest skips that set and
records an English operator note. The session still starts. A set that
loads with several tools and fails one name omits only that name when
the failure is per-tool (reserved or duplicate name). Failures that are
per-set (missing DLL, invalid runner, duplicate names inside one exec
set) omit the whole set.
When a project directory has a tools.json, it hides the same-named Home
set even if its manifest is unreadable or invalid.
Tool set vs tool
Section titled “Tool set vs tool”Tool set (directory + tools.json root) |
Each contributed tool | |
|---|---|---|
| Identity | Directory name | Model-facing name / Definition.Name |
| Enabled | enabled on the set (default true) |
Same runner; no per-tool switch |
| Runner | One exec or dotnet |
Same runner |
| Exec command | Shared argv prefix | Optional extra argv suffix (subcommand) |
| Dotnet | One assembly, one ALC | One public ITool and/or IMultimodalTool type |
catalogs |
Default for every tool | Override |
pathArguments / argv / schema |
Defaults when there is a single exec tool | Required per exec tool when several exist |
Catalogs (Plan / Work)
Section titled “Catalogs (Plan / Work)”The product has two tool catalogs, Plan and Work. Approval modes
(Default, Edit, Review, Audit, Full) are not catalogs. Do not
put review, audit, default, or full in this field.
Field name is catalogs, not modes.
"catalogs": ["plan", "work"]The array is a set. ["plan", "work"] and ["work", "plan"] both mean
the tool is registered in Plan and in Work. ["plan"] is Plan
only. ["work"] is Work only.
Closed members: plan, work. Duplicates are ignored. Unknown values
refuse the set (or that tool override). An empty array refuses: a tool
that is in neither catalog must not be listed.
Default when catalogs is omitted: ["plan", "work"]. Installing a
set opts it into the whole product unless the author narrows it.
A set-level catalogs is the default for every tool in the set. A
tool-level catalogs replaces that default; it does not merge.
Built-in Plan is still without edit, write, and bash. External
tools listed for Plan are extra entries. They keep the Write +
Workspace floor, so Plan is no longer built-in reads only once such a
tool is installed. Approval still runs. Plan + Full can auto-pass a
workspace-bounded write from an external tool. Authors who want
Work-only set "catalogs": ["work"].
First-party tools declare HostToolCatalogs on IToolContribution.
Disk plugins declare PluginToolCatalogs. Each is Plan, Work, or both.
External sets do not go through PluginRegistry.
Manifest
Section titled “Manifest”tools.json is JSON. Unknown fields are ignored, including a root
name. Required at the root: runner.
Single exec tool (shorthand — one contributed tool, model-facing name defaults to the directory name):
{ "runner": "exec", "description": "Ship the current workspace to a named environment.", "schema": { "type": "object", "properties": { "environment": { "type": "string" }, "path": { "type": "string" } }, "required": ["environment"] }, "command": ["deploy", "release"], "stdin": true, "argv": { "environment": "--env", "path": "--path" }, "pathArguments": ["path"], "timeoutSeconds": 120}Exec set with several tools (shared binary, subcommand dispatch):
{ "runner": "exec", "command": ["acme"], "stdin": true, "timeoutSeconds": 120, "tools": [ { "name": "acme_deploy", "description": "Ship the current workspace to a named environment.", "schema": { "type": "object", "properties": { "environment": { "type": "string" } }, "required": ["environment"] }, "command": ["deploy"], "argv": { "environment": "--env" } }, { "name": "acme_inventory", "description": "List inventory for this workspace.", "schema": { "type": "object", "properties": {} }, "command": ["inventory"] } ]}Dotnet set (all public ITool types; optional per-tool-name overlay).
Tool names come from Definition.Name:
{ "runner": "dotnet", "assembly": "Acme.Tools.dll", "tools": { "acme_inventory": { "catalogs": ["plan"], "pathArguments": ["path"] } }}| Field | Layer | Meaning |
|---|---|---|
name |
Tool only | Model-facing tool name. Exec tools[] required. Shorthand defaults to the directory name. Dotnet from Definition.Name. A root name is unrecognized and ignored. |
runner |
Set | exec or dotnet. |
enabled |
Set | Boolean. Default true. false omits the whole set after overlay. |
approval |
Set default, tool override | inherit or always. Default inherit. |
command |
Set and/or tool | Exec argv. Set prefix then tool suffix. No substitution. |
stdin |
Set | Exec only. Boolean, default true. Applies to every exec tool in the set. |
argv |
Tool (or shorthand root) | Map of schema property name to one flag string. |
pathArguments |
Tool (or shorthand root) | Property names treated as filesystem paths. |
output |
Exec tool (or shorthand root) | text (default) or content. content reads a JSON object from stdout. Dotnet rejects this field. |
timeoutSeconds |
Set | Positive integer up to 4,294,967, or "unlimited"; default 120. Applies to exec and dotnet. |
catalogs |
Set default, tool override | plan and/or work. Default ["plan", "work"]. Both members means both catalogs. |
description / schema |
Each exec tool | Required for exec. Dotnet takes these from the native tool Definition. Overlay does not replace them. |
assembly |
Set | Dotnet only. File name relative to the set directory. |
types |
Set | Dotnet only. Optional allowlist of type names. Default: every public ITool or IMultimodalTool. |
tools |
Set | Exec: array of tool objects. Dotnet: optional map keyed by Definition.Name for overlays (catalogs, pathArguments). |
Stdin is always the arguments object for the one call. There is no JSON
envelope ({ "tool": "...", "arguments": { } }). Which exec tool ran
is expressed by argv (shared prefix + per-tool suffix).
Shorthand vs tools: an exec set may use the shorthand root fields
or a tools array, not both. Mixing refuses the set. A tools
array with one element is valid and is not shorthand.
Author approval and source policy
Section titled “Author approval and source policy”An author may declare that ordinary calls should not ask or enter Review:
{ "runner": "exec", "approval": "always", "command": ["acme"], "tools": [ { "name": "acme_inventory", "description": "List inventory.", "schema": { "type": "object", "properties": {} } }, { "name": "acme_deploy", "description": "Deploy.", "schema": { "type": "object", "properties": {} }, "approval": "inherit" } ]}inherit uses the host approval mode and remembered call grants. always
skips the operator prompt and Review when the source policy follows the author.
The tool-level value replaces the set default. Updating a tool does not revoke
or re-confirm the declaration.
The operator configures each source in ~/.crystal/config.json:
{ "externalToolApproval": { "home": "author", "project": "host" }}author follows tools.json; host treats every declaration as inherit.
Defaults are Home author and Project host. /tools home author|host and
/tools project author|host change these values. /tools lists the declared
and effective value for every loaded external tool; /tools reload rescans,
and /tools on|off controls discovery.
Safety floors (all runners)
Section titled “Safety floors (all runners)”A process or loaded assembly is at least Write + Workspace. Path
properties in pathArguments are resolved with the same fence as
read / write. Credential paths become Forbidden. Paths outside the
workspace become OutsideWorkspace. The rewritten absolute path is what
the child or assembly sees.
Host classification looks up the external tool by its model-facing name. It does not fall through to “Unknown tool” after a successful load.
Full auto-passes workspace-bounded Write for any such tool, not only
built-in write / edit. Edit and Review still auto-pass only
those built-in names. Default asks the operator for external Write.
Review and Audit send it to the reviewing model. A tool-provided
pre-classifier can only raise risk or authority for its own calls and fails
closed; it never removes the floor or the pathArguments checks. Session and
Persistent grants apply only to ordinary Workspace Read/Write calls without
RequirePrompt. A raised classification goes through approval again, even
when a grant for that tool already exists. Restricted calls allow Once only.
RequirePrompt prevents policy, grant, and reviewer Allow from bypassing the
operator; reviewer Deny still rejects. Headless runs deny operator prompts.
Secrets never appear in tools.json. The host does not inject
credentials into the child environment or into a loaded assembly.
Exec children inherit the host process environment. Dotnet assemblies
must not receive secrets via Environment.SetEnvironmentVariable
(process-wide). Do not log argument JSON that may contain secrets.
Stdout and stderr are drained concurrently while only a bounded prefix is
retained. The combined result is truncated to
MaximumToolOutputCharacters, the same limit as bash.
Each set’s global and project runtime-data directories are ordinary writable directories. They are not a secret store: do not write credentials there. The project directory is inside the workspace, so anything written there is workspace content.
Catalog composition
Section titled “Catalog composition”The text WorkspaceCatalog is: built-in plugin tools, then disk plugin
tools, then every contributed external ITool whose catalogs contains
Plan or Work respectively, then skill when enabled. For an image-capable
turn, the multimodal executor adds built-in, disk, and external
IMultimodalTool definitions in that order. External tools are not
registered through PluginRegistry.Add. A disk plugin tool name is
reserved before external registration. The external classifier is appended
after disk classifiers.
Not included
Section titled “Not included”- MCP, HTTP/OpenAPI runners, argv string templates, stdin dispatch envelopes.
- Qualified model-facing names (
Set/toolor automaticSet_toolprefixes). - Collectible unload, hot reload, signing, marketplace.
- Host-injected secrets into child processes or in-process assemblies.