Custom Tools
Feature Definition
Section titled “Feature Definition”Custom tools let users extend an agent beyond built-in primitives by defining new callable operations.
In practice, this is where a coding agent becomes a platform:
- internal company APIs (tickets, deploy, CI/CD)
- domain analyzers (security checks, linters, architecture rules)
- repo-specific workflows (generate changelog, bump versions, run release)
- “glue” for bespoke environments (databases, feature flags, staging toggles)
A custom tool system is not “just load code and run it.” It is an integration surface with security, lifecycle, and compatibility consequences.
Why Custom Tools Are Hard
Section titled “Why Custom Tools Are Hard”A production custom-tool system must solve multiple hard problems simultaneously:
- schema trust: model-facing schemas must be valid, stable, and not drift
- execution trust: tool code can run arbitrary actions (filesystem, network, secrets)
- lifecycle consistency: tool surface must persist across resume/retry/replay
- protocol bridging: model call -> runtime execution -> structured response
- permission boundaries: tool capability must be governable
- observability: tool calls must appear in UI and logs with stable IDs
The main design choice is where custom tools execute:
- inside the core agent process
- inside a controlled sandbox process
- outside the agent (client implements tool calls and returns results)
Codex and OpenCode both support “outside core” execution, but with different primitives.
See Also
Section titled “See Also”- MCP Client and Server MCP Integration for protocol-based external tool surfaces.
- Approval Flow for how dynamic tool calls are mediated at runtime.
- Hooks for non-tool extensibility paths that may overlap with custom-tool use cases.
Aider Implementation
Section titled “Aider Implementation”Aider at commit b9050e1d5faf8096eae7a46a9ecc05a86231384b does not implement a dynamic custom-tool registry.
Aider does have extensibility (you can patch Python and add features), but not a first-class “custom tool definition” mechanism.
Command Set Is Static
Section titled “Command Set Is Static”Aider’s slash commands are hard-coded cmd_* methods on Commands in references/aider/aider/commands.py.
Example: listing is implemented as cmd_ls.
basic_help() shows the discovery mechanism: it enumerates commands via get_commands() and then looks up cmd_<name>.
Implications:
- there is no user config location where you define
name,description, andinput_schema - there is no typed tool registry that changes per session
- there is no generic function-call bridge for custom tools
Practical Consequence
Section titled “Practical Consequence”Aider’s simplicity is a strength:
- tool surface is stable and versioned with the code
- no untrusted plugin modules are loaded
- the “custom tool” feature is effectively “modify Aider itself”
But it also means:
- platform integrations require forking or upstreaming patches
- tooling cannot be injected per-project or per-user as pure configuration
Codex Implementation
Section titled “Codex Implementation”Codex at commit 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476 implements custom tools as dynamic tools.
Dynamic tools are provided at thread start, validated by the app server, registered into the core tool router as normal function tools, and executed via an event bridge to the client.
This is a key architectural split:
- core owns: tool registry, model call dispatch, turn lifecycle
- client owns: implementing tool side effects and returning content items
Thread Start Ingestion and Validation
Section titled “Thread Start Ingestion and Validation”The app server accepts dynamic tool definitions and validates them in references/codex/codex-rs/app-server/src/codex_message_processor.rs.
The validation function is validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String>.
Validation rules enforced:
namemust not be emptynamemust not have leading or trailing whitespacenameis reserved if it is"mcp"or starts with"mcp__"- names must be unique (duplicate names rejected)
input_schemamust be supported by core parsing (codex_core::parse_tool_input_schema)
This ensures:
- names don’t collide with MCP namespace rules
- obvious schema garbage is rejected early
The test suite in the same file includes:
- a rejecting case for an unsupported schema (
{"type": "null"}) - an accepting case where schema is “sanitizable” (missing top-level
type)
Registry Wiring and Schema Sanitization
Section titled “Registry Wiring and Schema Sanitization”Core converts dynamic tool specs into OpenAI-style function tools in references/codex/codex-rs/core/src/tools/spec.rs.
The converter is dynamic_tool_to_openai_tool(tool: &DynamicToolSpec).
A critical detail: Codex sanitizes schemas before parsing them into its internal JsonSchema enum.
This is done by:
parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error>- which calls
sanitize_json_schema(&mut input_schema)
What sanitize_json_schema does (high-level):
- if the schema is a boolean (
true/false), it coerces to{ "type": "string" } - recursively sanitizes nested schema locations:
propertiesitemsoneOf/anyOf/allOf/prefixItems
- ensures every schema object has a
type:- if missing, it infers from common keywords
- otherwise defaults to
string
- normalizes union
type: [ ... ]by selecting the first supported type - ensures object schemas have a
propertiesmap - ensures array schemas have an
itemsschema
The goal is compatibility:
- tolerate schemas that omit
type - coerce unsupported fragments into a minimal safe subset
Tradeoff:
- sanitization can “widen” schemas and accept more inputs than the original tool author intended
Execution Bridge Flow
Section titled “Execution Bridge Flow”Codex executes dynamic tools via DynamicToolHandler in references/codex/codex-rs/core/src/tools/handlers/dynamic.rs.
Key behaviors:
DynamicToolHandler.kind() -> ToolKind::FunctionDynamicToolHandler.is_mutating(...) -> true
The handler flow:
- parse tool call payload
- parse arguments as JSON value (
serde_json::Value) - register a pending oneshot sender in turn state keyed by
call_id - emit
EventMsg::DynamicToolCallRequestto the client - await response via oneshot receiver
- return a function tool output containing content items (not just a string)
The pending call storage is in TurnState (references/codex/codex-rs/core/src/state/turn.rs):
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>- insert:
insert_pending_dynamic_tool(call_id, tx) - remove:
remove_pending_dynamic_tool(call_id)
Response delivery is mediated by Session.notify_dynamic_tool_response(call_id, response) in references/codex/codex-rs/core/src/codex.rs:
- it removes the pending entry
- sends response down the oneshot channel
- logs warnings if no pending entry exists (late/duplicate response)
Cancellation semantics:
- if the oneshot is never fulfilled (turn canceled or client disconnect), the handler returns a model-visible error:
"dynamic tool call was cancelled before receiving a response"
App-Server Bridging and API-Version Gating
Section titled “App-Server Bridging and API-Version Gating”The app server relays dynamic tool calls to the client in references/codex/codex-rs/app-server/src/bespoke_event_handling.rs.
Behavior:
- on
EventMsg::DynamicToolCallRequest(request):- if
ApiVersion::V2, it sends aServerRequestPayload::DynamicToolCall(params)request - it spawns a task to await the client’s response and then submits
Op::DynamicToolResponseback to the core thread - if not v2, it submits a fallback error response (
"dynamic tool calls require api v2")
- if
Response handling code is in references/codex/codex-rs/app-server/src/dynamic_tools.rs.
on_call_response(...):
- awaits the client request result
- on failure, submits fallback response:
"dynamic tool request failed"
- on JSON parse failure, submits fallback response:
"dynamic tool response was invalid"
- converts app-server protocol content items into core protocol content items
- submits
Op::DynamicToolResponse { id: call_id, response }
This makes the contract explicit:
- core expects
DynamicToolResponsecontent items - client is responsible for returning results in the agreed shape
Persistence and Replay Semantics
Section titled “Persistence and Replay Semantics”Dynamic tool definitions must persist across session resumption.
Codex persists and rehydrates per-thread state, including tool definitions, via its session storage and thread-start snapshots.
A minimal proof point exists in suite tests such as references/codex/codex-rs/core/tests/suite/sqlite_state.rs (dynamic tool state survives restart/backfill).
A strong determinism requirement emerges:
- if a resumed thread has a different tool set than the original, replay becomes ambiguous
Guardrails and Filters
Section titled “Guardrails and Filters”Codex has at least one mode that changes dynamic-tool exposure:
js_repl_tools_onlyhides dynamic tools from the model-exposed tool list
This is tested in references/codex/codex-rs/core/src/tools/spec.rs:
- dynamic tools can exist in the full router specs
- but are filtered out from the model’s direct tool options in JS-REPL-only mode
Operationally this means:
- there are configurations where dynamic tools are “installed” but not directly callable by the model
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode at commit 7ed449974864361bad2c1f1405769fd2c2fcdf42 implements custom tools as plugin tools.
Unlike Codex, OpenCode executes plugin tools inside the OpenCode process (TypeScript/Bun), but with a structured discovery and registry pipeline.
Registry Discovery Pipeline
Section titled “Registry Discovery Pipeline”The registry is ToolRegistry in references/opencode/packages/opencode/src/tool/registry.ts.
Discovery sources:
- filesystem custom tools:
- uses
new Bun.Glob("{tool,tools}/*.{js,ts}") - scans every directory in
Config.directories() - scan options:
absolute: truefollowSymlinks: truedot: true(includes dotfiles)
- installed plugins:
Plugin.list()returns plugin metadata- registry adds each
plugin.toolentry
Custom tool module structure:
- file basename becomes a
namespace - each exported
ToolDefinitionbecomes a tool - ids are computed as:
namespacefordefaultexportnamespace_<export_name>for other exports
Runtime Registration API
Section titled “Runtime Registration API”OpenCode supports adding tools programmatically:
ToolRegistry.register(tool: Tool.Info)
Behavior:
- replaces existing tool with same id
- otherwise pushes new tool into custom list
This enables:
- dynamic tool creation from plugins at runtime
- late-binding tools based on environment detection
Model/Provider Filtering
Section titled “Model/Provider Filtering”ToolRegistry.tools(model, agent?) filters exposure.
Examples of built-in filtering:
websearchandcodesearchonly enabled for OpenCode’s provider or whenOPENCODE_ENABLE_EXAis set- toggles between
apply_patchandedit/writebased on model ID heuristics:- GPT family uses
apply_patch - other models use
edit/write
- GPT family uses
This matters for custom tools because:
- the model does not see the entire registry; it sees the filtered view
Execution Contract
Section titled “Execution Contract”OpenCode uses a uniform tool interface (Tool.Info):
idinit(...)returns:parameters(Zod schema)descriptionexecute(args, ctx)
Plugin tool definitions are wrapped by fromPlugin(id, def).
Wrapping behavior:
- parameters are built from the plugin’s
def.argsobject viaz.object(def.args) - execution creates a
pluginCtxderived from the tool context, adding:directory: Instance.directoryworktree: Instance.worktree
- output is passed through
Truncate.output(result, {}, agent)
Truncation behavior (references/opencode/packages/opencode/src/tool/truncation.ts):
- limits:
MAX_LINES = 2000MAX_BYTES = 50 * 1024
- on truncation, it writes full output to
Global.Path.data/tool-output/<id> - it injects a hint encouraging Grep/Read or delegation to Task tool
So plugin tools are not allowed to flood the model context with huge tool results.
Operational Dependencies
Section titled “Operational Dependencies”OpenCode runs custom tools as Bun modules.
When custom tools are discovered:
- it calls
Config.waitForDependencies()before importing them
This implies:
- custom tools can depend on NPM packages
- tool loading can fail on install errors
- tool surface can change after dependency changes
Schema and Safety Differences vs Codex
Section titled “Schema and Safety Differences vs Codex”OpenCode plugin tools do not use JSON Schema.
They use Zod schemas defined in TypeScript.
Consequences:
- schema sanitization is not needed; the tool author writes executable validators
- tool arguments are runtime-validated inside the process
- there is no universal “schema subset” restriction like Codex’s internal
JsonSchemaenum
But:
- schema drift is now a code drift problem (package versioning, imports)
- plugin tools run in-process and can access the same runtime environment as OpenCode
Claude Code Implementation (Inferred)
Section titled “Claude Code Implementation (Inferred)”Source: Public documentation at code.claude.com/docs/ (closed source — architecture inferred from docs, not inspected code).
Claude Code has the most mature extensibility system of any reference implementation. It goes beyond “custom tools” into a full plugin platform with marketplace distribution, versioned packages, and managed enterprise controls. The extensibility surface has five distinct layers:
- Skills — prompt templates invoked as
/namecommands - Agents — specialized subagent definitions with their own tools and prompts
- Hooks — event handlers that fire on tool use, permissions, session lifecycle
- MCP servers — external tool providers via the Model Context Protocol
- LSP servers — language server integrations for code intelligence
All five can be delivered either as standalone config (in .claude/) or as plugins distributed through marketplaces.
Plugin Architecture
Section titled “Plugin Architecture”A plugin is a self-contained directory with a manifest at .claude-plugin/plugin.json and component directories at the root:
my-plugin/├── .claude-plugin/│ └── plugin.json # manifest (name, version, description, author, component paths)├── commands/ # slash command markdown files├── agents/ # subagent markdown definitions├── skills/ # agent skills with SKILL.md files├── hooks/│ └── hooks.json # event handler configuration├── .mcp.json # MCP server definitions├── .lsp.json # LSP server configurations├── settings.json # default settings (currently only 'agent' key)└── scripts/ # hook and utility scriptsThe manifest is optional. If omitted, Claude Code auto-discovers components in default locations and derives the plugin name from the directory name.
Manifest schema (key fields):
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes (if manifest exists) | Unique identifier (kebab-case). Used as skill namespace prefix |
version | string | No | Semantic version for update detection |
description | string | No | Brief plugin description |
author | object | No | {name, email?, url?} |
commands | string|array | No | Custom paths to command files/directories |
agents | string|array | No | Custom paths to agent files |
skills | string|array | No | Custom paths to skill directories |
hooks | string|object | No | Hook config path or inline config |
mcpServers | string|object | No | MCP config path or inline config |
lspServers | string|object | No | LSP config path or inline config |
Custom paths supplement default directories — they don’t replace them. All paths must be relative to plugin root and start with ./.
Environment variable: ${CLAUDE_PLUGIN_ROOT} expands to the absolute path of the plugin’s installation directory. Required for hooks and MCP configs because plugins are copied to a cache directory on install.
Plugin Namespacing
Section titled “Plugin Namespacing”All plugin components are namespaced by the plugin’s name field. A skill called hello in a plugin named my-plugin becomes /my-plugin:hello. This prevents conflicts when multiple plugins define components with the same name.
This is a stronger isolation model than any open-source reference:
- Codex: Reserves
mcp__prefix for MCP tools, but dynamic tools share a flat namespace - OpenCode: Plugin tools use
namespace_exportnaming but no colon-separated prefix - Aider: No extensibility namespace at all
Plugin Installation and Scopes
Section titled “Plugin Installation and Scopes”Plugins are installed to one of four scopes:
| Scope | Settings file | Use case |
|---|---|---|
user | ~/.claude/settings.json | Personal plugins across all projects (default) |
project | .claude/settings.json | Team plugins shared via version control |
local | .claude/settings.local.json | Project-specific, gitignored |
managed | managed-settings.json | Enterprise-controlled (read-only, update only) |
On install, plugins are copied to a local cache at ~/.claude/plugins/cache rather than used in-place. This has implications:
- Plugins cannot reference files outside their directory (path traversal blocked)
- Symlinks are followed during copy (workaround for shared resources)
- Version changes in
plugin.jsontrigger cache updates
Plugin Lifecycle CLI
Section titled “Plugin Lifecycle CLI”claude plugin install <name>[@marketplace] # install from marketplaceclaude plugin uninstall <name> # removeclaude plugin enable <name> # enable disabled pluginclaude plugin disable <name> # disable without uninstallingclaude plugin update <name> # update to latest versionclaude plugin list # list installed pluginsclaude plugin validate <path> # validate manifest and structureAll commands accept --scope user|project|local to target a specific settings file. The --plugin-dir flag on the main claude command loads a plugin directly for development without installation.
Marketplace Distribution Model
Section titled “Marketplace Distribution Model”A marketplace is a JSON catalog that lists available plugins and where to fetch them. The marketplace manifest lives at .claude-plugin/marketplace.json in a git repository.
Marketplace schema:
{ "name": "company-tools", "owner": { "name": "DevTools Team", "email": "devtools@company.com" }, "metadata": { "description": "Internal tooling plugins", "version": "1.0.0", "pluginRoot": "./plugins" }, "plugins": [ { "name": "code-formatter", "source": "./plugins/formatter", "description": "Auto formatting on save", "version": "2.1.0" }, { "name": "deploy-tools", "source": { "source": "github", "repo": "company/deploy-plugin" }, "description": "Deployment automation" } ]}Plugin source types:
| Source | Format | Notes |
|---|---|---|
| Relative path | "./plugins/name" | Directory within the marketplace repo |
| GitHub | {"source": "github", "repo": "owner/repo", "ref?", "sha?"} | Supports pinning to branch, tag, or exact commit |
| Git URL | {"source": "url", "url": "https://...git", "ref?", "sha?"} | Any git host |
| npm | {"source": "npm", "package": "name", "version?", "registry?"} | Installed via npm |
| pip | {"source": "pip", "package": "name", "version?", "registry?"} | Installed via pip |
Marketplace management CLI:
claude plugin marketplace add <source> # add from URL, path, or GitHub repoclaude plugin marketplace list # list configured marketplacesclaude plugin marketplace remove <name> # remove marketplaceclaude plugin marketplace update [name] # update all or specific marketplaceMarketplace sources for the marketplace itself: Users add marketplaces via GitHub (owner/repo), git URL, local path, or direct URL to marketplace.json.
Release Channels
Section titled “Release Channels”Marketplaces support release channels by maintaining multiple marketplace files pointing to different refs of the same plugin repos:
{ "plugins": [{ "name": "formatter", "source": {"source": "github", "repo": "co/formatter", "ref": "stable"} }] }
// latest-tools/marketplace.json{ "plugins": [{ "name": "formatter", "source": {"source": "github", "repo": "co/formatter", "ref": "latest"} }] }Different user groups receive different marketplaces via managed settings. The version field in plugin.json must differ between refs for update detection to work.
Strict Mode
Section titled “Strict Mode”The strict field on marketplace plugin entries controls authority:
true(default):plugin.jsonis authoritative for component paths; marketplace can supplementfalse: marketplace entry is the entire definition;plugin.jsonmust not declare components
This enables curated marketplaces where the marketplace operator restructures or restricts a plugin’s exposed components.
Enterprise Marketplace Controls
Section titled “Enterprise Marketplace Controls”The strictKnownMarketplaces setting in managed settings controls which marketplaces users can add:
| Value | Behavior |
|---|---|
| Undefined | No restrictions |
[] | Complete lockdown — no new marketplaces |
| List of sources | Allowlist — only matching marketplaces |
Supports exact matching for GitHub/URL sources and regex matching via hostPattern for internal git hosts. Restrictions are validated before any network requests.
Team-level marketplace auto-discovery is configured in .claude/settings.json:
{ "extraKnownMarketplaces": { "company-tools": { "source": {"source": "github", "repo": "org/plugins"} } }, "enabledPlugins": { "formatter@company-tools": true, "deploy@company-tools": true }}Hook System (Plugin Component)
Section titled “Hook System (Plugin Component)”Plugin hooks fire on Claude Code lifecycle events:
| Event | When it fires |
|---|---|
PreToolUse | Before Claude uses any tool |
PostToolUse | After successful tool use |
PostToolUseFailure | After tool execution fails |
PermissionRequest | When a permission dialog is shown |
UserPromptSubmit | When user submits a prompt |
Notification | When Claude Code sends notifications |
Stop | When Claude attempts to stop |
SubagentStart / SubagentStop | Subagent lifecycle |
SessionStart / SessionEnd | Session lifecycle |
TeammateIdle | Agent team teammate going idle |
TaskCompleted | Task being marked completed |
PreCompact | Before conversation history is compacted |
Hook types:
command: Execute shell commands/scriptsprompt: Evaluate a prompt with an LLM (uses$ARGUMENTSfor context)agent: Run an agentic verifier with tools (for complex verification)
Hooks support matchers (e.g., "matcher": "Write|Edit" to fire only on specific tools).
LSP Integration (Plugin Component)
Section titled “LSP Integration (Plugin Component)”LSP plugins give Claude real-time code intelligence (diagnostics, go-to-definition, find-references). Config format:
{ "go": { "command": "gopls", "args": ["serve"], "extensionToLanguage": { ".go": "go" } }}Optional fields: transport (stdio/socket), env, initializationOptions, settings, startupTimeout, shutdownTimeout, restartOnCrash, maxRestarts.
The language server binary is NOT bundled — users must install it separately. This is a “connector” model, not a “bundled” model.
Comparison with Open-Source References
Section titled “Comparison with Open-Source References”| Aspect | Claude Code | Codex | OpenCode | Aider |
|---|---|---|---|---|
| Extension model | Plugin packages with marketplace distribution | Dynamic tools via event bridge | Plugin tools in-process (Bun modules) | None (modify source) |
| Namespacing | Colon-separated (plugin:skill) | mcp__ prefix reserved | namespace_export | N/A |
| Distribution | Marketplace catalogs with versioned sources (GitHub, npm, pip, git) | None | None | N/A |
| Schema format | JSON Schema (for MCP tools); Markdown (for skills/agents) | JSON Schema with sanitization | Zod (TypeScript) | N/A |
| Installation scopes | user, project, local, managed | N/A | N/A | N/A |
| Enterprise controls | strictKnownMarketplaces allowlist | N/A | N/A | N/A |
| Hook events | 14 lifecycle events with matchers | N/A | N/A | N/A |
| LSP integration | Plugin-delivered LSP server configs | N/A | N/A | N/A |
| Caching/isolation | Plugins copied to cache on install | Session-level tool persistence | In-process, no isolation | N/A |
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Schema Drift Breaks Calls Quietly
Section titled “Schema Drift Breaks Calls Quietly”When tool schemas change, models often continue emitting the old shape for a while.
If errors are not crisp and structured, the model can loop.
Mitigations:
- explicit version fields in tool ids
- compatibility shims for 1-2 releases
- tight error messages (what field is wrong, what is expected)
Dynamic Tools Need Stable Identity
Section titled “Dynamic Tools Need Stable Identity”Name collisions are common:
- two tools named
deploy - a plugin tool named
mcp__foo__barcolliding with reserved namespaces
Mitigations:
- namespacing:
org_tool,org:tool, ororg.tool - reserved prefixes for built-ins
- conflict detection at registration time
Permission Is Not Optional for “Internal” Tools
Section titled “Permission Is Not Optional for “Internal” Tools”Custom tools are usually the most dangerous tools.
They touch:
- prod systems
- secrets
- user data
- destructive operations
If custom tools bypass permission gates, you have built remote code execution with a friendly UI.
Resume/Replay Must Preserve Tool Surface
Section titled “Resume/Replay Must Preserve Tool Surface”If you resume a thread and the tool set is different:
- replayed tool calls may not exist
- tool semantics may have changed
- you cannot reproduce prior outputs
Persist:
- tool definitions (or hashes) per thread
- tool version identifiers
Long-Running Tool Calls Need Explicit Lifecycle Events
Section titled “Long-Running Tool Calls Need Explicit Lifecycle Events”Custom tools often do:
- network calls
- multi-minute builds
- indexing
Without:
- started
- progress
- completed nobody can tell whether the agent is working or hung.
Codex solves this via the client request/response event bridge.
OpenCode solves this by treating tool execution as a first-class session part and rendering it in the TUI.
Output Truncation Must Be Designed, Not Bolted On
Section titled “Output Truncation Must Be Designed, Not Bolted On”OpenCode’s truncation mechanism is not just a limit.
It also:
- writes full output to a file
- provides a hint for how to continue without re-reading everything
This pattern should exist for custom tools from day one.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Tool Definition Model
Section titled “Tool Definition Model”Support two extension layers:
config tools: loaded from project/user configuration at startupsession tools: injected per session/thread (like Codex dynamic tools)
Normalize tool definitions into a single internal struct:
id(namespaced)descriptioninput_schema(JSON Schema or equivalent)executor(where/how it runs)permission_policyversion
Registry Architecture
Section titled “Registry Architecture”Implement a layered registry with deterministic precedence:
- built-ins
- config tools
- session tools
- runtime registrations (optional)
Collision policy:
- default: error on collision
- optional: allow override only with explicit
override=truein config
Execution Bridge
Section titled “Execution Bridge”Provide an execution bridge that works for both local and remote clients.
Event protocol:
ToolCallRequested { call_id, tool_id, args }ToolCallProgress { call_id, message, percent? }ToolCallCompleted { call_id, content_items, success }ToolCallFailed { call_id, error }ToolCallCancelled { call_id }
This matches Codex’s dynamic-tool architecture and keeps UI rendering consistent.
Validation and Security
Section titled “Validation and Security”Registration-time validation:
- enforce reserved prefixes
- enforce id regex
- enforce schema sanity (or schema compiler)
Call-time validation:
- validate args strongly
- reject unknown fields unless explicitly permitted
Permissions:
- require every custom tool to declare a permission category
- allow policy rules by tool id and by arg patterns
Determinism and Persistence
Section titled “Determinism and Persistence”Persist per-thread:
- tool definitions (or hash of definition)
- executor configuration
- version fields
Include tool hashes in transcript logs.
This allows:
- debugging tool drift
- reproducing executions
Output Shaping
Section titled “Output Shaping”Build truncation into the tool framework:
- hard caps by lines and bytes
- always provide an escape hatch to full output (file path)
- always provide a follow-up strategy message
Crates
Section titled “Crates”serde/serde_jsonfor schemas and argsschemarsfor schema generationtokiochannels for bridge eventsuuidfor stable call ids- OpenOxide permission/session crates
Plugin and Marketplace System
Section titled “Plugin and Marketplace System”Claude Code demonstrates that a mature coding agent needs a distribution layer beyond just “load custom tool definitions.” OpenOxide should plan for this in phases:
Phase 1 — Core extension loading (build first):
- Load skills, agents, hooks, and MCP configs from
.openoxide/directories - Support
--plugin-dirfor development/testing - Namespace extensions by source directory name (colon-separated:
plugin:tool)
Phase 2 — Plugin packaging:
- Define a plugin manifest format (
.openoxide-plugin/plugin.json) - Support plugin install/uninstall/enable/disable to user/project/local scopes
- Copy plugins to a local cache on install (isolation from source changes)
${OPENOXIDE_PLUGIN_ROOT}env var for portable paths in hook/MCP configs
Phase 3 — Marketplace distribution:
- Define a marketplace manifest format (
marketplace.jsonwith plugin entries and source types) - Support marketplace add/remove/update from GitHub repos, git URLs, and local paths
- Plugin sources: relative paths, GitHub, git URLs (npm/pip can be deferred)
- Version detection for update triggers (semantic versioning on
plugin.json)
Phase 4 — Enterprise controls (defer until needed):
- Managed settings scope for enterprise-pushed plugins
strictKnownMarketplacesallowlist for marketplace lockdown- Team auto-discovery via
extraKnownMarketplacesin project settings
The plugin caching model (copy-on-install to ~/.openoxide/plugins/cache) is worth adopting from Claude Code. It provides:
- Isolation from source repo changes mid-session
- Path traversal prevention (plugins can’t escape their directory)
- Clean uninstall (delete cache entry)
- Version-keyed cache directories for rollback
Key Design Decisions
Section titled “Key Design Decisions”- custom tools must be first-class, not “just plugins”
- schemas must be validated and versioned
- the execution boundary must be explicit (in-process vs bridged)
- permission gates must be part of the tool contract
- plugin namespacing must be enforced from day one to prevent collisions at scale
- distribution should be designed as a phased addition, not an afterthought
- enterprise controls (marketplace allowlists, managed scopes) should be structurally possible even if not implemented initially