Skip to content

Custom Tools

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.

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.



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.

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, and input_schema
  • there is no typed tool registry that changes per session
  • there is no generic function-call bridge for custom tools

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 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

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:

  • name must not be empty
  • name must not have leading or trailing whitespace
  • name is reserved if it is "mcp" or starts with "mcp__"
  • names must be unique (duplicate names rejected)
  • input_schema must 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)

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:
    • properties
    • items
    • oneOf / 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 properties map
  • ensures array schemas have an items schema

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

Codex executes dynamic tools via DynamicToolHandler in references/codex/codex-rs/core/src/tools/handlers/dynamic.rs.

Key behaviors:

  • DynamicToolHandler.kind() -> ToolKind::Function
  • DynamicToolHandler.is_mutating(...) -> true

The handler flow:

  1. parse tool call payload
  2. parse arguments as JSON value (serde_json::Value)
  3. register a pending oneshot sender in turn state keyed by call_id
  4. emit EventMsg::DynamicToolCallRequest to the client
  5. await response via oneshot receiver
  6. 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 a ServerRequestPayload::DynamicToolCall(params) request
    • it spawns a task to await the client’s response and then submits Op::DynamicToolResponse back to the core thread
    • if not v2, it submits a fallback error response ("dynamic tool calls require api v2")

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 DynamicToolResponse content items
  • client is responsible for returning results in the agreed shape

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

Codex has at least one mode that changes dynamic-tool exposure:

  • js_repl_tools_only hides 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 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.

The registry is ToolRegistry in references/opencode/packages/opencode/src/tool/registry.ts.

Discovery sources:

  1. filesystem custom tools:
  • uses new Bun.Glob("{tool,tools}/*.{js,ts}")
  • scans every directory in Config.directories()
  • scan options:
    • absolute: true
    • followSymlinks: true
    • dot: true (includes dotfiles)
  1. installed plugins:
  • Plugin.list() returns plugin metadata
  • registry adds each plugin.tool entry

Custom tool module structure:

  • file basename becomes a namespace
  • each exported ToolDefinition becomes a tool
  • ids are computed as:
    • namespace for default export
    • namespace_<export_name> for other exports

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

ToolRegistry.tools(model, agent?) filters exposure.

Examples of built-in filtering:

  • websearch and codesearch only enabled for OpenCode’s provider or when OPENCODE_ENABLE_EXA is set
  • toggles between apply_patch and edit/write based on model ID heuristics:
    • GPT family uses apply_patch
    • other models use edit/write

This matters for custom tools because:

  • the model does not see the entire registry; it sees the filtered view

OpenCode uses a uniform tool interface (Tool.Info):

  • id
  • init(...) returns:
    • parameters (Zod schema)
    • description
    • execute(args, ctx)

Plugin tool definitions are wrapped by fromPlugin(id, def).

Wrapping behavior:

  • parameters are built from the plugin’s def.args object via z.object(def.args)
  • execution creates a pluginCtx derived from the tool context, adding:
    • directory: Instance.directory
    • worktree: Instance.worktree
  • output is passed through Truncate.output(result, {}, agent)

Truncation behavior (references/opencode/packages/opencode/src/tool/truncation.ts):

  • limits:
    • MAX_LINES = 2000
    • MAX_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.

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

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 JsonSchema enum

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

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:

  1. Skills — prompt templates invoked as /name commands
  2. Agents — specialized subagent definitions with their own tools and prompts
  3. Hooks — event handlers that fire on tool use, permissions, session lifecycle
  4. MCP servers — external tool providers via the Model Context Protocol
  5. 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.

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 scripts

The 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):

FieldTypeRequiredDescription
namestringYes (if manifest exists)Unique identifier (kebab-case). Used as skill namespace prefix
versionstringNoSemantic version for update detection
descriptionstringNoBrief plugin description
authorobjectNo{name, email?, url?}
commandsstring|arrayNoCustom paths to command files/directories
agentsstring|arrayNoCustom paths to agent files
skillsstring|arrayNoCustom paths to skill directories
hooksstring|objectNoHook config path or inline config
mcpServersstring|objectNoMCP config path or inline config
lspServersstring|objectNoLSP 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.

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_export naming but no colon-separated prefix
  • Aider: No extensibility namespace at all

Plugins are installed to one of four scopes:

