Skip to content

Plugin tools

A plugin contributes tools an agent can call in two lanes. Most reach the world over HTTP and run in a QuickJS sandbox with zero ambient authority: these are function tools, an extension of Subspace’s custom functions. A small minority need a protocol that cannot ride a fetch, like MTProto or ZeroMQ, and run as full-trust native tool modules. Both join the same tool registry as first-party tools, so gates, claims, approvals, and audit apply identically. The default answer is always the sandbox; the native lane is the deliberate escape hatch.

A function tool is user or plugin TypeScript that runs in a fresh QuickJS-WASM sandbox and joins the tool registry as a callable fn.<name>. The source is an ordinary code/ page (see custom functions); the manifest tools: [{ name, page }] binds a tool name to that page. Because the sandbox starts with zero ambient authority, no filesystem, no process, no network, everything a tool can do is exposed explicitly through the host API, and the plugin’s capabilities.host allowlist decides which host methods it may call.

Capabilities are pinned into a source-pinned snapshot at install time. The snapshot records capabilities.plugin provenance alongside the host allowlist, so a tool inherits exactly its plugin’s declared reach, and a bare code/ function you write by hand keeps the default minimal grant no matter what any manifest says.

The one way a sandboxed tool reaches the network is http.fetch, and it is deliberately narrow:

  1. Domain allowlist, https only

    A request must target a domain named in capabilities.fetch over https. Anything else is refused before a socket opens.

  2. Credentials brokered host-side

    capabilities.credentials: [{ name, into: <header template> }] names a secret and the header it lands in. The host resolves the named secret through the capability broker (grant-checked and audited) and injects it into the outgoing request host-side. The token never enters QuickJS, so sandboxed code cannot read, log, or exfiltrate it.

    A credential narrowed with domains may also opt into user-authored endpoint hosts with bindingDomains: [<metadata dot path>]. The host harvests those HTTPS names from readable binding pages at snapshot time. This extends only credential injection; the endpoint must still be independently granted by capabilities.fetchBindings.

  3. Bounded and closed

    Every response is size- and time-capped (fetchMaxBytes / fetchTimeoutMs, per capability, with safe defaults), and redirects stay closed so a 302 cannot walk a request off the allowlist.

// code/lookup-book: a function tool bound by tools: [{ name: "lookupBook", page: "code/lookup-book" }]
export default async function lookupBook(isbn: string) {
// https + api.openlibrary.org must be in capabilities.fetch;
// the Authorization header is filled from the brokered "openlibrary_key" secret host-side.
const res = await http.fetch(`https://api.openlibrary.org/isbn/${isbn}.json`);
const book = await res.json();
return { type: 'stat', value: book.title, label: 'Title' };
}

The header template is where the credential lands: a spec like { name: "openlibrary_key", into: { header: "Authorization", format: "Bearer {{secret}}" } } tells the broker to set Authorization: Bearer <resolved-secret> on the request. The plugin’s code only ever sees a URL; the secret is assembled outside the sandbox.

Some providers cannot be brokered over a fetch: a Telegram client speaks MTProto, a market feed speaks ZeroMQ, a service needs a raw websocket held open. nativeTools is the full-trust lane for exactly these, and nothing else.

"nativeTools": [{
"module": "native/telegram.js",
"names": ["tg.sendMessage", "tg.readChat"],
"dependencies": { "telegram": "^2.26.22" }
}]

module names a compiled ESM module inside the immutable version directory whose tools (or default) export maps each tool name to { description, inputJsonSchema?, requires?, needsApproval?, readOnly?, execute(args, ctx) }.

dependencies declares bare npm imports used by that module. Install resolves each package against the host runtime, verifies the requested semver range, and links only those declared package roots into the immutable plugin directory. The installer never fetches undeclared native code: the app lockfile/image remains the integrity and upgrade authority. An unavailable or out-of-range package rejects install before the version directory is copied.

Install: full-trust review, SDK-pinned

Native modules ride the same sdk: 1 gate as workflow bundles, and the referenced module must exist inside the plugin directory. The install review card renders every declared name as native tool <name> (<module>) in the full-trust lane, under the version directory’s source-tree hash, so you approve specific bytes.

Runtime: only declared names register

On boot and on every plugins event, the registry syncs on both planes. Only the declared names register; undeclared exports in the module are ignored. Native tools compose under built-ins, so a plugin can never shadow a first-party tool name. Modules may export activate(host) and dispose(host). The host object supplies the live, process-local registration seams for task resolvers, write guards, and narrow standing approvals. activate runs once when the module becomes enabled; disable or upgrade awaits dispose before evicting it, which is also where long-lived sockets such as MTProto clients should close. Register and unregister through the supplied host so development and compiled runtimes share the same authoritative registries.

Gates, claims, and reach

Native tools ride the ordinary seams. A definition’s tools: allowlist decides which ones an agent may call; needsApproval defaults to true (a plugin may lift only its own gate, never claim alwaysGate authority); a requires: list of broker capabilities gates the module’s reach; and mutating execution takes a <tool>:<runId>:<callId> effect claim, exactly like a first-party tool. A genuinely side-effect-free live read may declare readOnly: true; the host then executes it on every call instead of replaying a memo that could be stale. The full-trust review is responsible for verifying that declaration.

Whichever lane a tool comes from, it is indistinguishable to an agent at the point of use: it appears in the tool registry, an agent definition opts in with its tools: allowlist, calls flow through the same approval and gating machinery, and every invocation is audited; mutating calls are claimed. A contract provider maps its methods onto these same tool names, so a resolved cross-plugin call inherits the identical guarantees.