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.
@tool references against exactly the set an agent will
actually be offered.$ 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.
| Group | n | Tools |
|---|---|---|
| messaging | 3 | send_message get_messages mark_message_read |
| workflow signals | 11 | enqueue_workflow_signal get_workflow_state start_workflow complete_research request_review approve_review reject_review mark_step_complete report_error cancel_workflow escalate_workflow |
| review | 5 | submit_plan_review request_code_review code_review_complete checkpoint_review_complete get_pending_reviews |
| tasks | 3 | create_task get_my_tasks update_task_status |
| persistence | 2 | save_artifact read_artifact |
| external | 7 | web_search fetch_url notify current_time set_environment_variable get_environment_variable list_environment_variables |
| memory | 5 | memory_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 raisesExternalToolError("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
urlargument 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.
| Provider | Direct driver | Evidence 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:assistant→agent.message,tool_use→agent.tool_call,tool_result→agent.tool_result,result→agent.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_outputanddonekinds.
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:
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
CAOWorkflowSpawnercaptures a singleWorkspaceSnapshotat 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_synctemporarily patchesterminal_service.tmux_client.server.new_sessionunder a module lock and restores it in afinally. 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_SESSIONSleaves 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 viacroniteror 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_atis advanced before the payload is enqueued, so a crash mid-fire loses the run rather than duplicating it. Atick_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 to127.0.0.1:8765by 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 withloop.call_soon_threadsafe. Supported events arecreated,modifiedanddeleted;movedis excluded on purpose because it carries adest_paththe 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.
| # | Check | Status | Detail |
|---|---|---|---|
| 1 | Body size | 413 | cap defaults to 1,000,000 bytes |
| 2 | HMAC signature | 401 | github scheme reads X-Hub-Signature-256: sha256=<hex>; generic reads X-Webhook-Signature. Compared with hmac.compare_digest. |
| 3 | JSON parse | 400 | — |
| 4 | Rate limit | 429 | per-route token bucket, default 30/min, refilling continuously |
| 5 | Idempotency | 200 | delivery id from body delivery_id, then X-Delivery-Id, then sha256(body). Duplicate returns {"status": "duplicate"}. |
| 6 | Accepted | 202 | {"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 — client —
plugins/mcp/local_mcp.py - NPCAgent consumes third-party MCP servers configured in
~/.npcagent/mcp.json. Two transports:stdio(spawns a subprocess) andhttp(streamable HTTP). Connection is lazy;tools/listis called once during connect and cached so the synchronous accessor needs no I/O. Idle sessions are swept afteridle_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 — server —
plugins/clis/npc_mcp_server.py - NPCAgent exposes its own tool registry to the CAO-spawned provider. A stdio MCP
server named
npcagent, invoked withsys.executableso it runs in the same virtualenv.list_toolsmaps eachToolSpecto an MCPTool;call_toolforwards totool_registry.dispatch(..., agent_id="cao_mcp_client")and re-raises aToolErroras aRuntimeErrorso the SDK rendersisError=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.
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
| Method | Path | Notes |
|---|---|---|
POST | /goals | body accepts goal, project, primitive_kind, schedule, trigger_type, trigger_route, workflow_id |
GET | /runs | query: status, root_id, parent_id, include_terminal, limit (1–1000, default 200) |
GET | /runs/{run_id} | one run detail |
GET | /runs/{run_id}/events | Server-Sent Events over the same kernel event stream |
POST | /runs/{run_id}/approve | the REST equivalent of npc approve |
GET | /needs-you | aggregated Needs-You summary |
GET | /plugins | loaded plugins |
GET | /projects | query: 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.