Skip to content

Sandboxed packages (the plugin model)

This is what a plugin is, and the only self-serve way one reaches a Lowkey instance. A sandboxed package ships its code to the Lowkey host, where the daemon runs it inside a Deno sandbox — a locked-down child process (a “jail”), never the daemon’s own process. The package reaches the host only through a closed capability vocabulary the installer explicitly consents to; anything not granted is denied by the sandbox, not by the plugin’s good behavior.

The web installer requires a main.ts/main.js entry module. A manifest with no entry cannot be installed through the web — it can only be operator-sideloaded (see Declarative seams vs. the jail).

Why the jail. A plugin that only reaches over the network is limited to an iframe, a webhook, an external runtime. The plugins worth building need the project’s files, a brokered credential, or the live turn lifecycle (a session mapper, a storage sync) — they are host-intimate. The jail is how third-party code runs on the host safely: narrow, consented reach, enforced by Deno.

The seal: no third-party code runs inside the daemon process. The jail is an out-of-process Deno isolate — “host-run” means “on the host machine,” not “in Lowkey’s process.”

Lowkey never hosts your app, model, or heavy compute. When your plugin is backed by a service you run elsewhere, the installed package is a thin main.ts shim: it declares net.egress to your domain and calls out to your service, and — if it has a UI — declares a surface to render it in a mount point. This is what “author-hosted” means here: not a different kind of plugin, just a package whose main.ts is mostly an egress call. It is also what keeps the host light (a plugin needing a container, a GPU, or a non-JS stack hosts that itself and reaches it via net.egress).

The manifest has two kinds of extension points, and a package can use both:

Host-run capabilities (the jail) Declarative seams
Declared by capabilities + a main.ts entry scopes + surface/mcp/webhook/agent blocks
What runs Your TS, in a Deno isolate, per grant No plugin logic hosted — Lowkey wires the manifest
Examples net.egress, fs.project, session.fork, cards.emit a surface iframe, an mcp server, a webhook trigger, an external: agent
Self-serve install Yes (web installer, admin consent) Only if the package also ships a main.ts
Manifest-only (no main.ts) n/a Operator sideload only (host filesystem access)

The canonical package (examples/plugins/deno-reference/) does both: capabilities for host-run handlers and a surface for its UI. A manifest that uses only declarative seams is still valid and still runs (the seams are wired for sideloaded plugins) — but a third party with no host access cannot deliver one, because the web installer requires the entry module. The mcp seam in particular spawns your command on the host unsandboxed, so a manifest-only MCP plugin is an operator decision. The declarative seams themselves are documented in Seams and External turn protocol.

A sandboxed package is a directory:

my-plugin/
manifest.json # identity + capabilities + seam blocks
main.ts # exports lifecycle handlers
ui/ # optional static assets

A copy-and-edit starting point ships in the repo: examples/plugins/deno-reference/ (a manifest.json declaring net.egress + fs.project read + events.turn + mount.* + cards.emit, and a main.ts exercising them).

main.ts exports any of the lifecycle handlers the host invokes:

Handler Called Notes
onInstall Once, at install Setup.
onTurnStart Before an agent turn Fire-and-forget, non-blocking — it can never delay or break the turn. Requires events.turn with turn.started.
onTurnComplete After an agent turn Requires events.turn with turn.completed.
render When a mount point needs content Returns {type:'html'} | {type:'url'} | {type:'empty'}. Requires a mount.* capability.
import { emitCard, readSession, forkSessionTurn } from '@lowkey/plugin-sdk';
export async function onTurnComplete(ctx) {
const session = await readSession({ sessionId: ctx.event.sessionId });
await emitCard({
sessionId: ctx.event.sessionId,
card: { type: 'status', level: 'info', text: `Turn ${ctx.event.turnId} mapped.` },
});
}

The handler ctx carries pluginId, installId, projectId, the granted capabilities, and the event payload — never a provider credential, a resume id, or an absolute session path (those stay sealed; see What the sandbox never sees).

manifest.capabilities is a closed, enumerated array. Each entry maps to a Deno permission the harness passes and/or a host SDK call the plugin imports. All are in the published manifest schema.

