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.”
Author-hosted services are a shim
Section titled “Author-hosted services are a shim”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).
Declarative seams vs. the jail
Section titled “Declarative seams vs. the jail”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.
The package
Section titled “The package”A sandboxed package is a directory:
my-plugin/ manifest.json # identity + capabilities + seam blocks main.ts # exports lifecycle handlers ui/ # optional static assetsA 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).
The capability vocabulary
Section titled “The capability vocabulary”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.)
Two capabilities worth understanding
Section titled “Two capabilities worth understanding”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.
Install with consent
Section titled “Install with consent”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:
POST /api/plugins/install— an admin (isAdminPrincipal; a non-admin gets403) uploads the package for aprojectPath. Lowkey validates the manifest + entry module and returns the declared capabilities for the consent step, plus a content hash for integrity.- The web UI presents the capabilities; the admin approves.
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.
How the sandbox enforces the grant
Section titled “How the sandbox enforces the grant”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.egressdomains →--allow-net=<domains>(undeclared domains are denied);fs.projectread →--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 version1) 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.
What the sandbox never sees
Section titled “What the sandbox never sees”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
tokenbroker injects auth out-of-band); - absolute session paths or the session JSONL format (
session.read/session.forkreturn outcomes, resolved host-side); - the daemon’s process, heap, or internal types;
- anything under the project’s
.lowkey/directory (explicitly denied even underfs.project).
Heavy or non-JS compute
Section titled “Heavy or non-JS compute”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.