Skip to content
Crystal CodeDocs
ENEnglish

Dotnet tool sets

The assembly is a framework-dependent class library, not a second self-contained runtime and not CrystalCode.Plugins.IPlugin.

Compile against Crystal.Tools (and the Crystal reference it requires). Implement Crystal.Tools.ITool, Crystal.Multimodal.Tools.IMultimodalTool, or both, with a public parameterless constructor. Multimodal output uses Crystal’s native MultimodalToolOutput, TextContent, ImageContent, and media-source contracts; there is no manifest or JSON output protocol.

A tool that needs the session workspace, session id, approval mode, or its runtime-data directories also references CrystalCode.Tools and implements IHostTool or IHostMultimodalTool (a type may implement one or both). The method takes the model call plus a ToolHostContext. The host builds that value at the start of the call, so a later /cd or /approval shows up on the next call and does not change an instance the tool already holds.

A text or multimodal tool instance may additionally implement IHostToolApprovalClassifier when its arguments contain paths or other information that the host must classify before applying the approval policy, for example one patch text whose file paths the host cannot see. The classifier receives the model call and a ToolHostContext carrying the live workspace root, session id, and approval mode; the two data directories are empty in that context because the classifier runs before the call and nothing is created for it. The classifier must not perform side effects. Implement ValueTask<HostToolApprovalClassification> ClassifyApprovalAsync(ToolCall call, ToolHostContext context, CancellationToken cancellationToken = default). The call contains the original model arguments, before path rewriting; resolve relative paths against context.WorkspaceRoot. Existing implementations of the former synchronous ClassifyApproval must update and rebuild against this contract. The method returns risk and authority as case-insensitive text: read, write, privileged, or forbidden, and workspace, outsideWorkspace, network, or privilegedEscalation. An underscore is ignored, so the engine spellings outside_workspace and privileged_escalation are accepted too. The host merges the result with its own classification: a tool can raise risk or authority for the call, never lower them. The Write + Workspace floor, the pathArguments checks, and the source trust policy always stay in force. RequirePrompt requests operator approval even for otherwise ordinary calls. A classifier that throws, returns null, or returns an unknown risk or authority fails the entire proposal closed to Privileged + PrivilegedEscalation, merged with the host floor so a Forbidden classification remains Forbidden. The host authors the final summary and preserves its path warnings; the proposal’s Summary is not displayed or sent to the reviewer.

Classification runs on a worker with user/turn cancellation and a separate fixed two-second budget, including code before the method returns its task. This budget is independent of timeoutSeconds, even when execution is unlimited. On timeout the classification fails closed; user/turn cancellation propagates without asking or invoking the tool. Either event disables that classifier instance until /tools reload (or another catalog reload). Later calls fail classification closed without starting more work on the disabled instance. Late tasks are observed, and their results are ignored. In-process code that ignores cancellation may still continue; the host cannot terminate it. /tools marks the presence of the interface in its Pre-classifier column; that column does not report the instance’s health.

WorkspaceRoot is the session workspace, not the process current directory. SessionId and Approval are empty when the host has none. Known approval modes are plan, default, edit, review, audit, and full. GlobalDataDirectory is {home}/data/tools/<directory> and ProjectDataDirectory is <workspace>/.crystal/data/tools/<directory>; the host creates both lazily before the first call. The context carries no credentials and no other home-directory path. The inherited InvokeAsync without a context fails with “This tool requires host context.” The wrapper does not call it. Tools that implement only ITool or IMultimodalTool do not reference CrystalCode.Tools and keep the original method. Exec tools receive the same facts as child environment variables.

The host loads the assembly once, then adds every matching type:

  • Public, non-abstract, non-generic class
  • Implements ITool or IMultimodalTool (host identity, see load context)
  • A type may implement both. Its text and multimodal definitions must match.
  • Optional types allowlist: if present, only those type names; names in the list that are missing refuse the set
  • Skip nested types unless they are public
  • Zero matching types refuses the set
  • Two types with the same Definition.Name refuse the set

Do not require each type to be listed in tools.json. The JSON overlay map is optional. Keys in tools that do not match a loaded Definition.Name refuse the set (typo). Types not mentioned still register with set defaults. Overlay may set catalogs and pathArguments; it does not replace the type’s name, description, or schema.