Capability Declares Enforced by SDK call Status
net.egress { domains: [...] } Deno --allow-net=<domains> (plain fetch) Live
fs.project { mode: "read" } Deno --allow-read=<projectDir>, .lowkey denied (plain Deno.readTextFile) Live
fs.project:write { mode: "write" } Deno --allow-write=<projectDir>, .lowkey denied writeProjectFile() Live (v2.1)
events.turn { events: ["turn.started","turn.completed"] } Lifecycle dispatch onTurnStart / onTurnComplete Live
mount.focus / mount.side-panel {} Render request render() Live
cards.emit {} Host RPC emitCard() Live
session.read {} Host RPC readSession() Live
session.fork {} Host RPC forkSessionTurn() Live
token { dest, ops: [...] } Broker injects auth at the network boundary useTokenGrant() Live (v2.1)
run.agent {} runAgent() Deferred — see below

Deferred capabilities. run.agent is declarable and consentable now, but its host primitive is not built. Calling it never throws and never escapes the sandbox — it resolves with a legible sentinel: { deferred: true, reason: "not_implemented", … }. Branch on result.deferred, not on a caught error. When it lands, the same SDK method returns its real result type. (token and fs.project:write were deferred in v2 and went live in v2.1; run.agent is the only one still inert.)

session.fork — the observer keystone. A session.fork-granted package asks the host to run a forked observer turn off a session it was notified about, passing references (fromSessionId, fromTurnId), an agentId, and a prompt, and gets the result back — without ever seeing the provider resume id, the credential, or the session path. The host resolves those sealed internals, runs the fork, and returns only the outcome. This is what lets a package map or annotate a live session.

token — a brokered credential you never hold. A token grant does not hand your code a secret. It authorizes an authenticated net.egress to a granted dest: when your fetch hits that destination, Lowkey’s network broker injects auth from the vault at the network boundary. useTokenGrant({ dest, op }) returns the base URL + headers to use; the raw token never enters the isolate. A grant is effectively an authenticated egress, not a fetched password.

Sandboxed packages are not enabled by dropping a directory — they are installed through an admin-gated flow that surfaces the declared capabilities for explicit approval:

  1. POST /api/plugins/install — an admin (isAdminPrincipal; a non-admin gets 403) uploads the package for a projectPath. Lowkey validates the manifest + entry module and returns the declared capabilities for the consent step, plus a content hash for integrity.
  2. The web UI presents the capabilities; the admin approves.
  3. POST /api/plugins/install/confirm — records the grant + content hash and stores the package under <project>/.lowkey/plugins/<id>/.

The package is then enabled per-project like any plugin (see discovery & enablement). Integrity is the content hash; the trust signal is “an admin consented.” There is no signing/provenance and no marketplace — transport is a direct upload.

The daemon spawns a Deno isolate per invocation (or a pooled worker) with --no-prompt and only the permission flags the granted capabilities map to:

  • net.egress domains → --allow-net=<domains> (undeclared domains are denied);
  • fs.project read → --allow-read=<projectDir> with --deny-read=<projectDir>/.lowkey;
  • fs.project:write--allow-write=<projectDir> with --deny-write=<projectDir>/.lowkey;
  • everything else → a host RPC (__lowkeyPluginRpc, protocol version 1) that the daemon answers only after re-checking the install’s grant.

An undeclared network domain or an out-of-project (or .lowkey) filesystem path is denied by Deno, not by plugin cooperation. This is the property that makes host-run third-party code safe.

Consistent with the governing rule (expose the outcome, seal the mechanism), the isolate names references and requests outcomes — it never receives:

  • provider credentials or resume ids (the token broker injects auth out-of-band);
  • absolute session paths or the session JSONL format (session.read / session.fork return outcomes, resolved host-side);
  • the daemon’s process, heap, or internal types;
  • anything under the project’s .lowkey/ directory (explicitly denied even under fs.project).

The sandbox runs Deno (JS/TS) only. If a package needs a container, a GPU, or a Python stack, the author hosts that themselves and the installed package is a thin TS shim that reaches it via net.egress — the same shim pattern all author-hosted services use. The host never runs heavy or arbitrary runtimes; the capability contract is the durable artifact and the engine (Deno today) is a swappable implementation detail.