nexo Plugin Contract

FieldValue
contract_version1.10.0
StatusStable
Authoritative referenceThis document
Reference implementationsHost: 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 its stdout.
  • stderr is 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 /tmp plus 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-net for network = "deny". For network = "host" the operator must set the NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW=1 capability env var; the manifest validator otherwise rejects the field.
  • User: --unshare-user --uid 65534 --gid 65534 when drop_user = true.
  • Allowlist: each fs_read_paths entry becomes --ro-bind <path> <path>; each fs_write_paths entry becomes --bind <path> <path> after ${state_dir} expansion.

Operators control sandbox enforcement via two env knobs:

Env varPurpose
NEXO_PLUGIN_SANDBOX_REQUIREWhen 1, the daemon refuses to spawn any plugin without sandbox.enabled = true. Strict-mode operator gate.
NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOWWhen 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 tools field is OPTIONAL when extends.tools is 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.
  • name must satisfy the per-plugin namespace rule (<plugin_id>_* or ext_<plugin_id>_*).
  • input_schema is an arbitrary JSONSchema object; the daemon caches it for arg validation before each tool.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):

  • -32601 method not found (only memory.recall wired in 81.20.a; llm.complete / tool.dispatch ship in 81.20.b/.c).
  • -32602 invalid params (missing agent_id / wrong type for query).
  • -32603 memory 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):

  • -32602 invalid params (missing provider / model / messages, malformed message, empty messages array).
  • -32603 LLM not configured (operator hasn't wired the registry to the subprocess pipeline) OR client build failed (provider name not registered, config invalid) OR chat() call returned an error.
  • -32601 provider returned tool calls instead of text — MVP surfaces this as not_implemented. The tool-call wire shape (which lets the child re-submit tool_result follow-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 + usagecontent 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:

CodeMeaningMaps to ChannelAdapterError
-33001channel.connection_failedConnection { source: <message> }
-33002channel.authentication_failedAuthentication { reason: <message> }
-33003channel.recipient_invalidRecipient { recipient: <data.recipient>, reason: <data.reason | message> }
-33004channel.rate_limitedRateLimited { retry_after_secs: <data.retry_after_secs> }
-33005channel.unsupported_featureUnsupported { 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:

CodeMeaning
-33101llm.connection_failed
-33102llm.authentication_failed
-33103llm.rate_limited (data.retry_after_secs)
-33104llm.model_not_found
-33105llm.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 with decision: "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.

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

CodeMeaningdata fields
-33301memory.collection_not_foundcollection
-33302memory.dimension_mismatchexpected, got
-33303memory.rate_limitedretry_after_secs
-33304memory.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

CodeVariantSemantic
-33401ToolNotFoundPlugin doesn't actually implement the declared tool name (drift between manifest and implementation)
-33402ToolArgumentInvalidArgs failed plugin-side schema validation; surface details: <Value> for the offending fields
-33403ToolExecutionFailedTool executed but raised a typed error (network failure, CDP hung, etc.)
-33404ToolUnavailableResource exhausted, rate-limited, or otherwise transient. Optional data: { retry_after_ms: <u64> }
-33405ToolDeniedPlugin's per-tenant authorization rejected the call (auth-style)
-32601MethodNotFoundPlugin 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:

DirectionPatterns
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).

CodeMeaning
-32700Parse error — line is not valid JSON
-32600Invalid request — well-formed JSON but not JSON-RPC 2.0
-32601Method not found
-32602Invalid params
-32603Internal error
-32000nexo: shutdown handler returned an error
-32001..-32099Reserved 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.

Rustcrates/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(())
}

Pythonnexoai:

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

TypeScriptnexo-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();

PHPnexo/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 kindSemver bump
Add a new optional manifest fieldminor
Add a new optional method (host or child)minor
Add a new optional notificationminor
Add a new error code in -32000..-32099minor
Remove or rename a method / notification / fieldmajor
Change the JSON shape of a method's params or resultmajor
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, feature plugin) — Phase 81.15.a.
  • Python / TypeScript / PHP child SDKs: github.com/lordmacu/nexo-plugin-sdkspython/ (PyPI nexoai), typescript/ (npm nexo-plugin-sdk), php/ (Packagist nexo/plugin-sdk, via the nexo-plugin-sdk-php mirror). All implement initialize / broker.event / shutdown / broker.publish, the child→host calls memory.recall / llm.complete (+ llm.complete.delta streaming), and the host→child tool.invoke request + the initialize-reply tool catalog (§4.1.1 / §5.t — Python nexoai ≥ 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.dispatch RPC 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

VersionDateChanges
1.0.02026-05-01Initial 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.02026-05-01Phase 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.02026-05-01Phase 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.02026-05-01Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.02026-05-04Phase 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.

See also