PLUGINS(7) NPCAgent Manual PLUGINS(7)

plugins — discovery, tools, providers, triggers, surfaces

Discovery

Everything outside contracts/ and kernel/ is a plugin, and plugins are found by walking three sources in a fixed order. The first registration of a given (kind, id) pair wins; a later collision is logged as registry.plugin_collision with policy="keep_first" and discarded.

Plugin discovery and tool-table composition Three discovery sources feed the plugin registry, which loads five shipped plugins. Three separate populators feed the tool registry: builtin tools, adapted tool plugins, and upstream MCP tools. Every dispatch then passes through an eight-stage pipeline. 1 · DISCOVERY — first wins on (kind, id) entry points group "npcagent.plugins" · 5 registered user directory $NPCAGENT_USER_PLUGIN_DIR or ~/.npcagent/plugins repo directory <repo_root>/plugins/ Registry(repo_root) manifest.json present? → use manifest.entrypoint (module:attr) absent → plugin.py, then __init__.py; prefer create_plugin() factory import + setup crash-isolated per plugin; discover() is one-shot shipped plugins (all version 0.1.0) cli · claude_code trigger · cron trigger · webhook trigger · file_watch mcp · local_mcp codex · gemini_cli · opencode have factories in _entry_points.py but are NOT wired 2 · TOOL TABLE — populated in three passes register_builtin_tools(...) 36 tools in 7 groups · provider_id = None not crash-isolated — a failure is a bug ToolPluginAdapter.adapt(...) one ToolSpec per provider.schema() entry name collision with a builtin → dropped adapt_mcp_tools(...) names namespaced mcp.<server>.<tool> async — surfaces await refresh_mcp_tools() ToolRegistry(bus=bus) register(spec, override=False) unregister(name) get(name) · list() list_schemas() → Anthropic shape dispatch(name, args, ...) ToolSpec carries name, description, schema, handler, is_available, provider_id, and two frozensets of sensitive arg / result field names duplicate name → ValueError consumers planner — capability cards validator — @tool reference check npc-mcp-server — tools/list npc tool list REST /plugins The provider CLI reaches these tools only through the downstream MCP shim named in its CAO profile 3 · DISPATCH — every tool call, in order 1 lookup tool_not_ found 2 available? tool_un- available 3 coerce str → int, float, bool 4 validate jsonschema validation_error 5 publish agent. tool_call 6 pre_hook optional 7 handler await · handler_error 8 publish agent. tool_result Arguments and results are redacted before they reach the event log: keys matching api_key, auth, credential, password, secret, signature, token, bearer, private_key, client_secret become [REDACTED], plus any field the ToolSpec marks sensitive. CancelledError from a handler is re-raised unwrapped and deliberately publishes no result event. The four ToolError codes are tool_not_found, tool_unavailable, validation_error and handler_error; __str__ renders "[code] message".
Figure 10 — discovery and dispatch. Three sources, three populators, eight dispatch stages. The tool registry is the single dispatch table for builtins, plugin-contributed tools and upstream MCP tools alike, which is why the validator can check a plan's @tool references against exactly the set an agent will actually be offered.
~/acme-api — npc plugin list clean install
$ npc plugin list --tools
                     Plugins (with tools)
┏━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┓
 Kind     ID           Version  Source       Tools 
┡━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━┩
 cli      claude_code  0.1.0    entry_point  -     
 trigger  cron         0.1.0    entry_point  -     
 trigger  webhook      0.1.0    entry_point  -     
 trigger  file_watch   0.1.0    entry_point  -     
 mcp      local_mcp    0.1.0    entry_point  -     
 tool     builtin      -        builtin      36    
└─────────┴─────────────┴─────────┴─────────────┴───────┘

$ cat ~/.npcagent/mcp.json
{
  "servers": {
    "fs": {
      "transport": "stdio",
      "command": "mcp-server-filesystem",
      "args": ["."]
    }
  }
}

