Skip to content

Per-seam reference

The seams below are the declarative extension points: the manifest wires an integration and Lowkey performs it, without running your logic in the jail. They are distinct from host-run capabilities (your main.ts in the Deno sandbox). A sandboxed package may declare these alongside its capabilities; a manifest that uses only these, with no main.ts, is valid but operator-sideload only (the web installer requires an entry module).

Each seam is a generic, host-owned extension point. The manifest wires your service into one; the service then talks to Lowkey’s HTTP API (or is talked to).

Every scoped plugin HTTP route shares the same envelope:

  • Auth — send the per-installation scoped token as either header:
    • Authorization: Bearer lkp_…
    • X-Lowkey-Plugin-Token: lkp_…
  • Project — pass ?projectPath=<absolute project path> as a query parameter. It must match the installation’s project, or the request is denied (project_mismatch, 403).
  • Scope — the route’s required scope must be in the manifest’s scopes, or the request is denied (undeclared_scope, 403).

Denials are JSON: { "error": "unauthorized" | "forbidden", "reason": "<reason>" }. See the deny reasons table.


Scope: focus or side-panel. Manifest: surface.

A surface is your served URL rendered inside a host-owned named mount point. The host draws the surrounding chrome; your iframe fills the rectangle. This is the focus-pane contract generalized from one hard-coded location to several addressable ones.

  • Slots (v1): focus (the main pane) and side-panel (a right-side pop-in). The set is host-owned and closed; new slots are added by Lowkey under demand, never declared open-endedly.
  • Two slots live at once. focus and side-panel are independent — a plugin can own the side-panel while a citation opens in focus.
  • Sandboxed. The iframe is sandboxed (allow-scripts, opaque origin — no allow-same-origin), served from your URL through the host’s preview proxy.
  • Token delivery. At launch the host appends the installation’s scoped token to the iframe src as the lowkey_plugin_token query parameter (not a header — the sandboxed iframe cannot set request headers on its own document load). Your surface reads it from its own URL, then authenticates each call back to Lowkey by sending it as Authorization: Bearer lkp_… or X-Lowkey-Plugin-Token. The token is present only for an enabled installation; a token-less mount is supported (first-party surfaces, or before the token mints).
  • Sealed: arbitrary chrome injection (menus, settings pages, header items). You get the offered slot, not Lowkey’s layout.
"scopes": ["side-panel"],
"surface": { "slot": "side-panel", "url": "https://plugin.example.com/panel" },
"launch": { "icon": "map", "label": "Nav Pane" }

Panel plugins (those with a surface) get a header launch icon; headless plugins do not. In v1 the icon is a generic glyphlaunch.icon is required when launch is present but its value is not yet mapped to a per-plugin icon (future); launch.label is used as the tooltip/accessible label.


Scope: cards. Route: POST /api/plugins/cards.

Emit a card into the session feed. Cards are a closed, enumerated schema — never open HTML. Payloads containing HTML (an html/innerHTML/… key, or any string that looks like markup) are rejected. Use a surface for rich UI; cards are for results that belong in the shared multi-user feed.

Request body:

{
"sessionId": "<session uuid>", // required; the session must exist
"card": { "type": "text", "title": "", "text": "" }
}

Card types:

type Fields
text text (required), title?
link url (required, http/https), label?, title?
status level (info|success|warning|error), text (required), title?
prompt prompt (required), title?

Response: 201{ "id": "<cardId>", "record": { … } }. The card is appended to the session as a plugin_card system record and revives an archived/completed session to active.

Terminal window
curl -X POST "$BASE/api/plugins/cards?projectPath=$PROJECT" \
-H "Authorization: Bearer $LOWKEY_PLUGIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sessionId":"'"$SESSION"'","card":{"type":"status","level":"success","text":"Render complete."}}'

Scope: mcp. Manifest: mcp.

A declared mcp block becomes an injectable stdio MCP server config, merged into the per-launch MCP-config path at agent launch — the same path as built-in servers, keyed by plugin id. There is no bespoke per-plugin code: manifest → config.

"scopes": ["mcp"],
"mcp": {
"command": "node",
"args": ["service.mjs", "mcp"],
"env": { "LOWKEY_PLUGIN_ID": "my-plugin" }
}

Scoping is enforced twice: only enabled plugins in the launch’s project contribute a server, and only a plugin declaring the mcp scope. The MCP protocol itself is borrowed and externally maintained — Lowkey never versions it.


Scope: webhook. Manifest: webhook. Route: POST /api/webhook/<source>.