ScopeSettings fileUse case
user~/.claude/settings.jsonPersonal plugins across all projects (default)
project.claude/settings.jsonTeam plugins shared via version control
local.claude/settings.local.jsonProject-specific, gitignored
managedmanaged-settings.jsonEnterprise-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.json trigger cache updates
claude plugin install <name>[@marketplace] # install from marketplace
claude plugin uninstall <name> # remove
claude plugin enable <name> # enable disabled plugin
claude plugin disable <name> # disable without uninstalling
claude plugin update <name> # update to latest version
claude plugin list # list installed plugins
claude plugin validate <path> # validate manifest and structure

All 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.

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:

SourceFormatNotes
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 repo
claude plugin marketplace list # list configured marketplaces
claude plugin marketplace remove <name> # remove marketplace
claude plugin marketplace update [name] # update all or specific marketplace

Marketplace sources for the marketplace itself: Users add marketplaces via GitHub (owner/repo), git URL, local path, or direct URL to marketplace.json.

Marketplaces support release channels by maintaining multiple marketplace files pointing to different refs of the same plugin repos:

stable-tools/marketplace.json
{ "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.

The strict field on marketplace plugin entries controls authority:

  • true (default): plugin.json is authoritative for component paths; marketplace can supplement
  • false: marketplace entry is the entire definition; plugin.json must not declare components

This enables curated marketplaces where the marketplace operator restructures or restricts a plugin’s exposed components.

The strictKnownMarketplaces setting in managed settings controls which marketplaces users can add:

ValueBehavior
UndefinedNo restrictions
[]Complete lockdown — no new marketplaces
List of sourcesAllowlist — 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
}
}

Plugin hooks fire on Claude Code lifecycle events:

EventWhen it fires
PreToolUseBefore Claude uses any tool
PostToolUseAfter successful tool use
PostToolUseFailureAfter tool execution fails
PermissionRequestWhen a permission dialog is shown
UserPromptSubmitWhen user submits a prompt
NotificationWhen Claude Code sends notifications
StopWhen Claude attempts to stop
SubagentStart / SubagentStopSubagent lifecycle
SessionStart / SessionEndSession lifecycle
TeammateIdleAgent team teammate going idle
TaskCompletedTask being marked completed
PreCompactBefore conversation history is compacted

Hook types:

  • command: Execute shell commands/scripts
  • prompt: Evaluate a prompt with an LLM (uses $ARGUMENTS for 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 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.

AspectClaude CodeCodexOpenCodeAider
Extension modelPlugin packages with marketplace distributionDynamic tools via event bridgePlugin tools in-process (Bun modules)None (modify source)
NamespacingColon-separated (plugin:skill)mcp__ prefix reservednamespace_exportN/A
DistributionMarketplace catalogs with versioned sources (GitHub, npm, pip, git)NoneNoneN/A
Schema formatJSON Schema (for MCP tools); Markdown (for skills/agents)JSON Schema with sanitizationZod (TypeScript)N/A
Installation scopesuser, project, local, managedN/AN/AN/A
Enterprise controlsstrictKnownMarketplaces allowlistN/AN/AN/A
Hook events14 lifecycle events with matchersN/AN/AN/A
LSP integrationPlugin-delivered LSP server configsN/AN/AN/A
Caching/isolationPlugins copied to cache on installSession-level tool persistenceIn-process, no isolationN/A

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)

Name collisions are common:

  • two tools named deploy
  • a plugin tool named mcp__foo__bar colliding with reserved namespaces

Mitigations:

  • namespacing: org_tool, org:tool, or org.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.

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.


Support two extension layers:

  • config tools: loaded from project/user configuration at startup
  • session tools: injected per session/thread (like Codex dynamic tools)

Normalize tool definitions into a single internal struct:

  • id (namespaced)
  • description
  • input_schema (JSON Schema or equivalent)
  • executor (where/how it runs)
  • permission_policy
  • version

Implement a layered registry with deterministic precedence:

  1. built-ins
  2. config tools
  3. session tools
  4. runtime registrations (optional)

Collision policy:

  • default: error on collision
  • optional: allow override only with explicit override=true in config

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.

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

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

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
  • serde / serde_json for schemas and args
  • schemars for schema generation
  • tokio channels for bridge events
  • uuid for stable call ids
  • OpenOxide permission/session crates

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-dir for 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.json with 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
  • strictKnownMarketplaces allowlist for marketplace lockdown
  • Team auto-discovery via extraKnownMarketplaces in 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
  • 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