Only claude_code is wired into the npcagent.plugins entry-point group among the CLI providers. builtin is a synthetic row — the 36 builtin tools do not come from a plugin, so _render_plugins collapses them into one line rather than pretending otherwise. Non-tool plugins render - in the Tools column, and a plugin whose schema() raises renders ?. After adding the mcp.json above and re-running, a mcp / mcp.fs / mcp.json row appears carrying however many tools that server advertises.

The tool table

Thirty-six builtin tools in seven groups, listed in registration order by BUILTIN_TOOL_NAMES. Two groups register unconditionally but return an error on dispatch when their dependency is missing — the memory tools when no LocalMemoryProvider was supplied, the persistence tools when no working_dir was.

The complete builtin set. Counts sum to 36.
GroupnTools
messaging3send_message get_messages mark_message_read
workflow signals11enqueue_workflow_signal get_workflow_state start_workflow complete_research request_review approve_review reject_review mark_step_complete report_error cancel_workflow escalate_workflow
review5submit_plan_review request_code_review code_review_complete checkpoint_review_complete get_pending_reviews
tasks3create_task get_my_tasks update_task_status
persistence2save_artifact read_artifact
external7web_search fetch_url notify current_time set_environment_variable get_environment_variable list_environment_variables
memory5memory_search memory_get memory_put memory_list_keys memory_compact_now

Two of these deserve a closer look because they reach the network.

web_search
Real, and unconfigured by default. It tries Brave first (BRAVE_API_KEY) and falls back to Exa (EXA_API_KEY), caching on (backend, query, limit). With neither key set it raises ExternalToolError("not_configured", …) naming both variables rather than returning empty results.
fetch_url
Guarded. It tracks the redirect chain, resolves DNS addresses, pins the host, and gates private-IP destinations behind an explicit operator opt-in. The url argument is marked sensitive, so it is redacted before the tool-call event is written to the log.

CLI providers

This is the area where README-level reading and source-level reading diverge most, so it is worth being precise. Four provider classes exist. One drives a subprocess. The other three parse output but cannot start or talk to a process — and the project says so in its own packaging comments.

Direct-driver maturity per provider class. All four production launch paths go through the CAO bridge regardless.
ProviderDirect driverEvidence in source
claude_code Complete _spawn_impl calls asyncio.create_subprocess_exec; _send_input_impl writes and drains stdin; _read_events_impl reads real stdout.readline(); _exit_impl sends SIGTERM, waits 5 s, then kills.
codex Parser only _spawn_impl does del prompt, working_dir, allowed_tools and mints a counter-based fake session id; _send_input_impl is a no-op. Events come from an in-memory queue fed by a test seam.
gemini_cli Parser only Both _spawn_impl and _send_input_impl raise NotImplementedError("GeminiCLIProvider subprocess driver lands with T078; use feed_line / probe_idle for parser-level testing.")
opencode Parser only _spawn_impl returns the literal string "opencode-1"; _send_input_impl raises NotImplementedError("… requires CAO integration; T053 ships parsing only.")

The parsers themselves are real and fixture-tested, and each provider declares a detection strategy chain in smart_detection.py. Claude Code alone gets a single native strategy; the others carry fallbacks because their output is less structured.

claude_code → native_stream_json
NDJSON from claude --output-format stream-json --input-format stream-json. Message kinds map to events: assistantagent.message, tool_useagent.tool_call, tool_resultagent.tool_result, resultagent.idle.
codex → native_stream_json, capture_diff_fallback, regex_fallback
JSON first, then a regex parser matching [ASSISTANT], [TOOL_CALL: name], [TOOL_RESULT: name] and [IDLE].
gemini_cli → capture_diff_fallback, probe_on_stale
No native structured mode at all. Idle detection is by marker strings (Ready., [End]) plus an active probe that sends a newline and watches for a response — described in the source as "advisory, not authoritative".
opencode → native_ndjson, capture_diff_fallback
NDJSON with message, tool, tool_output and done kinds.

capture_diff_fallback is a defence against silent upstream changes. It compares live output against the newest recorded fixture under tests/fixtures/clis/<provider>/<version>/ using difflib.SequenceMatcher at a 0.72 similarity threshold; below that it logs cli_detection.drift_detected and swaps to the fallback parser. Fixtures for boot, idle, tool_call and provider-specific scenarios ship in the repository.

