Skip to content

Plugins and extensions

ptywright includes a trusted extension surface: declarative manifests, explicit host permissions, JSON-RPC helpers for manifest validation, and an embedded Lua runtime for built-in adapter/orchestration code.

The first embedded plugin is the Claude Code adapter. Rust still owns PTY IO, terminal parsing, screen mutation, actions, matchers, redaction, and RPC framing; Lua is only called for explicit adapter decisions.

Current model

Implemented:

  • PluginManifest
  • PluginKind
  • PluginRuntime
  • PluginPermission
  • PluginHostCapabilities
  • trusted embedded Lua execution for built-in plugins
  • built-in claude-code Lua adapter plugin surfaced via PluginHostCapabilities.builtin_plugins
  • manifest validation (PluginManifest::validate)
  • TOML manifest loader for trusted-local third-party plugins (PluginManifest::load_from_toml_path)
  • CLI: ptywright serve --plugin <manifest.toml> (repeatable) for boot-time registration
  • JSON-RPC: plugin.load / plugin.unload for runtime registration when ptywright serve is started with --allow-plugin-load
  • per-method permission gating at the JSON-RPC dispatcher (-32004 PermissionDenied with structured data)
  • JSON-RPC methods:
    • plugin.capabilities
    • plugin.validate_manifest
    • plugin.describe
    • plugin.load
    • plugin.unload

Not implemented yet:

  • A sandbox for untrusted third-party plugins.
  • WASM plugin execution. This has been re-evaluated and intentionally deferred until untrusted marketplace-style plugins become a concrete priority.
  • Filesystem, process, or network capabilities for plugin host APIs.
  • Per-method permission gating on session.* methods (session.* operates on directly-created sessions that have no associated plugin manifest; revisit if untrusted plugins become a priority).

Manifest example

json
{
  "name": "claude-code",
  "kind": "adapter",
  "version": "0.1.0",
  "runtime": "lua",
  "entrypoint": "plugins/claude-code/main.lua",
  "permissions": ["session.spawn", "screen.read", "input.write", "session.kill"]
}

Kinds:

  • adapter
  • macro
  • matcher

Runtimes:

  • lua
  • wasm is reserved for future work

Permissions:

  • session.spawn
  • session.kill
  • session.resize
  • screen.read
  • transcript.read
  • input.write
  • matcher.wait

Permission gating

Every adapter.* JSON-RPC method consults the bound plugin manifest's declared permissions before invoking the handler. The mapping is:

MethodRequired permission
adapter.startsession.spawn
adapter.resumesession.spawn
adapter.sendinput.write
adapter.waitmatcher.wait
adapter.turninput.write AND matcher.wait
adapter.cancel_waitnone (caller-driven cancellation)
adapter.snapshot / adapter.state / adapter.inspectscreen.read
adapter.transcripttranscript.read
adapter.closesession.kill
adapter.list / adapter.livenone (read-only)

A call against a manifest that lacks the required permission returns -32004 PermissionDenied with data = { "method": "...", "required_permission": "..." }. Plugin authors should declare the minimal set of permissions their plugin actually uses.

The plugin.load / plugin.unload JSON-RPC methods are server-mode gated rather than per-plugin gated: they are disabled unless ptywright serve was started with --allow-plugin-load. When disabled, both return -32004 PermissionDenied with data.reason = "server_did_not_grant_plugin_load" to distinguish from per-adapter denials.

Loading third-party plugins

ptywright supports two paths for registering trusted-local third-party plugins beyond the built-in claude-code adapter:

  1. At server startup, pass --plugin <path/to/manifest.toml> to ptywright serve (repeatable for multiple plugins). The flag works with any transport — --stdio, --socket, or the default per-user socket. Plugins registered this way are visible through adapter.list and instantiable via adapter.start.
  2. At runtime, start the server with --allow-plugin-load and call plugin.load { "manifest_path": "..." } from a connected client. Symmetric plugin.unload { "plugin": "<name>" } deregisters previously loaded plugins (built-ins cannot be unloaded; plugins with live adapters must be closed first).

The manifest's entrypoint is resolved relative to the manifest file's parent directory, so a manifest at /foo/plugins/echo/manifest.toml with entrypoint = "main.lua" loads /foo/plugins/echo/main.lua. Absolute paths and .. traversal components in entrypoint are rejected. After string-level validation the resolved entrypoint is canonicalized and asserted to stay inside the canonicalized manifest directory, so a Normal path component that turns out to be a symlink pointing outside the plugin's own directory is also rejected.

Trust model. Third-party plugins execute in the same trust domain as the built-in claude-code plugin — they run in the same embedded Lua runtime, with the same host helper API, gated by their declared permissions. Only load plugins from trusted local sources. This is not a sandbox for untrusted third-party code.

A worked example (the echo fixture from tests/fixtures/plugins/echo/):

toml
# manifest.toml
name = "echo"
kind = "adapter"
version = "0.1.0"
runtime = "lua"
entrypoint = "main.lua"
permissions = [
  "session.spawn",
  "session.kill",
  "screen.read",
  "transcript.read",
  "input.write",
  "matcher.wait",
]

[default_target]
program = "/bin/sh"
args = ["-lc", "cat"]

# Optional: keys the plugin needs in the spawned child's environment and
# that the caller MUST NOT be allowed to override. Merged last during
# `adapter.start`, after manifest defaults and caller env. Use this only
# for keys that would break the plugin if a caller set them differently.
[default_target.required_env]
# CLAUDE_CODE_DISABLE_TERMINAL_TITLE = "1"

Environment merge precedence

adapter.start layers the spawned child's environment in four tiers, last-write-wins:

  1. Inherited parent environmentptywright's own process environment passes through to the child by default. Set Target::clear_env() (or call sites that propagate it) to strip this tier so only the explicit overlays below reach the child.
  2. default_target.env — plugin-supplied defaults a caller may override.
  3. Caller env — the env map passed to adapter.start overrides matching keys from tier 1.
  4. default_target.required_env — plugin-mandated keys the caller cannot override; applied last so it always wins.

Keys present in only one tier pass through unchanged. The built-in claude-code manifest uses required_env to lock down CLAUDE_CODE_DISABLE_TERMINAL_TITLE = "1" and CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL = "1" — turning either off corrupts the classifier (OSC title escapes pollute the body parser, virtual scrolling moves cells out of the vt100 grid), so they must not be at caller mercy.

lua
-- main.lua
local M = {}

function M.classify(_ctx)
  return { state = "ready", confidence = 1.0, evidence = "echo plugin" }
end

function M.send_prompt(input)
  return {
    actions = {
      ptywright.action.text(input.prompt or ""),
      ptywright.action.key("enter"),
    },
    last_intent = "prompt_submitted",
  }
end

function M.wait_turn_matcher(_input)
  return ptywright.matcher.screen_stable(150)
end

return M

Load it and drive it:

bash
ptywright serve --stdio --plugin tests/fixtures/plugins/echo/manifest.toml
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "adapter.start",
  "params": { "plugin": "echo" }
}

JSON-RPC

Query host capabilities:

json
{ "jsonrpc": "2.0", "id": 1, "method": "plugin.capabilities" }

Validate a manifest:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "plugin.validate_manifest",
  "params": {
    "manifest": {
      "name": "demo",
      "kind": "adapter",
      "version": "0.1.0",
      "runtime": "lua",
      "entrypoint": "main.lua",
      "permissions": ["session.spawn", "screen.read"]
    }
  }
}

Validation checks:

  • name is non-empty;
  • version is non-empty;
  • executable runtime declarations include a non-empty entrypoint;
  • entrypoints without a runtime are rejected;
  • permissions are known;
  • permissions are not duplicated.

Unknown permissions fail during JSON deserialization and return Invalid params.

Runtime model

Embedded Lua is available for trusted built-in adapter/orchestration logic. It is not part of the PTY hot path:

  • no Lua callbacks run per PTY byte;
  • Lua receives explicit screen/transcript snapshots or action inputs;
  • Lua uses the injected ptywright.action.* and ptywright.matcher.* helper APIs to build control plans;
  • Lua returns generic Action, Matcher, and state values;
  • Rust installs host helper constructors according to manifest permissions;
  • Rust interrupts runaway Lua calls with instruction-count and wall-clock limits;
  • No blocking filesystem/process/network host callbacks are exposed to Lua;
  • Rust owns session IO and applies redaction at RPC read/error boundaries.

The built-in claude-code plugin lives at plugins/claude-code/main.lua and is embedded into the single binary with include_str!. Callers drive it through the generic adapter.* JSON-RPC surface and LuaExtension::built_in("claude-code") in Rust; there is no application-specific RPC namespace or typed Rust adapter shim.

Library callers can load an explicitly trusted local Lua plugin with LuaPlugin::load_trusted(root, manifest). The entrypoint must be a relative path inside the provided plugin root and is resolved after canonicalization to prevent symlink escapes. This is intended for trusted local adapters only; it is not an untrusted plugin sandbox.

WASM remains reserved for a future untrusted plugin model. Until then, prefer out-of-process JSON-RPC clients for untrusted or experimental automation code.

Released under the MIT License.