nexo Plugin Contract
| Field | Value |
|---|---|
contract_version | 1.10.0 |
| Status | Stable |
| Authoritative reference | This document |
| Reference implementations | Host: crates/core/src/agent/nexo_plugin_registry/subprocess.rs. Rust child: crates/microapp-sdk/src/plugin.rs (feature plugin). Python / TypeScript / PHP children: github.com/lordmacu/nexo-plugin-sdks. See §11. |
This contract describes how an out-of-tree plugin binary communicates with the nexo daemon. A conforming plugin can be written in any language — Rust, Python, TypeScript, Go, etc. — as long as it implements the protocol defined here.
1. Transport
- Plugin runs as a child process of the daemon.
- Daemon writes to the child's
stdin. Child writes to itsstdout. stderris closed by the daemon (currently/dev/null— Phase 81.23 will collect it into structured tracing).- Each direction is a stream of newline-delimited UTF-8 lines.
- Each line is exactly one JSON-RPC 2.0 message — request, response, or notification.
- Lines must not exceed the platform pipe buffer (typically 4 KiB on Linux); fragmenting one JSON object across multiple lines is not supported.
2. Manifest
The plugin ships a nexo-plugin.toml file — schema defined by
the nexo-plugin-manifest crate. The fields relevant to this
contract are:
[plugin]
id = "slack" # ASCII slug, ^[a-z][a-z0-9_]{0,31}$
version = "0.2.0" # semver
name = "Slack Channel"
description = "..."
min_nexo_version = ">=0.1.0"
[plugin.requires]
nexo_capabilities = ["broker"]
# Phase 81.14 — subprocess entrypoint.
[plugin.entrypoint]
command = "/usr/local/bin/plugin-slack" # absolute path or PATH binary
args = ["--mode", "stdio"] # optional
env = { "RUST_LOG" = "info" } # optional, MUST NOT begin with "NEXO_"
# Phase 81.8 — channel kinds the plugin exposes. Drives the
# broker subscribe / publish allowlist (see §6).
[[plugin.channels.register]]
kind = "slack"
adapter = "SlackChannelAdapter"
The host parses this manifest at boot and uses
plugin.id to verify the child's identity in the initialize
handshake (§4.1). It uses plugin.entrypoint.command to spawn
the child process. Any env key beginning with NEXO_ is
rejected at boot — those names are reserved for the daemon's
own runtime configuration.
2.1 Extends section (Phase 81.28)
A subprocess plugin that contributes to a daemon-side registry
beyond [plugin.channels.register] declares its capabilities in
an additive [plugin.extends] section:
[plugin.extends]
channels = ["slack", "discord"] # paired with Phase 81.24 wrapper
llm_providers = ["cohere", "mistral"] # paired with Phase 81.25
memory_backends = ["pinecone", "qdrant"] # paired with Phase 81.26
hooks = ["pii_redact"] # paired with Phase 81.27
Each list names the IDs the plugin contributes to the matching registry. Validation rules:
- Each id MUST match
^[a-z][a-z0-9_]{0,31}$. - No duplicates within a single list.
- No cross-list duplicates — an id MUST occupy at most one of the four lists within a single plugin.
- All four fields default to empty; legacy manifests parse unchanged.
The four canonical sections are fixed in code
(EXTENDS_SECTIONS); adding a new capability surface requires a
manifest-crate change. This is intentional — the closed schema
keeps serde(deny_unknown_fields) defense intact and gates new
extension points behind a coordinated rollout.
[plugin.extends] is the declarative half of the
capability story. Daemon dispatch wiring — actually populating
LlmClientRegistry / memory backend store / HookInterceptor
registry — ships with Phase 81.24 (channels), 81.25 (LLM
providers), 81.26 (memory backends), and 81.27 (hooks).
Capability-negotiation handshake (verifying the subprocess's
initialize reply matches the declared extensions) is a
follow-up (81.28.b).
[plugin.extends].channels exists in parallel with
[plugin.channels.register] (§6 — topic allowlist). Use
extends for subprocess plugins routed through the future
remote ChannelAdapter wrapper; use register for in-tree
adapters that link directly into the daemon binary. Both
surfaces stay independent.
2.2 Sandbox section (Phase 81.22)
Subprocess plugins on Linux can opt into bubblewrap-based
isolation via an additive [plugin.sandbox] section. Default =
disabled — every existing manifest parses unchanged; the daemon
spawns the plugin as a normal child process.
[plugin.sandbox]
enabled = true # default false (opt-in)
network = "deny" # "deny" | "host"
fs_read_paths = ["/etc/ssl/certs"] # absolute, ro-bind into sandbox
fs_write_paths = ["${state_dir}"] # absolute, rw-bind. ${state_dir}
# token expands to the plugin's
# per-instance state root.
drop_user = true # default true; bwrap maps the
# child to nobody:nogroup (uid
# 65534) via --unshare-user.
When enabled, the daemon wraps the spawn Command with bwrap
flags:
- Process hardening:
--die-with-parent --unshare-pid --unshare-uts --unshare-ipc --new-session. - Filesystem skeleton:
--proc /proc --dev /dev --tmpfs /tmpplus read-only binds for/usr /bin /sbin /lib /lib64 /etc/ssl. The plugin command's parent dir is also auto-bound read-only so the binary is reachable inside the sandbox. - Network:
--unshare-netfornetwork = "deny". Fornetwork = "host"the operator must set theNEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW=1capability env var; the manifest validator otherwise rejects the field. - User:
--unshare-user --uid 65534 --gid 65534whendrop_user = true. - Allowlist: each
fs_read_pathsentry becomes--ro-bind <path> <path>; eachfs_write_pathsentry becomes--bind <path> <path>after${state_dir}expansion.
Operators control sandbox enforcement via two env knobs:
| Env var | Purpose |
|---|---|
NEXO_PLUGIN_SANDBOX_REQUIRE | When 1, the daemon refuses to spawn any plugin without sandbox.enabled = true. Strict-mode operator gate. |
NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW | When 1, manifests declaring network = "host" validate. Default off. |
Hard denylist (compile-time const) — operator-supplied allowlists that equal or include any of these paths are rejected at manifest load:
/etc/shadow,/etc/sudoers,/etc/sudoers.d/proc/sys,/proc/kcore,/proc/kallsyms/sys/firmware,/sys/kernel/dev/mem,/dev/kmem,/dev/port/var/run/docker.sock,/run/docker.sock,/private/var/run/docker.sock/root,/boot
Validation errors surface as ManifestError::Sandbox* variants
(SandboxAllowlistTouchesDenylist, SandboxRelativePath,
SandboxInvalidStateDirInterpolation,
SandboxHostNetworkWithoutCapability).
Platform support: Linux requires bubblewrap in PATH
(apt install bubblewrap). macOS is currently a no-op + tracing::warn!
log per spawn — native sandbox-exec integration is deferred to
follow-up 81.22.macos. With NEXO_PLUGIN_SANDBOX_REQUIRE=1 on
macOS, the daemon refuses to spawn (treats macOS as unsupported).
Out of scope for v1:
- Granular network egress allowlist (
network = "allowlist",network_allowlist = ["host:port"]) — defers to 81.22.b (slirp4netns + nftables). - Per-syscall seccomp filters — defers to 81.22.c.
- Cgroup / rlimit resource caps — Phase 81.21.c.
- Doctor CLI surface — defers to 81.22.d.
3. JSON-RPC envelope
All frames are valid JSON-RPC 2.0:
Request
{
"jsonrpc": "2.0",
"id": <integer or string>,
"method": "<method-name>",
"params": <object | null>
}
Response (success)
{
"jsonrpc": "2.0",
"id": <same as request>,
"result": <object | null>
}
Response (error)
{
"jsonrpc": "2.0",
"id": <same as request, null if request was un-parseable>,
"error": {
"code": <integer>,
"message": "<string>"
}
}
Notification
A notification is a request without an id field. The
peer must not reply.
{
"jsonrpc": "2.0",
"method": "<method-name>",
"params": <object | null>
}
The contract uses notifications for unidirectional broker events — see §5.
4. Lifecycle
4.1 initialize (host → child request)
After spawning the child, the daemon writes one initialize
request and awaits the response. The child must respond before
NEXO_PLUGIN_INIT_TIMEOUT_MS (default 5000) elapses or the
daemon kills it and surfaces PluginInitError::Other.
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "nexo_version": "0.1.5" }
}
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"manifest": { "plugin": { "id": "slack", "version": "0.2.0", ... } },
"server_version": "slack-0.2.0"
}
}
The child must echo a manifest whose plugin.id matches the
id the daemon expected (the id under which the plugin was
registered in the factory registry). Mismatch is a hard failure
— the daemon kills the child and refuses to load the plugin.
This defends against an out-of-tree binary impersonating a
different plugin.
server_version is a free-form string identifying the running
binary; the SDK defaults it to <id>-<version> from the
manifest.
4.1.1 Tool catalog advertisement (Phase 81.29, optional)
Plugins declaring [plugin.extends].tools = [...] MUST include
a tools array in the initialize-reply result. Each entry is
a RemoteToolDef:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"manifest": { "plugin": { "id": "browser", ... } },
"server_version": "browser-0.1.1",
"tools": [
{
"name": "browser_navigate",
"description": "Navigate to a URL",
"input_schema": {
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
}
}
]
}
}
Validation rules at the host:
- The
toolsfield is OPTIONAL whenextends.toolsis empty. Required (non-empty) when the manifest declares any tool. - Every advertised name MUST appear in
manifest.plugin.extends.tools. Drift in this direction (advertised but not declared) is a hard failure: the daemon kills the child and refuses to load. - Manifest entries WITHOUT an advertised counterpart are
tolerated but logged at warn — runtime calls to those tools
yield
-33401 ToolNotFound. namemust satisfy the per-plugin namespace rule (<plugin_id>_*orext_<plugin_id>_*).input_schemais an arbitrary JSONSchema object; the daemon caches it for arg validation before eachtool.invoke.
4.2 shutdown (host → child request)
The daemon sends shutdown when it wants the plugin to exit
gracefully. The child should flush state, then reply.
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "shutdown",
"params": { "reason": "host requested" }
}
Response:
{
"jsonrpc": "2.0",
"id": 2,
"result": { "ok": true }
}
Reply with an error object instead of result if shutdown
fails — the host surfaces PluginShutdownError::Other to the
operator.
After the reply, the daemon waits 1 second for the process
to exit on its own. If the child is still alive, the daemon
sends SIGKILL. So: reply, then exit.
5. Broker bridge
The wire-level shape of the broker bridge is two notifications:
5.1 broker.event (host → child)
Whenever the daemon's broker delivers an event on a topic
matching one of the plugin's outbound subscriptions (derived
from manifest.channels.register[].kind — see §6), the daemon
sends:
{
"jsonrpc": "2.0",
"method": "broker.event",
"params": {
"topic": "plugin.outbound.slack.team_a",
"event": {
"id": "01940000-0000-0000-0000-000000000001",
"timestamp": "2026-05-01T00:00:00Z",
"topic": "plugin.outbound.slack.team_a",
"source": "agent.coordinator",
"session_id": "01940000-0000-0000-0000-000000000099",
"payload": { "text": "hello", ... }
}
}
}
The event field is a serialised nexo_broker::Event. The
plugin processes the event (e.g. forwards payload.text to
Slack's API) and may reply with a broker.publish
notification (§5.2) — but it is not required to reply.
5.2 memory.recall (child → host request) <Phase 81.20.a>
When the plugin needs to look up agent memory entries, it issues
a JSON-RPC request to the daemon. Unlike broker.event /
broker.publish which are notifications, this is a
request-response flow: the child sends with an id and
awaits the matching reply.
Child → host request:
{
"jsonrpc": "2.0",
"id": 42,
"method": "memory.recall",
"params": {
"agent_id": "ventas_v1",
"query": "user prefers concise answers",
"limit": 5
}
}
Host → child reply (success):
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"entries": [
{
"id": "01940000-0000-0000-0000-000000000001",
"agent_id": "ventas_v1",
"content": "user prefers concise answers",
"tags": ["preference"],
"concept_tags": [],
"created_at": "2026-04-30T18:22:31Z",
"memory_type": null
}
]
}
}
Host → child reply (error):
-32601method not found (onlymemory.recallwired in 81.20.a;llm.complete/tool.dispatchship in 81.20.b/.c).-32602invalid params (missingagent_id/ wrong type forquery).-32603memory not configured (operator hasn't enabled long-term memory) OR memory backend returned an error.
limit defaults to 10, capped hard at 1000. The handler calls
LongTermMemory::recall(agent_id, query, limit) which already
expands the query with up to 3 derived concept tags so FTS5 hits
memories whose stored content diverges from the query surface.
5.3 llm.complete (child → host request) <Phase 81.20.b>
When the plugin needs an LLM completion, it issues a request and awaits the response.
Child → host request:
{
"jsonrpc": "2.0",
"id": 50,
"method": "llm.complete",
"params": {
"provider": "minimax",
"model": "minimax-m2.5",
"messages": [
{"role": "user", "content": "summarize this in one line: ..."}
],
"max_tokens": 1024,
"temperature": 0.7,
"system_prompt": "You answer concisely."
}
}
messages[].role is one of system, user, assistant, tool.
max_tokens defaults to 4096; temperature defaults to 0.7;
system_prompt is optional.
Host → child reply (success):
{
"jsonrpc": "2.0",
"id": 50,
"result": {
"content": "Concise reply text.",
"finish_reason": "stop",
"usage": {
"prompt_tokens": 25,
"completion_tokens": 8
}
}
}
finish_reason is one of stop, length, tool_use,
other:<reason>.
Host → child reply (errors):
-32602invalid params (missingprovider/model/messages, malformed message, empty messages array).-32603LLM not configured (operator hasn't wired the registry to the subprocess pipeline) OR client build failed (provider name not registered, config invalid) ORchat()call returned an error.-32601provider returned tool calls instead of text — MVP surfaces this asnot_implemented. The tool-call wire shape (which lets the child re-submittool_resultfollow-ups) lands in a future contract bump.
Daemon-side caps max_tokens at u32::MAX. Streaming via
llm.complete.delta notifications is opt-in via
params.stream = true (Phase 81.20.b.c).
Streaming flow
When the request includes "stream": true, the host calls
LlmClient::stream instead of chat. Each text chunk arrives
as a notification correlated to the original request id:
{
"jsonrpc": "2.0",
"method": "llm.complete.delta",
"params": { "request_id": 50, "chunk": "hello" }
}
{
"jsonrpc": "2.0",
"method": "llm.complete.delta",
"params": { "request_id": 50, "chunk": " world" }
}
The final reply matches the original id but carries only
finish_reason + usage — content is omitted because the
child reassembled it from deltas:
{
"jsonrpc": "2.0",
"id": 50,
"result": {
"finish_reason": "stop",
"usage": { "prompt_tokens": 12, "completion_tokens": 7 }
}
}
Tool-call deltas in streaming mode are dropped (same scope as
the non-streaming MVP). If the provider returns ONLY tool calls
during a stream (no text), the final reply is -32601
not_implemented.
5.4 broker.publish (child → host)
When the plugin wants to push an event onto the broker (e.g. delivering an inbound message from Slack), it writes:
{
"jsonrpc": "2.0",
"method": "broker.publish",
"params": {
"topic": "plugin.inbound.slack.team_a",
"event": {
"id": "01940000-0000-0000-0000-000000000002",
"timestamp": "2026-05-01T00:01:00Z",
"topic": "plugin.inbound.slack.team_a",
"source": "slack",
"session_id": null,
"payload": { "from": "U01ABC", "text": "hi", ... }
}
}
}
The host validates the topic against the allowlist (§6)
before forwarding to the broker. Topics outside the
allowlist are dropped with a tracing::warn! log and never
reach the broker.
5.x Channel methods (Phase 81.24)
Subprocess plugins that contribute new channel kinds (declared
in [plugin.extends].channels, §2.1) implement three host-
initiated request methods. The host's RemoteChannelAdapter
wraps each ChannelAdapter trait method into a JSON-RPC
request; the child replies with the corresponding result or a
typed error.
Every payload carries kind so a single subprocess advertising
multiple kinds (extends.channels = ["slack", "discord"]) can
dispatch via one request handler.
channel.start
// host → child
{
"jsonrpc": "2.0",
"id": 42,
"method": "channel.start",
"params": {
"kind": "slack",
"instance": "primary" // null when no per-instance multiplexing
}
}
// child → host
{ "jsonrpc": "2.0", "id": 42, "result": { "ok": true } }
Subscribe to plugin.outbound.<kind> (or per-instance
plugin.outbound.<kind>.<instance> when instance is set) and
begin publishing inbound events. Default host-side timeout
30 seconds.
channel.stop
// host → child
{
"jsonrpc": "2.0",
"id": 43,
"method": "channel.stop",
"params": { "kind": "slack" }
}
// child → host
{ "jsonrpc": "2.0", "id": 43, "result": { "ok": true } }
Release resources, drop subscriptions, stop publishing inbound. Idempotent. Default host-side timeout 30 seconds.
channel.send_outbound
// host → child
{
"jsonrpc": "2.0",
"id": 44,
"method": "channel.send_outbound",
"params": {
"kind": "slack",
"msg": { "kind": "text", "to": "U123", "body": "hi" }
}
}
// child → host (success)
{
"jsonrpc": "2.0",
"id": 44,
"result": { "message_id": "1234.5678", "sent_at_unix": 1741032000 }
}
msg.kind is one of text, media, or custom (see
OutboundMessage in §3 for the full enum). Default host-side
timeout 60 seconds. Operator override via
NEXO_PLUGIN_CHANNEL_TIMEOUT_MS env (single value applied to
all 3 methods).
Channel-specific error codes
In addition to the JSON-RPC standard codes (§7), channel.*
methods MAY return:
| Code | Meaning | Maps to ChannelAdapterError |
|---|---|---|
-33001 | channel.connection_failed | Connection { source: <message> } |
-33002 | channel.authentication_failed | Authentication { reason: <message> } |
-33003 | channel.recipient_invalid | Recipient { recipient: <data.recipient>, reason: <data.reason | message> } |
-33004 | channel.rate_limited | RateLimited { retry_after_secs: <data.retry_after_secs> } |
-33005 | channel.unsupported_feature | Unsupported { feature: <data.feature | message> } |
Error example:
{
"jsonrpc": "2.0",
"id": 44,
"error": {
"code": -33004,
"message": "rate limited",
"data": { "retry_after_secs": 42 }
}
}
-32601 method_not_found from a child means the plugin declared
the kind in extends.channels but did not implement the
requested method; the host surfaces this as
ChannelAdapterError::Unsupported { feature: "<method>" }.
5.y LLM provider methods (Phase 81.25)
Subprocess plugins that contribute LLM providers (declared in
[plugin.extends].llm_providers, §2.1) implement one host-
initiated request method with two modes (sync + streaming). The
host's RemoteLlmClient wraps each LlmClient trait call into
a JSON-RPC request; the child replies with the corresponding
result or a typed error.
Every payload carries provider so a single subprocess
advertising multiple providers (extends.llm_providers = ["cohere", "mistral"]) can dispatch via one request handler.
llm.chat (non-streaming)
// host → child
{
"jsonrpc": "2.0",
"id": 50,
"method": "llm.chat",
"params": {
"provider": "cohere",
"model": "command-r",
"stream": false,
"request": {
"model": "command-r",
"messages": [{ "role": "user", "content": "hi" }],
"max_tokens": 1024,
"temperature": 0.7
}
}
}
// child → host
{
"jsonrpc": "2.0",
"id": 50,
"result": {
"content": { "type": "text", "text": "Hello world" },
"usage": { "prompt_tokens": 12, "completion_tokens": 4 },
"finish_reason": { "kind": "stop" }
}
}
The full request schema mirrors nexo_llm::types::ChatRequest
fields (messages / tools / max_tokens / temperature /
system_prompt / stop_sequences / tool_choice /
system_blocks / cache_tools). tool_choice serializes as
{"kind":"auto"|"any"|"none"|"specific","name":"<n>"?}.
The full result schema:
content—{type:"text", text:"..."}OR{type:"tool_calls", tool_calls:[{id, name, arguments}]}usage—{prompt_tokens, completion_tokens}finish_reason—{kind:"stop"|"tool_use"|"length"|"other","reason":"<r>"?}cache_usage— optional{cache_read_input_tokens, cache_creation_input_tokens, input_tokens, output_tokens}
Default host-side timeout 60 seconds.
llm.chat (streaming)
// host → child
{
"jsonrpc": "2.0",
"id": 51,
"method": "llm.chat",
"params": {
"provider": "cohere",
"model": "command-r",
"stream": true,
"request": { "...": "as above" }
}
}
// child → host: zero or more deltas
{
"jsonrpc": "2.0",
"method": "llm.chat.delta",
"params": {
"request_id": 51,
"chunk": { "type": "text_delta", "delta": "Hello" }
}
}
{
"jsonrpc": "2.0",
"method": "llm.chat.delta",
"params": {
"request_id": 51,
"chunk": { "type": "text_delta", "delta": " world" }
}
}
// child → host: final response (id matches request)
{
"jsonrpc": "2.0",
"id": 51,
"result": {
"content": { "type": "text", "text": "" },
"usage": { "prompt_tokens": 12, "completion_tokens": 4 },
"finish_reason": { "kind": "stop" }
}
}
chunk.type values:
text_delta—{ delta: "<text>" }tool_call_start—{ id, name }tool_call_args_delta—{ id, delta }tool_call_end—{ id }usage—{ usage: {prompt_tokens, completion_tokens} }end—{ finish_reason: {kind, reason?} }
Default host-side stream timeout 300 seconds. Operator override
via NEXO_PLUGIN_LLM_TIMEOUT_MS env (single value applied to
both sync + streaming).
LLM-specific error codes
In addition to the JSON-RPC standard codes (§7), llm.chat
MAY return:
| Code | Meaning |
|---|---|
-33101 | llm.connection_failed |
-33102 | llm.authentication_failed |
-33103 | llm.rate_limited (data.retry_after_secs) |
-33104 | llm.model_not_found |
-33105 | llm.context_overflow |
Error example:
{
"jsonrpc": "2.0",
"id": 50,
"error": {
"code": -33103,
"message": "rate limited",
"data": { "retry_after_secs": 30 }
}
}
The host surfaces these as anyhow::Error with messages
operators can grep ("rate limited", "authentication failed",
etc.). Structured retry-after info lands in the message string
for v1; future contract bumps may add a typed
LlmProviderError enum.
5.z Hook methods (Phase 81.27)
Subprocess plugins that contribute hook handlers (declared in
[plugin.extends].hooks, §2.1) implement one host-initiated
request method.
hook.on_hook
// host → child
{
"jsonrpc": "2.0",
"id": 60,
"method": "hook.on_hook",
"params": {
"plugin_id": "compliance_plugin",
"hook_name": "before_message",
"event": {
"sender": "alice",
"body": "ping"
}
}
}
// child → host (block)
{
"jsonrpc": "2.0",
"id": 60,
"result": {
"abort": true,
"reason": "PII detected",
"decision": "block"
}
}
// child → host (transform — rewrite payload)
{
"jsonrpc": "2.0",
"id": 60,
"result": {
"abort": false,
"decision": "transform",
"transformed_body": "[REDACTED]"
}
}
// child → host (allow / no-op)
{
"jsonrpc": "2.0",
"id": 60,
"result": {}
}
The result shape is HookResponse (defined in
crates/extensions/src/runtime/mod.rs). Fields:
abort: bool(legacy block signal — Phase 11.6)reason: Option<String>(operator-readable explanation)override: Option<JsonValue>(key-by-key event mutation; non-object values logged + ignored)decision: Option<"allow" | "block" | "transform">(Phase 83.3 — richer audit signal)transformed_body: Option<String>(only meaningful withdecision: "transform")do_not_reply_again: bool(anti-loop signal — host suppresses pending auto-replies for the conversation)
Default host-side timeout: 5 seconds (lower than channel
30s and LLM 60s — hooks fire on the message hot path; long
timeouts block agent flow). Operator override via
NEXO_PLUGIN_HOOK_TIMEOUT_MS env.
Continue-on-error semantic
Every dispatch failure (transport closed, subprocess crash,
timeout, JSON-RPC error, malformed reply) returns
HookResponse::default() (Continue) on the host side. The
HookRegistry::fire loop continues iterating remaining
handlers and the agent flow does NOT break on subprocess
misbehavior.
This is the explicit philosophy from hook_registry.rs:
"extension misbehavior must not take down agent flow."
Operators see the failures via tracing::warn! (target
plugins.init and the handler's own dispatch logs).
-32601 method_not_found from a child means the plugin declared
extends.hooks = [...] but did not implement the wire method;
the host treats this as Continue (no hard failure).
5.w Memory backend methods (Phase 81.26)
Subprocess plugins that contribute vector store backends
(declared in [plugin.extends].memory_backends, §2.1)
implement three host-initiated request methods. The host's
RemoteVectorBackend wraps each VectorBackend trait method
into a JSON-RPC request; the child replies with the
corresponding result or a typed error.
Every payload carries backend so a single subprocess
advertising multiple backends (extends.memory_backends = ["pinecone", "qdrant"]) can dispatch via one request handler.
v1 ships the wire surface + registry only — operator-side
consumer wiring (LongTermMemory.recall_vector reading from
wire.vector_backend_registry) lands in 81.26.b. Operators
can audit registered backends today via
wire.vector_backend_registry.names().
memory.vector_upsert
// host → child
{
"jsonrpc": "2.0",
"id": 70,
"method": "memory.vector_upsert",
"params": {
"backend": "pinecone",
"collection": "kb",
"records": [
{
"id": "r1",
"content": "hello",
"embedding": [0.1, 0.2, 0.3],
"metadata": {"source": "kb"}
}
]
}
}
// child → host
{ "jsonrpc": "2.0", "id": 70, "result": { "count": 1 } }
embedding is a pre-computed dense vector (host-side embedder
or LLM provider produces it; backend stores). metadata is
opaque JSON the backend may filter against. Default host-side
timeout 30 seconds.
memory.vector_search
// host → child
{
"jsonrpc": "2.0",
"id": 71,
"method": "memory.vector_search",
"params": {
"backend": "pinecone",
"collection": "kb",
"query": {
"embedding": [0.1, 0.2, 0.3],
"limit": 10,
"filter": {"namespace": "tenant-1"}
}
}
}
// child → host
{
"jsonrpc": "2.0",
"id": 71,
"result": {
"matches": [
{
"id": "r1",
"content": "hello",
"score": 0.97,
"metadata": {"source": "kb"}
}
]
}
}
filter is opaque — backend interprets per its native
convention (Pinecone metadata filter, Qdrant filter
expression, Weaviate where, etc.). The host does NOT
validate or rewrite. score uses the backend's native scale
(cosine vs dot-product vs distance). Default host-side
timeout 10 seconds (search is hot-path).
memory.vector_delete
// host → child
{
"jsonrpc": "2.0",
"id": 72,
"method": "memory.vector_delete",
"params": {
"backend": "pinecone",
"collection": "kb",
"ids": ["r1", "r2"]
}
}
// child → host
{ "jsonrpc": "2.0", "id": 72, "result": { "count": 2 } }
Default host-side timeout 30 seconds. Operator override via
NEXO_PLUGIN_MEMORY_TIMEOUT_MS env (single value applied to
all 3 methods).
Memory-specific error codes
In addition to the JSON-RPC standard codes (§7), memory.*
methods MAY return:
| Code | Meaning | data fields |
|---|---|---|
-33301 | memory.collection_not_found | collection |
-33302 | memory.dimension_mismatch | expected, got |
-33303 | memory.rate_limited | retry_after_secs |
-33304 | memory.write_failed | (message) |
Error example:
{
"jsonrpc": "2.0",
"id": 70,
"error": {
"code": -33302,
"message": "dimension mismatch",
"data": { "expected": 768, "got": 2 }
}
}
The host surfaces these as anyhow::Error with messages
operators can grep ("dimension mismatch: expected 768, got 2",
"rate limited; retry after 60s", etc.).
5.t Tool methods (Phase 81.29)
Plugins declaring [plugin.extends].tools = [...] get a
host-initiated tool.invoke request per agent-loop tool call.
The daemon's LLM picks a tool name from the cached function-
calling spec (built from initialize-reply's tools array, see
§4.1.1), the agent's tool registry routes the call to a
RemoteToolHandler, and the handler serializes the call into
a tool.invoke JSON-RPC frame over the existing stdio bridge.
Default timeout: 60 s. Operator override via
NEXO_PLUGIN_TOOL_TIMEOUT_MS.
tool.invoke
Host → child request:
{
"jsonrpc": "2.0",
"id": 80,
"method": "tool.invoke",
"params": {
"plugin_id": "browser",
"tool_name": "browser_navigate",
"args": { "url": "https://example.com" },
"agent_id": "shopper"
}
}
Child → host reply on success — the result body is whatever
JSON shape the daemon's ToolHandler::call returns to the
agent loop. The conventional shape mirrors the in-tree
ToolResponse:
{
"jsonrpc": "2.0",
"id": 80,
"result": {
"content": [
{ "type": "text", "text": "Navigated to https://example.com" }
],
"is_error": false
}
}
Plugins MAY return any JSON Value — the daemon does not
validate the result shape beyond the JSON-RPC envelope. Tool
authors using the Rust SDK return ToolResponse directly.
Tool-specific error codes
| Code | Variant | Semantic |
|---|---|---|
-33401 | ToolNotFound | Plugin doesn't actually implement the declared tool name (drift between manifest and implementation) |
-33402 | ToolArgumentInvalid | Args failed plugin-side schema validation; surface details: <Value> for the offending fields |
-33403 | ToolExecutionFailed | Tool executed but raised a typed error (network failure, CDP hung, etc.) |
-33404 | ToolUnavailable | Resource exhausted, rate-limited, or otherwise transient. Optional data: { retry_after_ms: <u64> } |
-33405 | ToolDenied | Plugin's per-tenant authorization rejected the call (auth-style) |
-32601 | MethodNotFound | Plugin does not implement tool.invoke — manifest declared extends.tools but child doesn't handle the method |
The host surfaces these as anyhow::Error with messages
operators can grep ("tool not found", "argument invalid",
"unavailable; retry after 5s", etc.). The agent loop receives
the error and decides what to do (LLM retry, abort tool plan,
escalate).
6. Topic allowlist
The host derives subscribe + publish patterns from the
manifest's [[plugin.channels.register]] entries.
For each entry with kind = K:
| Direction | Patterns |
|---|---|
| Outbound (daemon → child) | plugin.outbound.K, plugin.outbound.K.> |
| Inbound (child → daemon) | plugin.inbound.K, plugin.inbound.K.> |
Wildcard semantics follow nexo_broker::topic::topic_matches:
*matches exactly one path segment.>matches one or more trailing segments (must have ≥1).- Plain segments match literally.
So plugin.inbound.slack.> matches plugin.inbound.slack.team_a
and plugin.inbound.slack.team_a.thread_42 but not
plugin.inbound.slack (no trailing segments). That's why both
exact and wildcard patterns are in the allowlist for each kind.
A child publish to a topic that does not match any pattern
in the allowlist is dropped — this is the host's primary defense
against a plugin attempting to hijack core nexo topics like
agent.route.* or command.*.
7. Error codes
-32xxx is JSON-RPC reserved range; nexo extensions live in
-31xxx (none used yet) and -32000..-32099 (implementation
defined).
| Code | Meaning |
|---|---|
-32700 | Parse error — line is not valid JSON |
-32600 | Invalid request — well-formed JSON but not JSON-RPC 2.0 |
-32601 | Method not found |
-32602 | Invalid params |
-32603 | Internal error |
-32000 | nexo: shutdown handler returned an error |
-32001..-32099 | Reserved for future nexo error variants |
The host translates each of these into a structured
PluginInitError or PluginShutdownError variant for operator
diagnostics.
8. Backpressure
The host's stdin writer feeds the child via a bounded mpsc
channel of depth 64. When the channel is full (the child is
processing more slowly than the broker is delivering events to
it), new broker.event notifications are dropped with a
warn-level log rather than blocking the daemon's broker.
This matches the at-most-once delivery semantics the broker itself promises — no plugin should be relying on every event arriving. Plugins that need durable delivery should subscribe to a NATS jetstream stream out-of-band, which is outside the scope of this contract.
9. Examples
9.1 Rust
Using the nexo-microapp-sdk crate with the plugin feature
(Phase 81.15.a):
use nexo_microapp_sdk::plugin::{PluginAdapter, BrokerSender}; use nexo_broker::Event; const MANIFEST: &str = include_str!("../nexo-plugin.toml"); #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { PluginAdapter::new(MANIFEST)? .on_broker_event(|topic: String, event: Event, broker: BrokerSender| async move { // Outbound: deliver to the external service. // (Pseudocode; replace with your channel client.) let payload = event.payload.clone(); let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or(""); send_to_slack(text).await; // Inbound: relay any reply back via the broker. let reply = Event::new( "plugin.inbound.slack", "slack", serde_json::json!({"echo": text}), ); let _ = broker.publish("plugin.inbound.slack", reply).await; }) .on_shutdown(|| async { Ok(()) }) .run_stdio() .await?; Ok(()) } async fn send_to_slack(_text: &str) {}
9.2 Python — nexoai
pip install nexoai (the nexo-plugin-sdk name was taken on PyPI; the
importable module stays nexo_plugin_sdk). Source:
nexo-plugin-sdks/python/.
import asyncio
from nexo_plugin_sdk import PluginAdapter, Event
MANIFEST = open("nexo-plugin.toml").read()
async def on_event(topic: str, event: Event, broker) -> None:
# call back into the host (memory.recall §5.2 / llm.complete §5.3):
entries = await broker.memory_recall(agent_id="my_agent", query="user prefers concise answers", limit=5)
result = await broker.llm_complete(provider="minimax", model="minimax-m2.5",
messages=[{"role": "user", "content": "summarize: ..."}])
await broker.publish("plugin.inbound.slack",
Event.new("plugin.inbound.slack", "slack", {"summary": result.content}))
async def main() -> None:
await PluginAdapter(manifest_toml=MANIFEST, on_event=on_event).run()
asyncio.run(main())
9.3 TypeScript / Node — nexo-plugin-sdk
npm install nexo-plugin-sdk. Source:
nexo-plugin-sdks/typescript/.
import { readFileSync } from "node:fs";
import { PluginAdapter, Event } from "nexo-plugin-sdk";
const adapter = new PluginAdapter({
manifestToml: readFileSync("nexo-plugin.toml", "utf-8"),
onEvent: async (topic, event, broker) => {
const entries = await broker.memoryRecall({ agentId: "my_agent", query: "user prefers concise answers", limit: 5 });
const result = await broker.llmComplete({ provider: "minimax", model: "minimax-m2.5",
messages: [{ role: "user", content: "summarize: ..." }] });
await broker.publish("plugin.inbound.slack",
Event.new("plugin.inbound.slack", "slack", { summary: result.content }));
},
});
await adapter.run();
9.4 PHP — nexo/plugin-sdk
composer require nexo/plugin-sdk (PHP ≥ 8.1 — uses Fibers). Source:
nexo-plugin-sdks/php/
(mirrored to nexo-plugin-sdk-php for Packagist).
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Nexo\Plugin\Sdk\{PluginAdapter, BrokerSender, Event};
$adapter = new PluginAdapter([
'manifestToml' => file_get_contents(__DIR__ . '/nexo-plugin.toml'),
'onEvent' => function (string $topic, Event $event, BrokerSender $broker): void {
$entries = $broker->memoryRecall(['agentId' => 'my_agent', 'query' => 'user prefers concise answers', 'limit' => 5]);
$r = $broker->llmComplete(['provider' => 'minimax', 'model' => 'minimax-m2.5',
'messages' => [['role' => 'user', 'content' => 'summarize: ...']]]);
$broker->publish('plugin.inbound.slack', Event::new('plugin.inbound.slack', 'slack', ['summary' => $r->content]));
// streaming: $broker->llmCompleteStream($opts, fn(string $chunk) => /* ... */);
},
]);
$adapter->run();
9.5 Tools — host-initiated tool.invoke (Phase 81.29)
A plugin that declares [plugin.extends].tools = ["myplugin_weather"]
in its manifest advertises a tool catalog at handshake (the
initialize reply's tools array, §4.1.1) and handles one
tool.invoke request per agent-loop tool call (§5.t). All four SDKs
expose the same surface: a catalog of tool definitions, one dispatch
handler (optionally with a context giving broker access mid-invocation),
and a typed -33401..-33405 error band.
Rust — crates/microapp-sdk with feature plugin:
use nexo_microapp_sdk::plugin::{PluginAdapter, ToolDef, ToolInvocation, ToolInvocationError}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { PluginAdapter::new(include_str!("../nexo-plugin.toml"))? .declare_tools(vec![ToolDef { name: "myplugin_weather".into(), description: "Current weather for a city".into(), input_schema: serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] }), }]) .on_tool(|inv: ToolInvocation| async move { let city = inv.args.get("city").and_then(|v| v.as_str()) .ok_or_else(|| ToolInvocationError::ArgumentInvalid("missing `city`".into()))?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": format!("Sunny in {city}") }], "is_error": false })) }) .run_stdio().await?; Ok(()) }
Python — nexoai:
import asyncio
from nexo_plugin_sdk import PluginAdapter, ToolDef, ToolInvocation, ToolArgumentInvalid, text_result
MANIFEST = open("nexo-plugin.toml").read()
async def on_tool(inv: ToolInvocation):
city = (inv.args or {}).get("city")
if not city:
raise ToolArgumentInvalid("missing `city`")
return text_result(f"Sunny in {city}")
async def main() -> None:
await PluginAdapter(
manifest_toml=MANIFEST,
tools=[ToolDef("myplugin_weather", "Current weather for a city",
{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]})],
on_tool=on_tool, # or on_tool_with_context=fn(inv, ctx) — ctx.broker = the on_event broker handle
).run()
asyncio.run(main())
TypeScript — nexo-plugin-sdk:
import { readFileSync } from "node:fs";
import { PluginAdapter, ToolArgumentInvalidError, textResult } from "nexo-plugin-sdk";
const adapter = new PluginAdapter({
manifestToml: readFileSync("nexo-plugin.toml", "utf-8"),
tools: [{ name: "myplugin_weather", description: "Current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }],
onTool: (inv) => {
const city = (inv.args as { city?: string } | null)?.city;
if (!city) throw new ToolArgumentInvalidError("missing `city`");
return textResult(`Sunny in ${city}`);
},
// or onToolWithContext: (inv, ctx) => { ... ctx.broker.memoryRecall(...) ... }
});
await adapter.run();
PHP — nexo/plugin-sdk:
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Nexo\Plugin\Sdk\{PluginAdapter, Tool, ToolArgumentInvalid, ToolDef, ToolInvocation};
$adapter = new PluginAdapter([
'manifestToml' => file_get_contents(__DIR__ . '/nexo-plugin.toml'),
'tools' => [new ToolDef('myplugin_weather', 'Current weather for a city',
['type' => 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']])],
'onTool' => function (ToolInvocation $inv) {
$city = $inv->args['city'] ?? null;
if (!$city) { throw new ToolArgumentInvalid('missing `city`'); }
return Tool::text("Sunny in {$city}");
},
// or 'onToolWithContext' => fn(ToolInvocation $inv, ToolContext $ctx) => /* $ctx->broker->memoryRecall(...) */
]);
$adapter->run();
Throwing one of the typed errors maps to the matching -33401..-33405
code: ToolNotFound (-33401), ToolArgumentInvalid (-33402, carries
details), ToolExecutionFailed (-33403 — also the catch-all for an
uncaught generic exception), ToolUnavailable (-33404, carries
retry_after_ms), ToolDenied (-33405). A tool.invoke arriving when
no handler is registered replies -32601. Declaring a tool whose name
is not in the manifest's [plugin.extends].tools is a hard failure at
construction (the daemon would otherwise kill the plugin — see §4.1.1).
10. Versioning + compatibility
This contract uses semver. The current version is 1.0.0.
| Change kind | Semver bump |
|---|---|
| Add a new optional manifest field | minor |
| Add a new optional method (host or child) | minor |
| Add a new optional notification | minor |
Add a new error code in -32000..-32099 | minor |
| Remove or rename a method / notification / field | major |
| Change the JSON shape of a method's params or result | major |
| Tighten validation (e.g. rejecting previously-allowed input) | major |
Plugins should declare the contract version they target via the
manifest's min_nexo_version field plus a future
contract_version field (Phase 81.16 follow-up). The host
rejects plugins targeting a major version it does not support.
11. Reference implementations
- Host adapter:
crates/core/src/agent/nexo_plugin_registry/subprocess.rs(SubprocessNexoPlugin) — Phase 81.14 + 81.14.b. - Rust child SDK:
crates/microapp-sdk/src/plugin.rs(PluginAdapter, featureplugin) — Phase 81.15.a. - Python / TypeScript / PHP child SDKs:
github.com/lordmacu/nexo-plugin-sdks—python/(PyPInexoai),typescript/(npmnexo-plugin-sdk),php/(Packagistnexo/plugin-sdk, via thenexo-plugin-sdk-phpmirror). All implementinitialize/broker.event/shutdown/broker.publish, the child→host callsmemory.recall/llm.complete(+llm.complete.deltastreaming), and the host→childtool.invokerequest + theinitialize-reply tool catalog (§4.1.1 / §5.t — Pythonnexoai≥ 0.4.0, TypeScript / PHP ≥ 0.3.0). - Go SDK: not yet planned.
11.1 Conformance kit
nexo-plugin-sdks/conformance/
is a cross-language conformance kit: one Python mock-host
(conformance/mock_host.py), a set of declarative scenarios
(conformance/scenarios/*.json — the expect* steps are the golden),
and one config-driven fixture per SDK. An SDK is conformant iff
python conformance/run.py --lang <lang> passes for it — the kit drives
the fixture through every exchange this contract defines (initialize /
shutdown / broker.event / broker.publish / memory.recall /
llm.complete (+ streaming) / tool.invoke + the §4.1.1 catalog) and
diffs the frames structurally (methods, ids, result/error shapes,
error codes, error.data shape, key presence/absence — not message
text). The nexo-plugin-sdks CI runs the {python, typescript, php}
matrix; the Rust SDK runs the same kit in this repo's CI via a shallow
clone (--lang rust --fixture <built-binary> --check-contract-version docs/src/plugins/contract.md — the version check ties §13's top entry
to the kit's SCENARIOS_TARGET, so a contract bump that lands without
updating the kit fails CI). The kit does not replace the per-SDK test
suites, which cover lang-specific robustness (async readers, Fiber
scheduling, the stdout guard, signal handling). Added in Phase 31.12;
the Rust-fixture wiring + reconciling the divergences the kit surfaces
(the Rust child SDK does not yet emit error.data.details /
error.data.retry_after_ms, and its nexo_broker::Event serializes
with extra id / timestamp / session_id fields the scripting SDKs
omit) is follow-up 31.12.b.
12. Out of contract scope
The following are part of the broader plugin platform but are deliberately out of THIS document's scope:
memory.recall/llm.complete/tool.dispatchRPC bridges (Phase 81.20) — let the child invoke daemon-mediated framework services.- Supervisor + respawn + resource limits (Phase 81.21).
- Sandbox (network + filesystem allowlist via manifest, Phase 81.22).
- Stdio → tracing bridge (Phase 81.23).
- Plugin marketplace + signing (Phase 31).
Each of these will either extend this contract additively (in
which case contract_version bumps minor) or live in a separate
contract document.
13. Changelog
| Version | Date | Changes |
|---|---|---|
1.0.0 | 2026-05-01 | Initial publication. Lifecycle (initialize / shutdown) + broker bridge (broker.event / broker.publish) + manifest [plugin.entrypoint] section. Host adapter shipped in Phase 81.14 + 81.14.b; Rust child SDK in Phase 81.15.a. |
1.1.0 | 2026-05-01 | Phase 81.20.a — memory.recall request-response added. Additive; existing 1.0.0 plugins continue to work unchanged. Manifest [plugin.supervisor] section (Phase 81.21.b) — additive. Host-side activation: Phase 81.17.b boot wire. Phase 81.21 supervisor + 81.21.b stderr tail capture. |
1.2.0 | 2026-05-01 | Phase 81.20.b — llm.complete request-response added. Additive. MVP supports text responses only; tool-call responses surface as -32601 not_implemented. Streaming (llm.complete.delta notifications) on roadmap as 81.20.b.b. Host-side runtime threading deferred to 81.20.b.b — daemon today returns -32603 "llm not configured" until main.rs threads LlmServices into the subprocess pipeline. |
1.3.0 | 2026-05-01 | Phase 81.20.b.b runtime threading shipped (memory + llm both flow end-to-end through production daemon path). Phase 81.20.b.c streaming added — llm.complete accepts stream: true opt-in; chunks emit as llm.complete.delta { request_id, chunk } notifications, final reply omits content. Additive — non-streaming requests unchanged. |
1.4.0 | 2026-05-04 | Phase 81.28 — [plugin.extends] manifest section added (channels / llm_providers / memory_backends / hooks lists). Schema-only this revision: parser + validator ship; daemon dispatch wiring per registry lands in Phase 81.24 (channels), 81.25 (LLM providers), 81.26 (memory backends), 81.27 (hooks). Additive — manifests without [plugin.extends] parse and validate unchanged. |
1.5.0 | 2026-05-04 | Phase 81.24 — channel.start / channel.stop / channel.send_outbound host-initiated request methods added (§5.x). Subprocess plugins declaring [plugin.extends].channels = [...] get one RemoteChannelAdapter per kind registered into the daemon's ChannelAdapterRegistry. Channel-specific error codes -33001 through -33005 map onto typed ChannelAdapterError variants. Default host-side timeouts: 30 s for start/stop, 60 s for send_outbound; NEXO_PLUGIN_CHANNEL_TIMEOUT_MS overrides all three. Additive — plugins not declaring channels are unaffected. |
1.6.0 | 2026-05-04 | Phase 81.25 — llm.chat host-initiated request method (sync + streaming via params.stream flag) + llm.chat.delta streaming notifications added (§5.y). Subprocess plugins declaring [plugin.extends].llm_providers = [...] get one RemoteLlmFactory per provider name registered into the daemon's LlmRegistry. LLM-specific error codes -33101 through -33105. Default timeouts: 60 s sync chat, 300 s streaming; NEXO_PLUGIN_LLM_TIMEOUT_MS overrides both. Additive — plugins not declaring llm_providers are unaffected. |
1.7.0 | 2026-05-04 | Phase 81.27 — hook.on_hook host-initiated request method added (§5.z). Subprocess plugins declaring [plugin.extends].hooks = [...] get one RemoteHookHandler per hook name registered into the daemon's HookRegistry. Reply shape is the existing HookResponse (already serde-derived); reused directly as wire type. Continue-on-error semantic: every dispatch failure (transport, timeout, JSON-RPC err, decode) returns HookResponse::default() so HookRegistry::fire keeps iterating + agent flow doesn't break. Default 5s timeout (lower than channels/LLM); NEXO_PLUGIN_HOOK_TIMEOUT_MS env override. Additive — plugins not declaring hooks are unaffected. |
1.8.0 | 2026-05-04 | Phase 81.26 — memory.vector_upsert / memory.vector_search / memory.vector_delete host-initiated request methods added (§5.w). Subprocess plugins declaring [plugin.extends].memory_backends = [...] get one RemoteVectorBackend per name registered into the daemon's VectorBackendRegistry. Memory-specific error codes -33301..=-33304. Default timeouts: 30s upsert/delete, 10s search; NEXO_PLUGIN_MEMORY_TIMEOUT_MS env override. v1 ships wire + registry only — consumer-side wiring (LongTermMemory.recall_vector reading from registry) lands in 81.26.b. Vector-only scope: short/long-term memory keep SQLite; plugin replaces ONLY the vector index. Additive — plugins not declaring memory_backends are unaffected. |
1.9.0 | 2026-05-04 | Phase 81.22 — [plugin.sandbox] manifest section added (§2.2). Linux-only bubblewrap-based isolation: 5 fields (enabled, network, fs_read_paths, fs_write_paths, drop_user). Hard denylist enforced via SANDBOX_DENYLIST_HOST_PATHS const — operator-supplied allowlists that cover or equal denylisted paths are rejected at validate time. Two operator capability env knobs: NEXO_PLUGIN_SANDBOX_REQUIRE (strict-mode rejection of sandbox-disabled plugins) + NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW (gate for network = "host"). macOS no-op + warn (native sandbox-exec deferred to 81.22.macos). Default off — every existing manifest parses and runs unchanged. Additive — plugins without [plugin.sandbox] are unaffected. |
1.10.0 | 2026-05-04 | Phase 81.29 — tool.invoke host-initiated request method added (§5.t) + initialize-reply tools array extension (§4.1.1). Subprocess plugins declaring [plugin.extends].tools = [...] advertise a tool catalog (name/description/input_schema) at handshake; daemon caches the schemas + builds typed function-calling defs for the LLM without per-call round-trip. Each agent-loop tool call becomes a single tool.invoke { plugin_id, tool_name, args, agent_id } request. Tool-specific error codes -33401..=-33405 map onto typed failures (ToolNotFound / ToolArgumentInvalid / ToolExecutionFailed / ToolUnavailable / ToolDenied). Default timeout 60 s; NEXO_PLUGIN_TOOL_TIMEOUT_MS env override. Subset check: advertised tools MUST be subset of extends.tools (drift detection). New extends.tools field is the 5th list in [plugin.extends] (joining channels/llm_providers/memory_backends/hooks). Tool name MUST satisfy per-plugin namespace policy from 81.3 (<plugin_id>_* or ext_<plugin_id>_*). Completes the 5-wrapper subprocess fleet (channels 81.24 + LLM 81.25 + hooks 81.27 + memory 81.26 + tools 81.29) — subprocess plugins can now contribute every category of host-side capability. Additive — plugins not declaring extends.tools are unaffected. |