The CAO bridge

Production execution does not use the direct provider classes at all. It uses cao_adapter.py, a 1316-line bridge to cli-agent-orchestrator — pinned exactly at ==2.1.1 in pyproject.toml. CAO owns the tmux session; NPCAgent owns the profile that tells the provider what it is and what tools it has.

The profile is a Markdown file with YAML frontmatter, written to CAO's agent store. The stanza that matters:

mcpServers: npcagent: type: stdio command: /path/to/venv/bin/python args: ["-m", "npcagent.plugins.clis.npc_mcp_server", "--repo-root", "/abs/path/to/repo", "--tool-working-dir", "/abs/path/to/.npcagent-worktrees/2352067ac26d"]

Those two paths are deliberately different, and the split is the single most consequential detail in the bridge. --repo-root points at the parent repository so workflow-signal tools enqueue rows in the database the executor is polling. --tool-working-dir points at the worktree so save_artifact and the environment tools write where the agent is actually working. This is what Runtime.rebind_workspace_tools() exists to support.

Three further details of the bridge are worth recording.

The system prompt is assembled once per spawner
CAOWorkflowSpawner captures a single WorkspaceSnapshot at construction, so the rendered <workspace-context> block is byte-stable for the whole run. That is a prompt-caching decision: a block that changes between phases would invalidate the cache every time. Memory tags are scrubbed from the result before it is stored or displayed.
Environment injection needs a monkeypatch
CAO does not expose a way to pass extra environment into the tmux session, so _create_terminal_sync temporarily patches terminal_service.tmux_client.server.new_session under a module lock and restores it in a finally. Honest, and clearly marked as a workaround.
Timeouts are patched into CAO too
NPCAGENT_CAO_SHELL_TIMEOUT_SECONDS (default 60 s) and a Claude startup timeout (default 120 s) are pushed into CAO's own globals, guarded by module-level "already patched" flags. NPCAGENT_CAO_KEEP_SESSIONS leaves the tmux session alive after a run for post-mortem inspection.

Triggers

Three trigger providers, all implementing the same protocol — setup, start, stop, teardown, emit() as an async iterator, and next_fire_times(limit=5). npc serve runs all three by default.

cron
Jobs persist to ~/.npcagent/cron/jobs.json, written atomically. Schedules accept five-field cron via croniter or duration shortcuts, and the duration parser is the same private helper the Routine primitive uses — deliberately re-imported so the two cannot drift. The at-most-once guarantee is structural: next_run_at is advanced before the payload is enqueued, so a crash mid-fire loses the run rather than duplicating it. A tick_once() method returning the number of jobs fired exists purely as a deterministic test seam.
webhook
A FastAPI app registering one POST /webhooks/{route} per configured route, bound to 127.0.0.1:8765 by default. Every request runs the same ordered gauntlet — see below. Prompt templates use {a.b.c} dotted-path substitution and render missing keys as empty strings, a deliberate choice carried over from a predecessor project.
file_watch
Wraps watchdog, one observer per config, bridging the watchdog thread into asyncio with loop.call_soon_threadsafe. Supported events are created, modified and deleted; moved is excluded on purpose because it carries a dest_path the payload shape does not model. A 0.5 s debounce collapses bursts on the same (path, event_type) pair — the stated motivation being an editor's write-rename-rename triple registering as one save.
Webhook request pipeline. Checks run in this order; the first failure returns.
#CheckStatusDetail
1Body size413cap defaults to 1,000,000 bytes
2HMAC signature401github scheme reads X-Hub-Signature-256: sha256=<hex>; generic reads X-Webhook-Signature. Compared with hmac.compare_digest.
3JSON parse400
4Rate limit429per-route token bucket, default 30/min, refilling continuously
5Idempotency200delivery id from body delivery_id, then X-Delivery-Id, then sha256(body). Duplicate returns {"status": "duplicate"}.
6Accepted202{"status": "accepted", "delivery_id": …} and the payload is queued

MCP, both ways

NPCAgent speaks MCP in both directions, and the two halves are easy to confuse because they live in sibling packages.

