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.

  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"] }]

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?, execute(args, ctx) }.

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.

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 every execution takes a <tool>:<callId> effect claim, exactly like a first-party tool.

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 claimed and audited. A contract provider maps its methods onto these same tool names, so a resolved cross-plugin call inherits the identical guarantees.