PHP plugin SDK

Author plugins in PHP 8.1+ that the daemon spawns as subprocesses, talking the same JSON-RPC 2.0 wire format used by the Rust + Python + TypeScript SDKs.

Reference template: extensions/template-plugin-php/ (or run nexo plugin new --lang php). The SDK package lives in the nexo-plugin-sdks repo (php/ subdir, mirrored to nexo-plugin-sdk-php for Packagist) and ships on Packagist as nexo/plugin-sdkcomposer require nexo/plugin-sdk.

Why PHP 8.1+

The SDK uses Fibers (introduced in PHP 8.1) to run each broker.event handler as a cooperative coroutine. Without Fibers the dispatch loop would block on slow handlers, breaking the contract invariant proven necessary by the TS + Python SDKs.

Architecture summary

Operator host                          Plugin process
┌──────────────────┐    stdin    ┌────────────────────────────┐
│ daemon (Rust)    │──JSON-RPC──▶│ bin/<id> (bash launcher)   │
│ subprocess host  │             │   exec php main.php        │
│                  │◀──JSON-RPC──│   PluginAdapter::run()     │
└──────────────────┘    stdout   │   Fiber scheduler ticks    │
                                 │   between stdin polls      │
                                 └────────────────────────────┘

The bash launcher in bin/<id> runs:

exec env php -d display_errors=stderr -d log_errors=0 \
    "$DIR/lib/plugin/main.php" "$@"

-d display_errors=stderr is critical — without it, PHP's default behavior writes errors to stdout, which would corrupt the JSON-RPC frame stream.

Daemon-side spawn code in crates/core/src/agent/nexo_plugin_registry/subprocess.rs treats the plugin as an opaque executable; PHP plugins re-use it without modification.

Public API

use Nexo\Plugin\Sdk\PluginAdapter;     // async dispatch loop
use Nexo\Plugin\Sdk\BrokerSender;      // write-only broker handle
use Nexo\Plugin\Sdk\Event;             // value object
use Nexo\Plugin\Sdk\Manifest;          // standalone TOML parser
use Nexo\Plugin\Sdk\StdoutGuard;       // defensive guard
use Nexo\Plugin\Sdk\Wire;              // JSON-RPC frame helpers + MAX_FRAME_BYTES
use Nexo\Plugin\Sdk\PluginError;       // base exception
use Nexo\Plugin\Sdk\ManifestError;     // raised when manifest malformed
use Nexo\Plugin\Sdk\WireError;         // raised on malformed/oversized frames

PluginAdapter constructor options:

OptionRequiredDescription
manifestToml: stringBody of nexo-plugin.toml. Read once at startup; the SDK validates plugin.id (regex /^[a-z][a-z0-9_]{0,31}$/), plugin.version, plugin.name, plugin.description.
serverVersion?: stringReturned in the initialize reply. Default "0.1.0".
onEvent?: callable(string, Event, BrokerSender): voidInvoked for every broker.event notification. Runs in a Fiber so the dispatch loop continues.
onShutdown?: callable(): voidAwaited before {ok: true} reply to the host's shutdown request. In-flight Fibers (onEvent + tool.invoke) also drained first.
tools?: ToolDef[]new ToolDef($name, $description, $inputSchema)[] — the tool catalog advertised in the initialize reply's tools array (contract §4.1.1; serialized with the wire key input_schema). Every $name must appear in the manifest's [plugin.extends].tools — otherwise the constructor throws ManifestError.
onTool?: callable(ToolInvocation): mixedDispatch handler for tool.invoke (contract §5.t). Runs in a Fiber tracked by the scheduler's drain set. Mutually exclusive with onToolWithContext.
onToolWithContext?: callable(ToolInvocation, ToolContext): mixedLike onTool, but $ctx->broker is the same BrokerSender onEvent gets — a tool body can memoryRecall / llmComplete mid-invocation. Wins over onTool when both are set.
enableStdoutGuard?: bool⬜ default trueInstalls an ob_start callback that diverts non-JSON echo/print/printf/var_dump output to stderr tagged with [stdout-guard].
maxFrameBytes?: int⬜ default 1048576Reject inbound frames larger than this with WireError; dispatch continues.
handleProcessSignals?: bool⬜ default trueListen for SIGTERM + SIGINT via pcntl_async_signals and trigger graceful shutdown (drain in-flight, exit 0).

Tool dispatch (tool.invoke, contract §4.1.1 + §5.t)

The tool classes live in src/Tool.php (loaded via the files autoload entry alongside src/Host.php):

use Nexo\Plugin\Sdk\{PluginAdapter, Tool, ToolDef, ToolInvocation, ToolContext,
    ToolNotFound, ToolArgumentInvalid, ToolExecutionFailed, ToolUnavailable, ToolDenied};

$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']])],
    'onToolWithContext' => function (ToolInvocation $inv, ToolContext $ctx): mixed {
        if ($inv->toolName !== 'myplugin_weather') { throw new ToolNotFound($inv->toolName); }
        $city = $inv->args['city'] ?? null;
        if (!$city) { throw new ToolArgumentInvalid('missing `city`', ['field' => 'city']); }
        // $ctx->broker is the onEvent broker handle — e.g. $ctx->broker->memoryRecall(['agentId' => $inv->agentId ?? '', 'query' => $city]);
        return Tool::text("Sunny in {$city}");   // any JSON value is fine; this is the conventional shape
    },
    // or 'onTool' => fn(ToolInvocation $inv) => ... when you don't need the broker
]);
$adapter->run();