Upstream — clientplugins/mcp/local_mcp.py
NPCAgent consumes third-party MCP servers configured in ~/.npcagent/mcp.json. Two transports: stdio (spawns a subprocess) and http (streamable HTTP). Connection is lazy; tools/list is called once during connect and cached so the synchronous accessor needs no I/O. Idle sessions are swept after idle_timeout_seconds (default 300; set 0 to disable). A malformed entry raises rather than being skipped — "a typo in the config file fails loudly rather than silently dropping the server".
Downstream — serverplugins/clis/npc_mcp_server.py
NPCAgent exposes its own tool registry to the CAO-spawned provider. A stdio MCP server named npcagent, invoked with sys.executable so it runs in the same virtualenv. list_tools maps each ToolSpec to an MCP Tool; call_tool forwards to tool_registry.dispatch(..., agent_id="cao_mcp_client") and re-raises a ToolError as a RuntimeError so the SDK renders isError=True. Exit codes: 0 clean, 2 argparse, 1 unexpected.

Upstream tools are namespaced mcp.<server>.<tool> "so they cannot collide with builtins or tools from other MCP servers", and carry provider_id = "mcp.<server>" so they can be counted per server in npc plugin list --tools. A server that fails to connect is skipped with a warning; one broken entry cannot stop the runtime from starting.

Limitation

Only LocalMCPProvider is refreshed. A third-party plugin declaring kind = "mcp" is discovered and listed, but its tools are never adapted, because the base protocol has no way to enumerate configured server names. The runtime logs runtime.mcp_provider_unsupported and moves on.

Surfaces

Four surfaces, one runtime. Each obtains its Runtime through for_repo() and therefore sees the same event stream.

REST — python -m npcagent.plugins.surfaces.api

Every route on the FastAPI app. Localhost-only unless explicitly overridden.
MethodPathNotes
POST/goalsbody accepts goal, project, primitive_kind, schedule, trigger_type, trigger_route, workflow_id
GET/runsquery: status, root_id, parent_id, include_terminal, limit (1–1000, default 200)
GET/runs/{run_id}one run detail
GET/runs/{run_id}/eventsServer-Sent Events over the same kernel event stream
POST/runs/{run_id}/approvethe REST equivalent of npc approve
GET/needs-youaggregated Needs-You summary
GET/pluginsloaded plugins
GET/projectsquery: include_archived

Protection is two-layer: TrustedHostMiddleware restricted to localhost, 127.0.0.1 and [::1], plus optional bearer-token middleware. Binding a non-loopback host raises unless NPCAGENT_API_ALLOW_REMOTE is truthy and a token is set, with the reasoning stated in the error: "The REST API can mutate local code and run agents, so remote exposure must be explicit."

ACP — python -m npcagent.plugins.surfaces.acp

Newline-delimited JSON-RPC 2.0 over stdio, with no hard dependency on any ACP package. Logging is forced to stderr so stdout carries only protocol frames. Methods are registered under both snake_case and session/* aliases: initialize, authenticate, session/new, session/load, session/resume, session/fork, session/list, session/cancel, session/prompt, permission/request. Sessions are in-memory only, guarded by a threading.Lock.

Two honest gaps: available_tools() returns [] by design, with the rationale that advertising commands the dispatcher cannot execute would mislead clients; and session/cancel returns an unsupported error because active goal work cannot yet be interrupted. Editor configuration for Zed and VS Code is documented in docs/acp.md.

Dashboard — npc dashboard

An embedded single-page app on 127.0.0.1:8766 with a REST surface (/api/providers, /api/sessions and per-session input, switch, layout, resize, DELETE) plus a WebSocket at /ws/sessions/{session_id}. Its stated design position is that it "owns the frontend input layer and treats tmux/CAO as the backend session runner" — which is what lets it intercept slash commands before they reach the provider.

Codeman — npc codeman

A bridge, not an integration. It speaks only Codeman's local HTTP API, can start the server if it is not running, creates a shell session rooted at the current repo, and opens a browser. Nothing in the kernel or runtime knows Codeman exists.

See also

npcagent 0.1.0 Apache-2.0 PLUGINS(7)