Plugins and hooks

Extend Xal with trusted in-process plugins that can register tools, providers, UIs, commands, and lifecycle hooks.

Loading plugins

The top-level plugins array tells Xal what to load. It does not install or download anything. Every referenced plugin must already exist and be resolvable when Xal starts.

Each entry supports one of these forms:

{
  "plugins": ["/absolute/path/to/my-plugin"]
}

The referenced directory must contain a plugin.ts whose default export has a name, a synchronous register function, and optionally asynchronous bootstrap and shutdown functions. Relative plugin paths are not resolved from the project directory, even when declared in project configuration.

Plugin registration is transactional. If importing, validating, or registering a plugin fails, Xal records a plugin registration failure and keeps none of that plugin's contributions.

Model-facing context

Xal's built-in system prompt carries two things: session state, and behavioral policy. Session state is identity, current environment, permission mode, and stateful workflows the user explicitly entered, such as plan mode. Behavioral policy is how the agent is expected to work, covering execution, code changes, verification, and the shape of its replies; the change sections are withheld in read-only modes, where they do not apply.

Tool contracts stay out of the prompt. Tool definitions reach the model through the provider's native tool schema, and a tool description should explain capability, inputs, effects, limits, preconditions, and failure conditions; it should not restate general workflow that belongs in the prompt.

Plugins may contribute system-prompt sections with ctx.registerPrompt. Reserve these for runtime state, an explicitly enabled mode, or instructions intrinsic to the plugin as a whole. Do not use them to restate a tool's contract, which the tool description already carries. Project AGENTS.md files, the compact skill catalog, and global memory are intentional prompt contributions because the user enabled those context sources. MCP server instructions stay deferred until a search loads tools from that server. Prompt hooks can replace individual user messages and therefore remain a separate, explicitly trusted extension point.

Lifecycle

ctx.runtime exposes the app name and version, app home and cache paths, profile-aware credential loading, saving, and compare-and-swap replacement, plus transient secret protection. Credential operations require both the provider ID and immutable profile ID. Provider plugins should use this runtime instead of reading or rewriting Xal's shared credential file directly. Provider model discovery and streaming also receive the profile ID, while connect returns a credential for the core to store under the user-provided profile name.

Plugins can contribute slash commands with ctx.registerCommand. Commands known synchronously belong in register; commands discovered from files or services may be added during bootstrap, before interactive input is released.

When the UI or CLI exits, Xal aborts ctx.signal so in-progress bootstrap work can stop, waits for bootstrap to settle, and then runs shutdown in reverse plugin order. Plugins that own child processes or network connections close them there. A dynamically discovered tool can be removed with ctx.unregisterTool(tool) using the same tool object that was registered. Tools that retain resources for an agent session can register per-session cleanup with ctx.registerToolSessionDisposer.

Hooks

Plugins register trusted lifecycle hooks with ctx.registerHook. Hooks run in built-in and configured-plugin order. Multiple hooks for the same event run sequentially, and a replacement from one hook becomes the input to the next.

HandlerInputAllowed result
promptModel-facing prompt text and image countReplace the text or reject the prompt
beforeToolTool name, call ID, and JSON argumentsReplace the arguments or block the call
afterToolTool details and its model-facing outputReplace the output
turnEndFinal output and token usage when availableNo result; use it for lifecycle automation or observability

Every handler also receives a context containing an abort signal and the session ID, kind, working directory, provider, model, and permission mode. Prompt changes affect what the model sees while the TUI keeps the user's original text. Tool argument changes happen before scheduling and permission evaluation, so Xal authorizes and records the effective action. Post-tool hooks also run for failed or interrupted executions, but not for calls blocked before execution.

Hook failures stop prompt, pre-tool, and turn-completion processing. A post-tool failure becomes a failed tool result that warns the model the tool may already have changed state. Hook inputs and code run inside Xal's process, so only load hooks you trust. Returned text and arguments pass through secret redaction before they reach the model, session storage, or TUI.

Hook example

This plugin marks prompts and read results, and blocks an exact git push command:

export default {
  name: "visual-hooks",
  register(ctx) {
    ctx.registerHook({
      name: "marker",
      prompt(input) {
        return { type: "replace", text: `${input.text}\n\nInclude the exact marker HOOKS_ACTIVE in the answer.` }
      },
      beforeTool(input) {
        if (input.tool !== "bash" || input.args.command !== "git push") return
        return { type: "block", reason: "Publishing is disabled by the visual hook." }
      },
      afterTool(input) {
        if (input.tool !== "read") return
        return { type: "replace", output: `[visual-hooks]\n${input.output}` }
      },
    })
  },
}

Put the file at plugin.ts inside a plugin directory and add that directory's absolute path to plugins. In the TUI, /hooks lists every registered hook and the events it handles. Each completed primary-session hook invocation appears in the transcript with its action and elapsed time; task-agent hook invocations appear in that agent's job output.

Plugin configuration

A custom plugin receives the object under pluginConfig whose key matches its exported plugin name:

{
  "pluginConfig": {
    "example-plugin": {
      "enabled": true
    }
  }
}

Built-in plugin options are documented with their features in TUI, Integrations, and Providers and models.

Classification tool

The built-in classify plugin exposes the general-purpose classification tool. It uses ctx.runtime.decisions, independently of the typesafe provider plugin. It contributes a tool definition, not a system-prompt instruction or automatic planning/review workflow. The existing TypeSafe AI switch controls availability and inference. Jev read-ahead is part of the harness tool runner rather than a plugin: it reads the results of the registered read, grep, and glob tools by name and prefetches through whichever read tool is registered, so a plugin that replaces those tools keeps read-ahead working as long as the output still lists workspace paths.

Decision models

Providers and models have explicit kinds. Provider and TextModelInfo have kind: "text"; DecisionProvider and DecisionModelInfo have kind: "decision". AnyProvider and ModelInfo are their respective unions. Existing text-provider plugins must add kind: "text" to their provider and model metadata. ModelCatalog defaults to text models; decision providers return ModelCatalog<DecisionModelInfo>.

Both provider kinds register through ctx.registerProvider and may implement connect to return a credential. A decision provider implements listModels(profileId, refresh) and evaluate(profileId, request) instead of defaultModel and stream. The harness accepts only text providers. Decision models are discovered separately and are never selectable as the harness model.

Consumers use ctx.runtime.decisions, so plugins never depend on or import one another. TypeSafe evaluations require the Use TypeSafe AI setting to be On and the requested profile to match its selected profile. Connection/model discovery remains available while Off, but inference is blocked:

const connections = await ctx.runtime.decisions.connections()
const connection = connections.find((entry) => entry.provider.id === "typesafe")
if (!connection) throw new Error("Connect TypeSafe first")
const catalog = await ctx.runtime.decisions.models(connection.profile.id)
const model = catalog.models.find((entry) => entry.id === "jev-latest")
if (!model) throw new Error("Jev is unavailable")
const result = await ctx.runtime.decisions.evaluate(connection.profile.id, {
  model: model.id,
  state: { task: "Decide whether this old file read is still needed" },
  questions: {
    keep: { type: "noul", instructions: "Is this file content still needed for the current task?" },
  },
  signal: ctx.signal,
})
const keep = result.answers.keep
if (keep?.type !== "noul") throw new Error("Expected a Noul answer")
console.log({ retainOriginal: keep.noul >= 0.5 })

The service resolves the immutable profile and checks its provider kind on every operation. It exposes connection metadata but not credentials. Outbound state and question descriptions pass through Xal's secret redactor; model, question, or choice identifiers containing protected secrets are rejected rather than renamed. State is a string, JSON object, or array. Questions are a discriminated DecisionQuestion union: noul, choice (named criteria), or score (ordered levels). Instructions and rubric descriptions accept strings, objects, arrays, or null. DecisionResponse returns the resolved model ID, a map of discriminated answers under the original question IDs, and normalized token usage. Choice and Score include probability distributions and confidence. Consumers narrow answer.type and apply their own thresholds; probabilities do not guarantee correctness.

TypeSafe validates answer types, probabilities, choice membership, score ranges and legends, question coverage, and usage at the wire boundary. Failures reject the operation. A consumer must surface any fallback rather than silently ignoring errors. Abort signals propagate to the transport. Decision requests do not use the chat streaming contract.