The host does not put operator tool instances in the catalog raw. Each text or multimodal tool is wrapped. One call follows this order:

  1. Classify the original arguments with the manifest floor and pathArguments. If the text or multimodal instance implements IHostToolApprovalClassifier, await its side-effect-free result with the separate classification budget. Its proposal can raise risk or authority, never lower them.
  2. Run approval before entering the execution wrapper, same as every other tool.
  3. Rewrite pathArguments on the text or multimodal tool call (overlay or empty).
  4. Start the set’s execution timeout and forward user and turn cancellation.
  5. When the instance implements IHostTool or IHostMultimodalTool, call that method with a new ToolHostContext. Otherwise call the original InvokeAsync.
  6. Truncate text output blocks.

The wrapper is the catalog entry. On timeout, the wrapper asks the tool to cancel and returns a failure. In-process code that ignores cancellation may continue running; AssemblyLoadContext cannot terminate it. Set "timeoutSeconds": "unlimited" to disable only the per-call timeout. Pure multimodal tools are exposed only when the selected model and provider support image input. Declared audioInput or videoInput does not count. Text-only sessions ignore them. A type implementing both retains its ordinary ITool entry in a text-only session. AssemblyLoadContext is not a sandbox.

Operator types must not reference CrystalCode, CrystalCode.Engine, CrystalCode.Display, or Spectre.Console. They may reference CrystalCode.Tools. The assembly contributes tools only: no plugin entries such as slash commands, client factories, or plugin approval classifiers.

Operators publish into the set directory:

dotnet publish -c Release -o ~/.crystal/tools/<directory>

The project is OutputType=Library, TargetFramework=net10.0 (or a lower TFM the host can load). Do not publish --self-contained or --runtime as a second RID-specific runtime. Native RID assets belong under runtimes/<rid>/native/ via a framework-dependent publish.

Expected files:

<directory>/
tools.json
Acme.Tools.dll
Acme.Tools.deps.json
<copy-local managed dependencies>
runtimes/ # optional native assets

dotnet build output is not enough: load requires *.deps.json and the resolved dependency graph that publish writes.

The release host is self-contained single-file (PublishSingleFile=true, not trimmed, not Native AOT). Shared contract types (Crystal, Crystal.Tools, CrystalCode.Tools) live in the host bundle. They are not files next to CrystalCode. Authors compile against Crystal.Tools and, when the tool takes host facts, CrystalCode.Tools, from source or a future pack. At runtime those identities must come from the host load context that already loaded Crystal.Tools (typeof(ITool).Assembly) and CrystalCode.Tools (typeof(IHostTool).Assembly), never from a DLL copied into the tool folder. Do not call AssemblyLoadContext.Default.LoadFromAssemblyName for those contracts: in testhost isolation that can load a second copy and is ITool or is IHostTool fails.

One AssemblyLoadContext per tool set (directory), created at session start, live for the process. All ITool types from that assembly share it. Contexts are not collectible. Two sets never share an ALC, so their private copies of Newtonsoft cannot collide.

Do not use Assembly.LoadFrom / LoadFile on the default context.

Algorithm for Load(AssemblyName name):

  1. If name is Crystal, Crystal.Tools, or CrystalCode.Tools, return the already-loaded host assembly (typeof(ChatMessage).Assembly, typeof(ITool).Assembly, or typeof(IHostTool).Assembly). Never load it from the set directory, even if that DLL is sitting there. Resolving repeats this step when Load returned null.
  2. If name is a shared framework assembly already loaded in the host context, return that instance.
  3. Otherwise call AssemblyDependencyResolver constructed from the set’s main DLL path (*.deps.json beside it). If it returns a path inside the set directory, LoadFromAssemblyPath.
  4. Otherwise fail the set load with an English message naming the missing assembly. Do not probe the NuGet global cache, the GAC, or the workspace.

Unmanaged probe: ResolvingUnmanagedDll uses resolver.ResolveUnmanagedDllToPath. Same directory fence.

Shared means: Crystal, Crystal.Tools, CrystalCode.Tools, and every System.* / Microsoft.* assembly already loaded in the host context that owns typeof(ITool).Assembly, plus netstandard and mscorlib.

Private means everything else, including Newtonsoft.Json. The text boundary is ToolCall / ToolOutput / JsonElement. Host facts cross that boundary as ToolHostContext on IHostTool and IHostMultimodalTool.

If the resolver points at Crystal.dll, Crystal.Tools.dll, or CrystalCode.Tools.dll inside the set folder, ignore that path and go to step 1.

Single-file implications:

  • typeof(ITool).Assembly must be available after host startup. Shared contracts are taken from that host context, not probed from disk.
  • Do not enable trimming or Native AOT on the host while this runner exists.
  • The set ALC must not try to load CrystalCode, CrystalCode.Engine, or CrystalCode.Display from the single-file bundle. CrystalCode.Tools is a shared contract and is taken from the host.