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).
Request conventions
Section titled “Request conventions”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.
Surfaces — mount points
Section titled “Surfaces — mount points”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) andside-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.
focusandside-panelare independent — a plugin can own the side-panel while a citation opens infocus. - Sandboxed. The iframe is
sandboxed (allow-scripts, opaque origin — noallow-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
srcas thelowkey_plugin_tokenquery 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 asAuthorization: Bearer lkp_…orX-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 glyph — launch.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.
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."}}'MCP registration
Section titled “MCP registration”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.
Webhook source
Section titled “Webhook source”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.
Scoped callback
Section titled “Scoped callback”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.
Turn-lifecycle event
Section titled “Turn-lifecycle event”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.
Subscribe — POST 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.
Agent contribution
Section titled “Agent contribution”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
runtimeto a built-in id. The agent runs on Lowkey’s adapter with yoursystemPrompt. UseplatformPromptMode: minimal+translator: nonefor a personality agent. No service required. - Tier B — external runtime. Set
runtime: external:<pluginId>and anendpoint. Lowkey proxies each turn to your service over the versioned protocol. Your service owns model, prompt, and voice end to end.
Fields:
platformPromptMode—full(default behavior; all platform instructions) orminimal(keeps the safety/operational floor, drops productivity instructions).noneis not available in v1.translator—default(Lowkey’s post-turn voice rewrite) ornone(raw agent output, no rewrite).noneis 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.
session-read
Section titled “session-read”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.