The handler's return value becomes the JSON-RPC result verbatim (non-encodable → -33403). Throwing ToolNotFound / ToolArgumentInvalid ($details) / ToolExecutionFailed / ToolUnavailable ($retryAfterMs) / ToolDenied maps to the matching -33401..-33405 code (the code is carried via parent::__construct($msg, $code) like RpcServerError — read it with getCode()); an uncaught \Throwable maps to -33403; a tool.invoke with no handler registered replies -32601. (Packagist nexo/plugin-sdk ≥ 0.3.0.)

Tarball convention (noarch)

Operators install PHP plugins via the same nexo plugin install <owner>/<repo>[@<tag>] CLI. The resolver in nexo-ext-installer falls back to noarch when no per-target tarball matches the daemon's host triple (Phase 31.4):

<id>-<version>-noarch.tar.gz
├── nexo-plugin.toml
├── bin/<id>           # bash launcher mode 0755
└── lib/
    ├── plugin/main.php
    └── vendor/        # composer install --no-dev output
        ├── autoload.php
        ├── nexo/plugin-sdk/...
        ├── yosymfony/toml/...
        └── composer/...

Composer integration

Templates consume the in-tree SDK via a path repository:

"repositories": [
  {
    "type": "path",
    "url": "../sdk-php",
    "options": { "symlink": false }
  }
]

symlink: false is critical — without it Composer creates a symlink in vendor/nexo/plugin-sdk/ pointing at the path repo. When the tarball is packed, that symlink would break on the operator host. With symlink: false Composer copies the SDK files physically — the tarball stays self-contained.

The publish workflow runs:

composer install --no-dev --optimize-autoloader --classmap-authoritative

This produces a deterministic + smallest vendor tree. The operator host does NOT need Composer installed — the vendor/autoload.php shipped in the tarball is plain PHP and works with just php-cli.

composer.lock is checked in for the template (reproducibility analogous to Cargo.lock for binary projects). The SDK itself omits the lockfile so consumers resolve fresh against their own constraints.

Pure-PHP deps constraint

noarch requires that vendored deps work on every operator's CPU. Native PHP extensions (*.so, *.dylib, *.dll) are normally loaded via php.ini from /usr/lib/php/<version>/, NOT vendored. If a Composer dep smuggles in a native build artifact under vendor/, the publish workflow's scripts/verify-pure-php.sh audit step rejects the tarball.

If your plugin needs a native dep, per-target tarballs are tracked as Phase 31.5.c.b and not yet shipped.

Stdout guard — what's guarded vs not

APIBehavior
echo $x;✅ Guarded — non-JSON lines diverted to stderr.
print $x;✅ Guarded.
printf("%s", $x);✅ Guarded.
var_dump($x);✅ Guarded.
fwrite(STDOUT, $x);NOT guarded — bypasses ob_start. The SDK's own BrokerSender::publish() uses this deliberately so blessed JSON frames always reach the host.

Plugin authors who need stdout output should use echo / print / printf — those are guarded. Calling fwrite(STDOUT, ...) directly from author code is undefined behavior; the operator's daemon will see the raw bytes and disconnect on parser failure.

CI publish workflow

The shipped workflow in extensions/template-plugin-php/.github/workflows/release.yml has the same 4-job shape as the Rust + Python + TS templates but:

  • Build matrix has a single noarch entry.
  • Build step uses shivammathur/setup-php@v2 with php-version: "8.3" + tools: composer:v2.
  • composer validate --strict gates the build.
  • composer install --no-dev --optimize-autoloader --classmap-authoritative produces the vendor tree.
  • Pack step calls scripts/pack-tarball-php.sh with SKIP_COMPOSER=1 (composer ran already).
  • Vendor audit step calls scripts/verify-pure-php.sh .audit/lib/vendor to enforce pure-PHP.

Sign + release jobs are identical to the other templates; cosign keyless OIDC ships .sig + .pem + .bundle per asset when the COSIGN_ENABLED repo variable is "true".

Operator install flow (no changes for PHP)

nexo plugin install your-handle/your-plugin@v0.2.0

Identical pipeline to the Rust + Python + TS install paths:

  1. Resolve release JSON.
  2. Try <id>-0.2.0-<host-triple>.tar.gz (miss for noarch plugins).
  3. Fall back to <id>-0.2.0-noarch.tar.gz (Phase 31.4 addition).
  4. Verify sha256.
  5. Cosign verify per trusted_keys.toml (Phase 31.3).
  6. Extract under <dest_root>/<id>-0.2.0/.
  7. Daemon picks it up at next boot or hot-reload; spawns bin/<id> which exec's php lib/plugin/main.php.

Local smoke test

echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
    | php src/main.php

Should print one JSON-RPC response with your manifest + server_version.

End-to-end test for the pack pipeline:

php tests/test_pack_tarball.php

SDK tests

In a clone of nexo-plugin-sdks:

cd php
composer install
php tests/run-all.php

14 test cases across handshake, manifest validation, dispatch (incl. Fiber-based slow-handler proof + drain), stdout-guard, wire-format hardening, lifecycle, event round-trip. All run via plain PHP scripts using proc_open — zero PHPUnit / Pest dep, mirroring the TS SDK's node:test choice and the Python SDK's unittest choice.

Plugin author constraint: cooperative scheduling

The Fiber scheduler preserves the "reader does not block on handler" invariant only at SDK boundaries. If your handler calls a synchronous blocking I/O function:

$result = file_get_contents("https://example.com/slow");  // blocks

…the dispatch loop blocks for the duration of the call. Cooperative scheduling cannot interrupt blocking I/O. Two mitigations:

  1. Keep handlers fast — typical channel plugins do work in <10ms.
  2. For long external calls, periodically Fiber::suspend() to yield. The SDK doesn't auto-suspend; that's an explicit author decision.

This matches the Python and TypeScript SDKs' contract — long blocking work is the author's responsibility to break up.

See also