Registers an inbound trigger. A POST to /api/webhook/<source> — where <source> equals your webhook.source — that carries your scoped token dispatches an agent turn. The plugin path is selected when the request bears a plugin token (lkp_…); operator-configured webhooks (basic-auth) continue to use the same path unchanged.

The request’s <source> must equal the token-holder’s manifest.webhook.source, or it is rejected (webhook_source_mismatch, 403).

Request body (all optional):

{
"agentId": "lucy", // defaults to the project's default agent; must exist
"target": "new_session", // or an existing session target
"callbackUrl": "https://plugin.example.com/callback", // optional scoped callback
"callbackToken": "" // optional bearer sent on the callback
// …any other fields are forwarded into the dispatched turn's content
}

Response: 200{ "sessionId": "…", "status": "started" | "appended" }, plus "callback": "delivered" when a callbackUrl was supplied. Callback/secret fields are redacted from the content shown to the agent.


A per-request reply channel. When a webhook request includes callbackUrl, Lowkey POSTs the turn’s output back to it once available:

{
"v": 1,
"type": "turn.completed",
"pluginId": "my-plugin",
"projectPath": "/abs/project",
"source": "my-plugin",
"sessionId": "",
"output": "…the turn's final message…"
}

If callbackToken was supplied, Lowkey sends it as Authorization: Bearer <token> so your receiver can authenticate the call. Lowkey only ever calls the URL you provide on the request — it holds no standing outbound credential for your service.


Scope: webhook. Routes: POST / DELETE /api/plugins/events/turn-completed/subscriptions.

A standing subscription (one per plugin + project) that fires on every turn completion in the project — the seam an introspection plugin uses to recompute a standing layer.

SubscribePOST body:

{ "callbackUrl": "https://plugin.example.com/turn-events", "callbackToken": "" }

Delivery — Lowkey POSTs to callbackUrl:

{
"pluginId": "my-plugin",
"event": {
"v": 1,
"type": "turn.completed",
"projectPath": "/abs/project",
"sessionId": "",
"turnId": "",
"agentId": "lucy",
"status": "complete" | "error" | "cancelled",
"completedAt": "<ISO 8601>",
"output": "…the turn's final message…"
}
}

callbackToken, if set, is sent as Authorization: Bearer. Unsubscribe with DELETE (same scope, same project) → { "removed": true|false }. Subscriptions are in-memory and do not survive a daemon restart — re-subscribe on startup.


Scope: agent. Manifest: agent.

Contributes a real roster agent. When the plugin is enabled for a project, loadAgents folds its agent block into that project’s AgentsConfig as a genuine AgentDef — so the contributed agent is addressable and picker-visible, takes turns, and appears in the roster exactly like a built-in one (it is not merely validated and discarded). The contribution applies only while enabled and only for the project; a built-in/on-disk agent of the same id wins (the fold skips duplicate ids). The plugin supplies the prompt and voice; Lowkey supplies sessions, addressing, and cards. Two delivery tiers:

  • Tier A — on a built-in runtime. Set runtime to a built-in id. The agent runs on Lowkey’s adapter with your systemPrompt. Use platformPromptMode: minimal + translator: none for a personality agent. No service required.
  • Tier B — external runtime. Set runtime: external:<pluginId> and an endpoint. Lowkey proxies each turn to your service over the versioned protocol. Your service owns model, prompt, and voice end to end.

Fields:

  • platformPromptModefull (default behavior; all platform instructions) or minimal (keeps the safety/operational floor, drops productivity instructions). none is not available in v1.
  • translatordefault (Lowkey’s post-turn voice rewrite) or none (raw agent output, no rewrite). none is the only override v1 exposes; bring-your-own translator is deferred.

Lowkey switches behavior on runtime, never on persona — the identity guard holds. Contributing an agent is a distinct, consent-worthy trust capability: the agent runs on the operator’s provider credential and can lower the safety floor. See trust.


Scope: session-read. Route: GET /api/plugins/session-read?projectPath=…&sessionId=….

A bounded read of a single session’s transcript + lineage. The session’s worktree is read by ordinary agent file I/O (a spawned lowkey run --agent job scoped to the session dir), not this route — only the transcript and lineage are Lowkey-specific. sessionId must be a valid UUID.

Response: 200

{
"sessionId": "",
"projectPath": "/abs/project",
"transcript": [ /* the session's records */ ],
"lineage": {
"parentSessionId": "" | null,
"childSessionIds": [""]
}
}

This scope is consentable, not first-party-only — a user may knowingly grant a third party “read my session’s transcript + this project.” It does not expose platform internals.