Customize

Plugins

Extend the Hull runtime with your own tools, hooks, providers and TUI features.

Plugins extend Hull by hooking into events, adding custom tools, registering auth or model providers and changing runtime behavior. A plugin is a TypeScript or JavaScript module that Hull loads at startup. Plugins run in the Hull runtime, so they work in the CLI, Hull Desktop and the IDE extensions.

Plugins run code on your machine with your permissions. Only install plugins you trust. Project plugins load only after you trust the project.

What plugins can do

  • Add custom tools the model can call (like read, write, bash).
  • Intercept tool calls to mutate arguments, rewrite output, or block dangerous operations.
  • Subscribe to events: sessions, messages, permissions, LSP diagnostics, file changes, etc.
  • Register auth providers: OAuth or API-key flows for model providers.
  • Register model providers: dynamic model catalogs.
  • Mutate chat parameters or headers sent to the LLM.
  • Customize compaction: inject or replace the prompt used when a session is compacted.
  • Inject shell environment variables for commands executed by the agent or user.

Use a plugin

There are three ways to load plugins. You can also install plugins from the Marketplace.

From a config file

Add an array of plugin specifiers to your config file:

{
  "$schema": "https://app.withhull.com/config.json",
  "plugin": [
    "@your-org/your-plugin",
    "your-plugin@1.2.3",
    ["your-plugin", { "apiKey": "{env:MY_API_KEY}" }],
    "./plugins/local.ts",
    "file:///abs/path/plugin.ts"
  ]
}

Each entry can be:

FormLoaded from
"package-name"Latest version from npm
"package-name@1.2.3"Pinned version from npm
["package-name", { options }]npm package with options passed to the plugin function
"./path/plugin.ts" / "file:///..."Local file (relative to the config file or absolute file: URL)

Config files live in the usual places. See Configuration.

From a plugin directory

Drop TypeScript or JavaScript files into a plugin/ or plugins/ folder inside any config directory:

  • Global: ~/.config/hull/plugin/
  • Project: .hull/plugin/ or legacy .hullcode/plugin/

Every .ts or .js file in those directories is auto-registered at startup, no need to list them in the config file.

my-project/
├── hull.json
└── .hull/
    └── plugin/
        ├── env-guard.ts
        └── notifications.ts

From the hull plugin command

Install an npm plugin and patch your config in one step:

# Install into the current project's config
hull plugin my-plugin

# Install into your global config
hull plugin my-plugin --global

# Replace an existing entry
hull plugin my-plugin --force

The command resolves the package, reads its package.json for plugin entry points, and adds the entry to a config file in the project's .hull/ folder, or in ~/.config/hull/ with --global. Server plugins are added to the main config file and TUI plugins to tui.json. Comments in JSONC files are kept.

How plugins are installed

  • npm plugins are installed automatically at startup using Bun. Packages and their dependencies are cached in ~/.cache/hull/packages/ (or $XDG_CACHE_HOME/hull/packages/).
  • Pinned npm versions like my-plugin@1.2.3 install that exact version and do not check for newer registry versions. Bare package names resolve to latest and can refresh when the cached copy becomes stale.
  • Install scripts are disabled for npm plugins. Hull installs packages with lifecycle scripts such as install and postinstall blocked.
  • Local plugins are loaded directly from the plugin directory. If your plugin imports external packages, add a package.json to your config directory (see Dependencies), Hull runs bun install on startup so imports resolve.

Load order

Plugins from all sources run on every session. They load in this order:

  1. Built-in plugins
  2. Global config plugin array (~/.config/hull/hull.json)
  3. Global plugin directory (~/.config/hull/plugin/)
  4. Project config plugin array (hull.json / opencode.json)
  5. Project plugin directory (.hull/plugin/ and friends)

Duplicates (same package, same version) are deduplicated. Hooks from multiple plugins run sequentially in load order.

Disabling external plugins

Run hull --pure, or set HULL_PURE=1, to skip all external plugins. Only built-in plugins load. This is useful for debugging and reproducible CI runs.

Create a plugin

A plugin is a module that exports a function returning a set of hooks.

Basic structure

Create a file in your plugin directory:

// .hull/plugin/hello.ts
import type { Plugin } from "@hullcode/plugin"

const hello: Plugin = async ({ project, client, $, directory, worktree }) => {
  console.log("hello plugin loaded")

  return {
    // hook implementations go here
  }
}

export default { id: "hello", server: hello }

The plugin function receives a context object:

FieldDescription
projectCurrent project metadata.
directoryCurrent working directory for this session.
worktreeGit worktree root for this session.
clientA Hull SDK client (@hullcode/sdk) for calling the local server.
$Bun's shell API.
serverUrlURL of the local Hull server.
experimental_workspaceRegister workspace adaptors (used by Agent Manager).

The function returns a Hooks object. Any second argument is the options object passed via config (e.g. the { apiKey: "..." } from ["my-plugin", { apiKey: "..." }]).

Register workspace adaptors

Workspace adaptors let plugins add custom workspace targets to Hull's workspace creation flow. This API is experimental and may change.

import type { Plugin } from "@hullcode/plugin"
import { mkdir, rm } from "node:fs/promises"

const WorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
  experimental_workspace.register("folder", {
    name: "Folder",
    description: "Create a blank folder",
    configure(config) {
      return { ...config, directory: `/tmp/hull-${Date.now()}` }
    },
    async create(config) {
      await mkdir(config.directory!, { recursive: true })
    },
    async remove(config) {
      await rm(config.directory!, { recursive: true, force: true })
    },
    target(config) {
      return { type: "local", directory: config.directory! }
    },
  })

  return {}
}

export default { id: "workspace-folder", server: WorkspacePlugin }

An adaptor implements configure(config), create(config, env, from?), remove(config), and target(config). target returns either { type: "local", directory } for a local workspace or { type: "remote", url, headers? } for a remote workspace endpoint.

Module shape

Plugins must default-export a module descriptor. id is required for local-file plugins and inferred from package.json#name for npm plugins.

import type { Plugin } from "@hullcode/plugin"

const server: Plugin = async (ctx) => ({
  /* hooks */
})

export default {
  id: "my-plugin",
  server,
}

An npm plugin can also expose a TUI entry point (tui) for TUI plugins, but server and tui are separate modules.

Package manifest for npm plugins

Published npm plugins should declare separate package entrypoints for each runtime they support. Hull detects install targets from package.json:

  • exports["./server"] marks the package as a server plugin.
  • exports["./tui"] marks the package as a TUI plugin.
  • main is a server-only fallback when exports is not used.
  • oc-themes marks a package as a TUI theme package, even when it has no ./tui export.
{
  "name": "@acme/hull-plugin",
  "type": "module",
  "main": "./dist/server.js",
  "exports": {
    "./server": {
      "import": "./dist/server.js",
      "config": { "apiKey": "{env:ACME_API_KEY}" }
    },
    "./tui": {
      "import": "./dist/tui.js",
      "config": { "compact": true }
    }
  }
}

The optional config object on an export becomes the default options tuple written to the user's config on first install. Keep server and TUI code in separate files; each runtime loads only the entrypoint that matches its target.

Theme-only packages can omit code entrypoints and provide package-relative theme files:

{
  "name": "@acme/hull-themes",
  "oc-themes": ["themes/acme-dark.json", "themes/acme-light.json"]
}

oc-themes entries must be relative paths inside the package. Absolute paths, file:// URLs, and paths that escape the package directory are rejected. Installed theme packages sync their themes on first install and when the package changes.

TypeScript support

Install the plugin package locally and import its types:

bun add -d @hullcode/plugin
import type { Plugin } from "@hullcode/plugin"
import { tool } from "@hullcode/plugin/tool"

Hull automatically creates a package.json in config directories that contain a plugin/ folder and installs @hullcode/plugin so types resolve out of the box.

Dependencies

Local plugins and custom tools can use external npm packages. Add a package.json to your config directory:

// .hull/package.json
{
  "dependencies": {
    "shescape": "^2.1.0"
  }
}

Hull runs bun install at startup so your plugins can import the packages:

// .hull/plugin/escape-bash.ts
import { escape } from "shescape"
import type { Plugin } from "@hullcode/plugin"

const EscapeBash: Plugin = async () => ({
  "tool.execute.before": async (input, output) => {
    if (input.tool === "bash") {
      output.args.command = escape(output.args.command)
    }
  },
})

export default { id: "escape-bash", server: EscapeBash }

Hooks reference

Every hook is optional. Return only the ones you care about.

Lifecycle

HookDescription
configReceives the fully-resolved config at startup. Read-only, useful for inspection.
eventCalled for every event on the internal bus (see Events).

Tools

HookDescription
toolMap of tool name → tool definition. Added tools are callable by the model.
tool.execute.beforeFires before a tool runs; you can mutate output.args.
tool.execute.afterFires after a tool returns; you can rewrite output.title, output.output, output.metadata.
tool.definitionMutate a tool's description and parameters before they are sent to the model.

Chat

HookDescription
chat.messageFires when a new user message arrives. Inspect or modify parts.
chat.paramsMutate temperature, topP, topK, maxOutputTokens, provider options.
chat.headersAdd or replace HTTP headers on the LLM API call.
permission.askAuto-allow or auto-deny permission prompts.
command.execute.beforeIntercept slash command execution; mutate the resulting parts.
shell.envInject environment variables into every shell command Hull runs.

Providers & auth

HookDescription
authRegister an auth method (OAuth or API key) for a provider, with interactive prompts.
providerDynamically supply a model catalog for a provider (useful for BYO-model gateways).

Provider hooks can replace or refresh the model catalog for a provider. The hook receives the provider definition and auth context, and returns a map of model ID to model metadata:

import type { Plugin } from "@hullcode/plugin"

const ProviderPlugin: Plugin = async () => ({
  provider: {
    id: "my-gateway",
    async models(provider, { auth }) {
      const res = await fetch("https://gateway.example.com/models", {
        headers: auth?.type === "api" ? { Authorization: `Bearer ${auth.key}` } : {},
      })
      return await res.json()
    },
  },
})

export default { id: "my-provider", server: ProviderPlugin }

Hull fills provider/model IDs from the returned catalog and uses the returned models in the picker and provider router.

Experimental

These hooks live behind the experimental. prefix and may change between releases.

HookDescription
experimental.chat.messages.transformRewrite the full message history before it is sent to the model.
experimental.chat.system.transformModify the system prompt array.
experimental.session.compactingInject extra context (output.context) or replace the compaction prompt entirely (output.prompt).
experimental.compaction.autocontinueDisable the synthetic "continue" turn that follows compaction.
experimental.text.completePost-process final text parts (e.g. append signatures, redact secrets).

Events

The event hook fires for every event on Hull's internal bus. Common event types include:

  • Session: session.created, session.updated, session.idle, session.error, session.deleted, session.compacted, session.diff, session.status
  • Message: message.updated, message.removed, message.part.updated, message.part.removed
  • Tool: tool.execute.before, tool.execute.after
  • Permission: permission.asked, permission.replied
  • File: file.edited, file.watcher.updated
  • Shell: shell.env
  • Command: command.executed
  • LSP: lsp.updated, lsp.client.diagnostics
  • Todo: todo.updated
  • Server: server.connected
  • Installation: installation.updated
const server: Plugin = async () => ({
  event: async ({ event }) => {
    if (event.type === "session.idle") {
      // session finished responding
    }
  },
})

Custom tools

Plugins can register tools the model can call alongside the built-in ones. Use the tool() helper for type-safety:

// .hull/plugin/database.ts
import type { Plugin } from "@hullcode/plugin"
import { tool } from "@hullcode/plugin/tool"

const DatabasePlugin: Plugin = async () => ({
  tool: {
    query: tool({
      description: "Run a read-only SQL query against the project database",
      args: {
        sql: tool.schema.string().describe("SQL query to execute"),
      },
      async execute(args, context) {
        const { directory, worktree } = context
        // your query logic here
        return `ran: ${args.sql}`
      },
    }),
  },
})

export default { id: "database", server: DatabasePlugin }

args uses a Zod schema via tool.schema. The tool's execute function receives:

  • args: validated against your schema
  • context: { sessionID, messageID, agent, directory, worktree, abort, metadata, ask }

Name precedence

If a custom tool uses the same name as a built-in tool, the custom tool wins. Prefer unique names unless you intentionally want to override a built-in (for example, to wrap bash with extra validation).

Alternative: standalone tool files

For tools that don't need the full plugin context, drop them in a tool/ or tools/ folder inside any config directory, for example .hull/tool/database.ts or ~/.config/hull/tool/database.ts. The file name becomes the tool name, and each file exports a tool() definition directly.

Examples

Configure CLI completion notifications

The CLI has built-in attention alerts for session completion, errors, and prompts that need input. You do not need a plugin or platform-specific notification command.

Enable notifications and sounds in hull console under Settings > CLI > Notifications, or configure the attention section of tui.json. See Notifications and sounds.

Block reads of .env files

// .hull/plugin/env-guard.ts
import type { Plugin } from "@hullcode/plugin"

const EnvGuard: Plugin = async () => ({
  "tool.execute.before": async (input, output) => {
    if (input.tool === "read" && String(output.args.filePath).includes(".env")) {
      throw new Error("reading .env files is blocked")
    }
  },
})

export default { id: "env-guard", server: EnvGuard }

Inject environment variables into every shell command

// .hull/plugin/inject-env.ts
import type { Plugin } from "@hullcode/plugin"

const InjectEnv: Plugin = async () => ({
  "shell.env": async (input, output) => {
    output.env.MY_API_KEY = "secret"
    output.env.PROJECT_ROOT = input.cwd
  },
})

export default { id: "inject-env", server: InjectEnv }

Structured logging

Prefer client.app.log() over console.log so entries land in Hull's log pipeline:

import type { Plugin } from "@hullcode/plugin"

const Logger: Plugin = async ({ client }) => {
  await client.app.log({
    body: {
      service: "my-plugin",
      level: "info",
      message: "plugin initialized",
      extra: { version: "1.0.0" },
    },
  })
  return {}
}

export default { id: "logger", server: Logger }

Levels: debug, info, warn, error.

Inject context during session compaction

// .hull/plugin/compaction.ts
import type { Plugin } from "@hullcode/plugin"

const Compaction: Plugin = async () => ({
  "experimental.session.compacting": async (input, output) => {
    output.context.push(
      "## Persist across compaction\n- current task status\n- files being actively edited\n- key decisions",
    )
  },
})

export default { id: "compaction", server: Compaction }

Set output.prompt to replace the default compaction prompt entirely, when present, output.context is ignored.

Stop auto-continuing after compaction

By default, Hull sends a synthetic "continue" turn after compaction so the agent resumes the interrupted task. Use experimental.compaction.autocontinue to disable that turn for specific sessions or providers:

const CompactionStop: Plugin = async () => ({
  "experimental.compaction.autocontinue": async (input, output) => {
    if (input.overflow) output.enabled = false
  },
})

The hook receives the sessionID, agent, model, provider, compacted message, and whether the compaction was caused by context overflow. output.enabled defaults to true.

TUI plugins

Plugins can also target the Hull TUI itself, registering slash commands, routes, slots, dialogs, and keybinds. TUI plugins are SolidJS modules exported from "./tui" in your plugin package, or theme-only packages declared with oc-themes.

TUI plugins live in a separate module namespace (@hullcode/plugin/tui) and have their own API surface (TuiPluginApi). The TUI API is larger and still changing, so this guide covers only the basics. Use the types in @hullcode/plugin/tui as the reference.

Common TUI APIs include:

  • api.command.register(...) to add commands and api.command.show() to open the command palette.
  • api.ui.Slot to render a host slot or a custom plugin slot.
  • api.slots.register(...) to define reusable custom slots for other plugins.
  • api.ui.Prompt to render prompt components in prompt replacement slots.

Host slots include home_prompt_right, session_prompt, session_prompt_right, and home_footer. The session_prompt slot replaces the default session prompt, while the *_prompt_right slots add controls next to the prompt metadata row.

Troubleshooting

  • Plugin failed to load: check the CLI logs with hull --print-logs --log-level DEBUG. Load failures are also surfaced as session errors in the TUI and VS Code extension.

  • Plugin loaded but hooks never fire: make sure the default export includes server:

    export default { id: "my-plugin", server }

    Named function exports are also accepted for backwards compatibility but should be considered legacy.

  • Package installed but not active in one runtime: make sure the package exposes the matching entrypoint. Server plugins need exports["./server"] or main; TUI plugins need exports["./tui"] or valid oc-themes. Packages that only support the other runtime are skipped with a warning instead of causing a fatal load error.

  • Local plugin can't find an npm import: add a package.json in the config directory so bun install picks up the dependency (see Dependencies).

  • Plugin loads locally but not in CI: check that HULL_PURE isn't set and that --pure isn't passed. Run with --log-level DEBUG to see install output.

  • Reset the plugin cache: delete the package's folder in ~/.cache/hull/packages/ (or the node_modules folder in your config directory) and restart Hull.

Reference

  • Types: the @hullcode/plugin package exports Plugin, Hooks, PluginInput, ToolDefinition, AuthHook and ProviderHook.
  • CLI command: hull plugin

On this page