Skip to content

feat(plugins): server plugin API for events, threads, tools, scheduling and send hooks - #17

Draft
ashx-j wants to merge 7 commits into
mainfrom
feat/plugins-phase-3
Draft

ashx-j wants to merge 7 commits into
mainfrom
feat/plugins-phase-3

Conversation

@ashx-j

@ashx-j ashx-j commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Phase 3 of the plugin system roadmap (#3, phase issue #7). It builds on #16, so until #14 to #16 merge this diff also shows their commits.

After Phase 1 a server plugin could store data and answer calls, but it couldn't see or touch anything else in T3. This phase gives plugins most of what T3's own server features use, through the same Promise-based API:

  • events.stored({ types, afterSequence? }) follows stored orchestration events of the requested types, from now or from a sequence number. The server opens one type-filtered stream per requested type, so other events never reach the plugin.
  • threads.list / read / sendMessage / interrupt / rename and projects.list / read go through ThreadManagementService and the project store, the same paths as the app.
  • turns.beforeDispatch(hook) sees each user or agent message, except slash commands, before it is sent and can append context. It runs in ThreadManagementService.dispatch, never in the orchestrator, with a 2-second deadline. A hook that is slow or throws is skipped and the message goes through.
  • mcp.registerTool offers a tool to agents as plg_<pluginId>_<name>. Read tools work for read-only MCP sessions, and write tools are checked like built-in writes. After a plugin is disabled its tools answer "plugin disabled", because MCP can't remove a tool. A t3_plugins_list tool appears only once a plugin is installed, so users without plugins don't pay context for it.
  • http.route(method, path, handler) serves routes under /api/plugins/<pluginId>/ with the same sign-in as the rest of /api. GET needs read access, other methods need operate access. Plugins never see the caller's authorization or cookie headers, and request bodies are capped at 1 MiB.
  • schedule.every(ms, handler) runs scheduled work until the plugin is disabled.
  • server.version, server.environment() and server.settings() (redacted) expose server info.

Fixes from an adversarial review (a second Claude Opus 5.5 read the first version, commit 17a61b157b):

  • Send hooks now skip slash commands. Appending a paragraph turned /goal clear into setting a new goal, and stopped /compact from matching. The dispatch test covers it.
  • The SDK no longer promises a veto result, because nothing honoured it. Veto is planned for Phase 5.
  • Asking for several event types used to merge separate streams out of sequence order, which broke resuming. They now share one ordered stream. One type still gets the event sink's per-type filter.
  • Schedules and send hooks stop as soon as a plugin is disabled, before its cleanups run. API calls are refused once the cleanups finish, so a disabled plugin's leftover timers can't act.
  • Plugin write MCP tools now need the same access as environment writes: a full-access/default calling thread or client. Their handlers can't see the caller's mode limits, so a plan-mode agent could otherwise reach anything the plugin can do.
  • A tool's input schema must have type: "object", because model APIs reject anything else and that would fail every turn.
  • Known and not fixed: send hooks run before the orchestrator's per-thread lock, so 2 sends to one thread could swap order if a hook is slow on only one of them. A replayed command id also re-runs hooks, although the receipt still stops it from dispatching twice.

Changes from the roadmap (issue #7 is updated with the reasons):

  • Not built, because the services T3 has today can't do it without touching off-limits files or every provider:
    • notify. A notification item can't be recorded without starting an agent run, and adding a command for it means changing Orchestrator.ts.
    • text.generate and registerTitleGenerator. TextGeneration has no free-form method.
    • secrets.request. Asking the user for a secret is a thread-scoped MCP flow.
    • threads.create, threads.setMetadata, worktrees.list, schedule.at and events.pluginState.
  • secrets.get isn't needed, because settings.get() already returns the plugin's own secret values.
  • The send hook can append text but can't veto. A veto needs a new error on dispatch, which every caller handles.
  • schedule.every runs as a fiber per plugin call instead of using Scheduler.

Measurements:

  • Send path with no hooks: one Ref read, about 0.27 µs per message. A micro-benchmark measured 356 ns against an 89 ns Effect.succeed baseline. Dispatching a message involves a database transaction, so this is noise.
  • Event fan-out: a plugin subscribed to one type gets one server-side stream filtered to that type. In the live run below, it received exactly the thread.metadata-updated event that its rename produced.
  • No plugins installed: no hooks, routes or MCP tools exist, and the tool list agents see is unchanged. AgentContextBudget.test.ts passes with no change to agentContextBudget.json.

Test plugins:

  • workbench-plugin uses threads, projects, events, schedule, the send hook and server info.
  • mcp-plugin has a read tool echo and a write tool touch.
  • http-plugin serves GET /hello and POST /echo.

Verification:

  • PluginHost.test.ts (11 cases, including one that loads workbench-plugin), PluginMcpTools.test.ts, PluginHttpRoutes.test.ts, api/turns.test.ts (a throwing hook and a hook that never resolves are both skipped) and ThreadManagementService.test.ts pass. The new ThreadManagementService.test.ts case shows a hook appending to a user message but not to a notification.
  • McpHttpServer.test.ts, McpToolAccess.test.ts and AgentContextBudget.test.ts pass, and vp run check:changed passed.
  • In a live run on a real server against this worktree's seeded data:
    • GET /api/plugins/http-plugin/hello returned 200 hello world with a token and 401 without one.
    • The workbench plugin reported the real environment label, 22 threads and 6 projects.
    • Renaming a thread through the plugin delivered the thread.metadata-updated event, at sequence 95170, to the plugin's own event stream.
  • Not verified: whether Claude, Codex and ACP agents refetch the tool list mid-session when a plugin adds a tool. That needs a paid agent turn. I also didn't run the mcp-tool-use eval for the t3_plugins_list description, for the same reason.

The MCP tools and HTTP routes were written by a Claude Opus 5.5 subagent in parallel with the rest.


PR filed by Claude Opus 5.5 in Claude Code through T3 Code.

@ashx-j ashx-j mentioned this pull request Oct 30, 2026
9 tasks done
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Oct 30, 2026
ashx-j and others added 7 commits October 31, 2026 21:22
…bility flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginHost discovers installed and dev plugins, loads enabled CommonJS
entries after the server is listening, and hands each a Promise-based API
for logging, settings, storage, RPC methods and channels. Failures mark the
plugin failed and never stop the server. Settings live beside T3's state
with secrets in the server secret store; storage is a lazily opened SQLite
file. Eight plugins.* WebSocket methods manage plugins, and the environment
advertises plugins: true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adversarial review found that reloading one plugin left plugins whose
folders vanished running, that a caller disconnecting mid-disable left a
plugin half torn down but still routed, that a misspelled access level
made a write method callable by read-only sessions, and that reload kept
old code for symlinked plugin folders. Lifecycle changes now run in the
host's scope, vanished plugins are stopped, access must be read or write,
and discovery resolves real paths. Activation and cleanups get deadlines,
calls stop routing before teardown, and plugins.json keeps unknown keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings gains a Plugins page for the selected environment: each plugin
with its status, failure, enable switch and reload, plus a settings form
generated from the manifest's JSON Schema with write-only secret fields
and a JSON fallback. The page follows plugins.subscribeStates only while
open; the form model lives in client-runtime for the later mobile screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng and send hooks

Plugins can now follow stored orchestration events by type, read and act
on threads and projects, run scheduled work, see and extend outgoing
messages before they are sent, offer MCP tools to agents and serve HTTP
routes under /api/plugins/<id>, and read the server's version, descriptor
and redacted settings. The send hook runs in ThreadManagementService.dispatch
with a 2 s deadline and fails open; with no hooks it is one Ref read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… writes

An adversarial review found that a send hook appending to /goal clear
turned it into setting a goal, that the SDK promised a veto nothing
honoured, that several event types merged out of sequence order, that a
schedule could outlive its plugin, and that plugin write tools skipped the
caller's mode limits. Hooks now skip slash commands, veto is gone from the
SDK, multi-type event streams stay ordered, schedules and hooks stop on
the deactivation signal, API calls end after cleanups, plugin write tools
need full access like environment writes, and tool schemas must be objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ashx-j
ashx-j force-pushed the feat/plugins-phase-3 branch from 17a61b1 to c2d9362 Compare October 31, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant