Plugins
A Loop plugin is an OCI image that Loop installs by digest and runs in its own container, one per channel. The plugin serves HTTP for Loop, calls Loop’s Plugin API with a channel-scoped token, asks Loop for agent runs that call its tools, and can draw panels in the workspace from a static web bundle.
Plugins are off unless plugins.enabled is set in the global config. While
gates.agentgate.enabled is on, plugin containers run under agentgate (see Agentgate
).
Enabling a plugin
An installed plugin runs only in projects that enable it:
// <project>/.loop/config.json
{ "plugins": { "cogtest": { "enabled": true } } }plugins.<id>.enabled in the global config sets the default for every project; a project’s own
value overrides it. Like the rest of a project config, a project’s value applies once the owner
trusts that config; until then the last approved version’s values do. Settings → Plugins sets
both, keeping the file’s comments.
A project is a channel, and its threads and worktree threads are part of it: they follow the
channel’s project config. Each channel, thread and worktree thread runs a container of its own,
with its own data, and the plugin’s agent runs go in a hidden thread of each. In a
project that doesn’t enable the plugin, its panels are left out of the add-pane menus, a pane
already open says so, and every call to the plugin answers 409. A container already running
there is stopped as soon as Loop writes the config or the owner trusts it, and within a minute of
an edit made outside Loop.
Settings
A plugin declares its settings in its manifest, and they’re set under plugins.<id>.settings:
// <project>/.loop/config.json
{ "plugins": { "cogtest": { "enabled": true, "settings": { "question_count": 8 } } } }A setting takes its manifest default, then the global config’s value, then the project’s,
setting by setting. Unlike enabled, a project’s settings apply without the owner trusting its
config: they only tune the plugin. A value for a setting the manifest doesn’t declare, or one its
schema refuses, is left out, so the value below it applies, and Settings → Plugins says so. Settings → Plugins edits both: the
global values on Global → Plugins, the project’s on Project → Plugins; an emptied field unsets
its value. The plugin reads its settings with GET /channel, and a panel with the bridge’s
settings.get.
Managing plugins
Settings → Plugins installs (after showing the capabilities the image asks for), upgrades and
removes plugins, and lists each one’s containers with a Stop button. loop plugin does the same
from the shell. The API behind both:
| Route | |
|---|---|
GET /api/plugins?channel_id= | the installed plugins with their actions, enabled_by_default, settings_schema, global_settings and containers (channel_id, channel_name, status, last_error, updated_at); with channel_id, also enabled and project_enabled (the project file’s own value), project_settings, settings and settings_problems for that channel’s project, and a top-level project_trusted |
POST /api/plugins/inspect | body {"image", "dev"}: pulls and checks an image, installing nothing |
POST /api/plugins | body {"image", "dev", "digest", "approve": […]}: installs or upgrades |
DELETE /api/plugins/{id}?purge= | removes a plugin; purge also deletes its data and its agent runs’ hidden threads in every channel |
POST /api/plugins/{id}/channels/{channel}/stop | stops the plugin’s container in a channel, keeping its data |
Installing, removing and stopping send a plugins.changed event to every client, and so do every
config write Loop makes, trusting a project config, and the idle or disabled containers the
daemon stops on its own; open panes and Settings → Plugins list the plugins again on it.
Image layout
/loop-plugin/plugin.json manifest (required)
/loop-plugin/ui/<panel>/... static UI bundle per panel (index.html plus assets)
/data/ must exist, owned by the image's USERThe image carries the label dev.loop.plugin=1, runs as a non-root USER (a numeric
uid:gid works) and serves HTTP on $LOOP_PLUGIN_PORT. /loop-plugin may hold at most 50 MB;
install refuses a bigger one. See
test/component/testdata/plugins/echo for the smallest working plugin.
Manifest
plugin.json is JSON with comments and trailing commas allowed.
{
"id": "echo", // [a-z][a-z0-9-]{1,31}
"name": "Echo",
"version": "0.1.0",
"api": "1",
"capabilities": ["agent.readonly", "files.read", "ui.panel"],
"agents": [{ "id": "say", "read_only": true, "tools": ["echo"] }],
"tools": [{ "name": "echo", "description": "Returns its input", "input_schema": { "type": "object" } }],
"actions": [
{ "id": "ping" },
// needs "schedule" in capabilities
{ "id": "daily", "default_schedule": { "type": "cron", "schedule": "0 9 * * *" } },
],
"settings": { "count": { "type": "integer", "title": "Count", "minimum": 1, "maximum": 10, "default": 3 } },
"ui": {
"panels": [{ "id": "main", "label": "Echo", "entry": "ui/main/index.html", "singleton": true }],
// needs "ui.notification.card" in capabilities
"cards": [{ "id": "score", "entry": "ui/score.html" }],
// needs "ui.composer_switch" in capabilities
"composer_switches": [{ "id": "mode", "label": "Mode", "icon": "ui/mode.svg", "title": "Mode is off" }],
},
// needs "live.control" in capabilities
"live": { "control": ["some_request"], "messages": ["some_state"] },
"resources": { "memory": "128m", "cpus": 0.5 },
}Each setting is a schema: type (string, integer, number, boolean, array or
object), with title, description, default, enum, minimum, maximum, pattern,
items, properties and additionalProperties. Defaults are checked at install.
Events
"events": ["message.created", "agent.status", "quality.scanned"] subscribes the plugin to those
Loop events, and needs events.subscribe. Loop sends each one to POST /events on the
container of the channel it happened in, starting the container if it isn’t running, when the
channel’s project enables the plugin. An event in one of the channel’s threads or worktree
threads carries the thread’s id and goes to the thread’s own container; "thread_events": "channel" sends it to the channel’s container instead, for a plugin that keeps one view of the
whole project, and "both" to both. Events in hidden threads, such as the plugin’s own agent
runs, aren’t sent. Delivery is best effort: each event is sent once, in no set order, and
dropped when the plugin doesn’t take it.
A plugin that only acts on events while the owner turned it on can keep its container from
starting otherwise with events_start: an event then starts the container only while one of the
boolean settings it names is on in the chat’s project, or one of the composer switches it names
is on in the chat the event is in. A container already running gets every event. With
"events_start": {}, events never start it.
"events": ["live.status"],
"events_start": { "settings": ["sync_all"], "switches": ["sync"] },live.status follows the chat’s live session
, with the data of the app’s
live.status
event. live.system needs live.control too: it brings the
live session’s system messages whose subtype the manifest lists in live.messages, as
{"subtype", "message"} with message the whole message as Claude wrote it. The app never sees
them.
settings.changed brings {"settings"}, the plugin’s settings in the channel’s project as
GET /channel has them, after a config write or a project trust change. It goes only to
containers already running, without a thread id, and the settings may be unchanged: a plugin
compares them with the ones it acted on.
Scheduled actions
A project asks for the scheduled task of an action in its config:
// <project>/.loop/config.json
{ "plugins": { "cogtest": { "enabled": true, "schedules": { "daily": { "enabled": true } } } } }Each channel of a project that enables the plugin then gets the task. type (cron or
interval) and schedule default to the action’s default_schedule, which an action without
one must set; enabled defaults to true, and false keeps the task but turned off.
Removing the entry deletes the task. An entry in the global config asks for the task in every
project; a project’s own entry replaces it, action by action. Like enabled, a project’s
schedules apply once the owner trusts its config. Settings → Plugins edits the entries: under
Global for every project, under Project for one, each turned on or off, with its own type and
schedule, or removed. The Tasks panel of a project’s channel makes one too: New task → Plugin
picks an action of a plugin the project enables and writes the project’s entry.
When a task runs, Loop calls POST /actions/{action} on the channel’s container with
"trigger": "schedule" and the task’s id, starting the container if it isn’t running. The tasks
show in the Tasks panel like any other. There, editing one’s schedule or turning it on or off
writes the project’s entry, and deleting one the project’s own entry made removes that entry.
Loop applies an entry once per change to it, so a task deleted otherwise, one the global entry
makes, comes back only when the entry changes. The entry holds only the schedule; how the task
runs is the task’s own, set when making or editing it in the Tasks panel. With Worktree on, the
action runs in a worktree thread made on the first run, its later runs first rebased onto the
origin branch when Update before run is on, and Auto-delete deletes the thread that many seconds
after the action started. A project that stops enabling the plugin
pauses its tasks, and enabling it again resumes them, except those the owner turned off. Only
desktop app channels get them: threads and worktree threads run their channel’s, and plugins
don’t run in Slack or Discord. Removing the plugin deletes them.
Capabilities are approved by the owner at install and checked on every call:
| Capability | Grants |
|---|---|
files.read | GET /channel/files, GET /channel/tree |
git.read | GET /channel/git/recent, GET /channel/git/head |
quality.read | GET /channel/quality/hotspots |
agent.readonly | runs of read-only agents; GET and DELETE /runs/{run_id} |
agent.write | runs of any agent; GET and DELETE /runs/{run_id} |
schedule | scheduled tasks of actions |
ui.panel | panels |
ui.notification.notify | POST /notify, the bridge’s ui.notification.notify |
ui.open_file | POST /ui/open_file, the bridge’s ui.open_file |
ui.copy | the bridge’s ui.copy |
memory.write | PUT /memory/{name}, DELETE /memory/{name} |
events.subscribe | the manifest’s events, sent to POST /events |
ui.run | POST /ui/commands: drives an app window, in the plugin’s channel and its threads |
ui.badge | PUT /badge, DELETE /badge: a short label on the channel’s sidebar row |
ui.notification.card | the manifest’s ui.cards, POST /cards: pages in the channel’s notification drawer |
ui.composer_switch | the manifest’s ui.composer_switches, PUT and DELETE /composer/switches/{id}: on/off pills in the chat’s composer |
live.control | the manifest’s live, POST /channel/live/control, PUT and DELETE /channel/live/hold: drives the chat’s live session |
Container environment
| Variable | Value |
|---|---|
LOOP_PLUGIN_ID | the plugin’s id |
LOOP_CHANNEL_ID, LOOP_CHANNEL_DIR | the channel the container serves and its dir on the host. The dir isn’t mounted: read its files through GET /channel/files and GET /channel/tree |
LOOP_PLUGIN_PORT | the port to serve on |
LOOP_API_URL | Loop’s API, for the Plugin API |
LOOP_PLUGIN_TOKEN_FILE | the plugin token for the Plugin API |
LOOP_PLUGIN_SECRET_FILE | the secret Loop sends to the plugin |
LOOP_DATA_DIR | /data, the host dir ~/.loop/plugins/<id>/data/<channel>/, which outlives the container |
The container is started when first needed and stopped when idle; keep state in /data. A
docker restart of it is fine: Loop finds the new port it’s published on. It runs
as the daemon’s user, so the files it writes there belong to you on the host. A daemon running as
root, in a container, names the user with LOOP_HOST_UID and LOOP_HOST_GID; plugin containers
never run as root. Removing the plugin keeps the files; --purge deletes them.
The channel’s dir is the working tree of the channel’s checkout, on whatever branch it has, with uncommitted changes. A thread’s is its channel’s; a worktree thread’s is its worktree, on the worktree’s branch.
Agentgate
While gates.agentgate.enabled is on, a plugin container runs its image’s entrypoint and command
under loop syscallwrap, which starts as root, drops to the daemon’s user and traps the
plugin’s file, exec and unix-socket syscalls. Nobody is there to answer a prompt, so whatever the
policy doesn’t allow is denied. The policy is a deny on running files from /data, /tmp and
/dev/shm, the dirs a plugin can write, so it only runs what its image ships. Then come the
manifest’s gate rules, then Loop’s baseline:
- every file op under
/dataand/tmp, and on the usual/devfiles - reads, stats and listings anywhere else
- any exec of a file outside those dirs
- no unix-socket connects
"gate": {
"files": [{ "paths": ["/data/cache/**"], "operations": ["delete"], "decision": "deny" }],
"commands": [{ "commands": ["curl", "wget"], "decision": "deny" }],
}The manifest may only allow or deny; approve is refused at install. Decisions go to an audit
log under ~/.loop/plugins/<id>/audit/<channel>/, kept per gates.audit. Unlike an agent
container, a plugin container keeps Docker’s default seccomp profile; the gate’s filter stacks
on top of it.
The gate needs a linux loop binary in the container, which plugin images don’t have. Loop
mounts the release matching the daemon’s version, downloaded on first use, checked against the
release’s checksums and cached under ~/.loop/cache/gate-bin/. A dev build has no release, so
build one with make build-linux and point plugins.gate_binary at it.
Loop → plugin
Both halves of the contract are published as OpenAPI 3.1 specs in the SDK module:
plugin-contract-v1.yaml
for what a plugin
serves, and plugin-api-v1.yaml
for the Plugin API.
Loop’s and the SDK’s tests check the routes, capabilities, success statuses and the shape of each
JSON answer against them. The SDK’s wire types are generated from them with oapi-codegen into
openapi/pluginapi and openapi/plugincontract: after changing a spec, run make sdk-gen;
make lint and CI fail while the generated files are out of date (make sdk-gen-check).
Every request carries Authorization: Bearer <contents of LOOP_PLUGIN_SECRET_FILE>; refuse
any other.
| Method and path | Body | Answer |
|---|---|---|
GET /healthz | 200 {"api": "1", "version": "…"} | |
POST /actions/{action} | {"trigger": "manual"|"schedule"|"workflow", "task_id": 0, "run_id", "input": {…}} | 202 quickly; do the work afterwards |
POST /tools/{tool} | {"run_id", "agent", "thread_id", "input": {…}} | 200 {"content": "…", "is_error": false, "structured_content": {…}}; the agent sees content, and is_error lets it retry; a workflow takes content as the node’s output, and is_error fails the node. structured_content is optional |
POST /runs/{run_id}/done | {"status": "done"|"failed", "error": "…"} | 204; sent when an agent run the plugin started ends |
POST /events | {"type", "channel_id", "thread_id", "data": {…}, "timestamp"} | 204; a Loop event the manifest subscribes to, with data as the app’s events WebSocket carries it |
POST /switches/{switch} | {"on", "thread_id"?} | 200 {"on", "status"?, "title"?}; the owner clicked one of the manifest’s composer switches
, asking for on, in the channel or in its thread thread_id. The answer is what the switch shows |
ANY /x/{path} | whatever the plugin’s UI sends | the plugin’s own API for its UI, proxied by Loop with method, query, body and Content-Type |
Tool input is validated against the tool’s input_schema before it reaches the plugin.
A tool may also answer with structured_content, the result as a JSON object, like MCP’s
structuredContent. An agent gets it as the MCP result’s structuredContent, and a workflow
reads its fields as {{.NodeData.<node id>.<field>}}. A tool that declares an output_schema, a
JSON schema of type object, must answer with structured_content that matches it; Loop checks
it, and turns a result that doesn’t into an is_error one (a workflow node fails). The schema is
listed as the MCP tool’s outputSchema. Keep content readable on its own: the JSON text is fine.
"tools": [{
"name": "score",
"description": "Grades an answer",
"input_schema": { "type": "object", "properties": { "answer": { "type": "string" } } },
"output_schema": {
"type": "object",
"properties": { "score": { "type": "integer" }, "verdict": { "type": "string" } },
"required": ["score"]
}
}]A workflow’s plugin node runs an action or calls a tool on the container of the run’s channel,
starting it if it isn’t running, when the channel’s project enables the plugin (see
Workflows: Plugin Node
). Both come with run_id
workflow:<run id>:<node id> and the node’s rendered input. Its action gets "trigger": "workflow", and the node succeeds once the plugin accepts it, so an action’s result never
reaches the workflow: a workflow that needs one calls a tool. Its tool call may name any tool in
the manifest, not only those given to an agent, comes with an empty agent and thread_id,
and may run past 60 seconds when the node’s timeout is longer.
When a plugin stops answering
Each call has a time limit: 10 s for actions, /runs/{run_id}/done, /events and /switches, 60 s for tools, 30 s for
/x. A tool answer is capped at 1 MB and an /x body at 8 MB.
When a container leaves 3 calls in a row unanswered (it refused the connection or ran out of
time), Loop stops calling it for 30 s and fails those calls at once: /x and actions with 503,
tool calls with an is_error result the agent sees. After that, one call tries the container
again; an answer, of any status, closes the breaker. A container that restarts starts afresh.
Plugin → Loop: Plugin API v1
Calls go to $LOOP_API_URL/api/plugin/v1/... with Authorization: Bearer <contents of LOOP_PLUGIN_TOKEN_FILE>. The token is held to the plugin’s channel, so no route takes a
channel id. A route needing a capability the owner didn’t approve answers 403.
| Route | Capability | Answer |
|---|---|---|
GET /channel | {"id", "name", "dir_path", "platform", "model", "effort", "settings"}; settings are the plugin’s settings in the channel’s project | |
GET /channel/files?path=&start=&end= | files.read | {"path", "content", "start", "end", "total_lines"}; lines are 1-based and inclusive, both optional (without them content is the whole file); .git/ is refused, and so is a file the channel’s agents may not read under agentgate (403); 404 if missing, 400 for a directory or a range past the end, 413 over the size limit |
GET /channel/tree?glob= | files.read | {"files": ["a/b.go", …], "truncated": true}: the files git tracks (or every file outside .git/ without git), leaving out files agentgate keeps from the channel’s agents, optionally filtered by a glob matched against the path or its base name; at most 50,000, with truncated set when there were more |
GET /channel/git/recent?days= | git.read | {"commits": [{"sha", "subject", "author", "time"}], "files": [{"path", "commits"}]}; files most-changed first; days defaults to 14, at most 365; the last 500 commits; 409 when the dir isn’t a git repository |
GET /channel/git/head | git.read | {"sha", "subject", "author", "time", "branch"}: the commit checked out; branch is empty when HEAD is detached; 409 when the dir isn’t a git repository or has no commits |
GET /channel/quality/hotspots?limit=&offset= | quality.read | a page of the latest quality scan’s functions, worst first: {"score", "raw", "total_functions", "over_threshold", "histogram", "functions": [{"path", "name", "start_line", "cyclomatic", "cognitive", "max_nesting", "param_count", "loc", "score"}], "offset", "limit", "returned"}; limit defaults to 50, at most 100; 503 before the first scan |
POST /runs | agent.readonly / agent.write | body {"agent", "prompt", "model"?, "effort"?} → 202 {"run_id", "thread_id"}; see below |
GET /runs/{run_id} | agent.readonly / agent.write | {"run_id", "thread_id", "status"} while the plugin’s run in the channel is queued or running; 404 once it ended, or for a run that isn’t the plugin’s |
DELETE /runs/{run_id} | agent.readonly / agent.write | 204; stops the run, or takes it out of the thread’s queue, and Loop calls POST /runs/{run_id}/done with failed. 404 when it already ended, 409 while it’s moving from the queue to running (try again) |
POST /events | body {"type", "data"} → 204; sent to the plugin’s panels in the channel as type | |
POST /notify | ui.notification.notify | body {"title", "body"} → 204; kept in the notification drawer of the plugin’s channel, or of the thread its container serves. There’s no desktop notification. Five a minute per plugin, cards included; past that 429 with Retry-After |
PUT /memory/{name} | memory.write | body {"content"} (up to 256 KB) → 204; writes plugin-<id>-<name>.md into the auto-memory dir of the channel’s dir, where the channel’s agents find it with search_memory once memory is on. name is lowercase letters, digits and dashes, at most 64 (400 otherwise); 409 when the channel has no dir |
DELETE /memory/{name} | memory.write | 204, also when there was no such file |
POST /ui/commands | ui.run | body {"steps", "client_id"?, "timeout"?} → 200 {"client_id", "results", "error"?}; an app window runs the UI bridge steps
and Loop answers when it’s done, up to timeout (a Go duration, 1m by default, at most 10m). The first step is select_channel, and each select_channel opens the plugin’s channel or one of its threads (403 otherwise); terminal steps work only on the agent and shell panes and the chat panes, where send_input queues a message in the chat. 404 when no window is open, 502 when it goes away, 504 when it doesn’t answer in time |
POST /ui/open_file | ui.open_file | body {"path", "line"?} → 200 {"client_id", "results", "error"?}; an app window opens the plugin’s channel and path, relative to the channel’s dir, in its editor, at line when given. 400 for an empty, absolute or outside path, or a negative line; otherwise answers as POST /ui/commands with its default timeout |
PUT /badge | ui.badge | body {"text", "title"?} → 204; shows text (trimmed, 1 to 16 characters on one line) on the sidebar row of the plugin’s channel, or of the thread its container serves, with title (at most 200 characters) as its tooltip. It replaces the plugin’s badge there and stays until the plugin clears it, is removed or loses ui.badge, the channel is deleted, or its project stops enabling the plugin; every open window drops it at once |
DELETE /badge | ui.badge | 204, also when there was no badge |
POST /cards | ui.notification.card | body {"card", "title"?, "data"?} (up to 64 KB) → 201 {"id"}; posts one of the manifest’s cards to the notification drawer of the plugin’s channel, or of the thread its container serves, with data for its page (see Cards
). Shares /notify’s five a minute (429). 400 for a card the manifest doesn’t declare, and outside the desktop app |
PUT /composer/switches/{id} | ui.composer_switch | body {"on", "status"?, "title"?, "thread_id"?} → 204; sets what one of the manifest’s composer switches
shows in the channel, or in its thread thread_id. status is busy, ok, error or empty, title at most 200 characters (400 otherwise); 404 for a switch the manifest doesn’t declare or a thread that isn’t the channel’s |
DELETE /composer/switches/{id}?thread_id= | ui.composer_switch | 204; the switch shows off again, with the manifest’s title |
POST /channel/live/control | live.control | body {"request": {"subtype", …}, "thread_id"?} → 200 {"response": {…}}; sends request as a control request to the live session
of the channel or its thread, and answers with the session’s response. 400 for a subtype the manifest’s live.control doesn’t list, 409 when the chat has no live session, 502 when the session refuses it, 503 while live sessions are unavailable, 504 after 30 s without an answer |
PUT /channel/live/hold?thread_id= | live.control | 204; starts the chat’s live session if it isn’t running and keeps it from idling out until the plugin lets go. During an agent image build it answers before the session runs; live.status tells when it does. 409 when the chat doesn’t run live |
DELETE /channel/live/hold?thread_id= | live.control | 204; lets the live session idle out again |
Agent runs
POST /runs starts a run of one of the plugin’s agents in the channel’s hidden plugin:<id>
thread, with a fresh session. The agent gets the tools the manifest gives it as MCP tools named
plugin_<tool>; a read-only agent runs read-only. The plugin sends the whole prompt. When the run
ends Loop calls POST /runs/{run_id}/done on the plugin.
Loop keeps each run in its database until it ends, so a restart of Loop doesn’t lose it: a run still
waiting in the thread’s queue runs once Loop is back, and one that was going on fails, with
/runs/{run_id}/done sent as failed. One that ended well while Loop was stopping is sent as
done once Loop is back.
Each plugin starts at most plugins.daily_runs runs (50 by default) in any 24 hours, across all
channels; past that, POST /runs answers 429.
Composer switches
A plugin approved for ui.composer_switch declares up to four on/off pills in the manifest’s
ui.composer_switches: id, label (at most 24 characters), icon (an .svg under ui/, at
most 16 KB) and title, the tooltip until the plugin sets one (at most 200 characters). Each
chat of a project that enables the plugin shows them in its composer, after the Learn and Explain
switches; side chats and hidden threads don’t. The icon is drawn in the pill’s color: dim when
off, the accent when on, red on error, with a dashed border while busy.
A click calls POST /switches/{switch} on the channel’s container, with the thread’s id for a
thread, and the pill shows the answer. Until then it shows the click; when the call fails it
turns back, with the error in its tooltip. The plugin may change the pill later with PUT /composer/switches/{id}, when what the switch stands for changes, and DELETE resets it. Loop
keeps each switch’s state per chat, and forgets it when the chat is deleted, the plugin is
removed, loses ui.composer_switch, stops declaring the switch or isn’t enabled by the project
anymore. When the daemon starts, it starts the plugin’s container in each channel where one of
its switches is on, so the plugin can bring back what the switch stands for; the pill shows its
last state until the plugin changes it.
Live sessions
A plugin approved for live.control drives the live session
of its
channel and its threads, the claude process their turns run in. The manifest’s live.control
lists the subtypes of the control requests it may send with POST /channel/live/control, and
live.messages the subtypes of the session’s system messages it gets as live.system events.
PUT /channel/live/hold keeps the session running while the plugin needs it; Loop lets it go
when the plugin may no longer use it there.
Go SDK
github.com/radutopala/loop/pluginsdk implements both halves of the contract for a plugin
written in Go:
LoadEnvreads the container environment;Env.Secret,Env.ClientandEnv.Listengive the secret, a Plugin API client and the listener.Serverserves/healthz,/actions,/tools,/runs/{run_id}/done,/events,/switches(Switches, aSwitchFuncper composer switch) and your ownXhandler under/x, behind the secret. An action answers202and runs itsActionFuncin the background; a tool’s error becomes a result withis_errorset, andStructuredResult(v)makes a result whosecontentandstructured_contentare bothv’s JSON;Eventstakes each event while Loop waits, so hand long work toServer.Go.Server.Goruns other background work, whichClosecancels.Client(theAPIinterface) calls the Plugin API; a non-2xx answer is aStatusError, whichHasStatuschecks.Hotspotsreturns the worst functions,HotspotsPagea page of them with the scan’s totals.RunUIwaits as long as the command’s timeout, pastClient.Timeout, andLiveControlas long as Loop waits for the live session.ReadManifestparsesplugin.json, so/healthzcan report its version.Serveserves until the context ends, then waits for requests in flight.
env := pluginsdk.LoadEnv(os.Getenv)
secret, err := env.Secret()
m, err := pluginsdk.ReadManifest(pluginsdk.ManifestPath)
srv := &pluginsdk.Server{
Secret: secret,
Version: m.Version,
Actions: map[string]pluginsdk.ActionFunc{"daily": daily(env.Client())},
X: uiAPI,
}
defer srv.Close()
ln, err := env.Listen()
return pluginsdk.Serve(ctx, ln, srv.Handler())Package pluginsdktest is for the plugin’s tests. NewFakeLoop serves the Plugin API from
memory, holding the plugin to its manifest’s capabilities and agents, and records the events,
notifications, runs, memory, UI commands, switch states, live control requests and holds it
gets; a UI command answers that every step ran, and a live control request answers
Config.LiveResponses for its subtype, or 409 with Config.NotLive.
Check drives a plugin through the Loop → plugin contract: its health and version, the secret,
every action with each trigger, every tool as an agent’s and as a workflow’s call, every
composer switch turned on and off, and the end of each run it started.
loop := pluginsdktest.NewFakeLoop(t, pluginsdktest.Config{Manifest: m, Files: files})
srv := newServer(loop.Client())
ts := httptest.NewServer(srv.Handler())
pluginsdktest.Check(t, &pluginsdktest.Plugin{URL: ts.URL, Secret: secret}, m,
pluginsdktest.CheckOptions{Loop: loop, Settle: srv.Wait})CheckUI loads each panel and card of the plugin’s /loop-plugin directory in a headless Chrome
the way Loop draws them: served with Loop’s content security policy into a sandboxed frame, with
a fake bridge that answers api.request with UIOptions.API. It returns the problems it saw: a
load or inline script the policy blocked, an uncaught error or unhandled rejection, a file of the
bundle that didn’t load, a failed bridge call, and a page that didn’t load or take the bridge’s
port within UIOptions.Timeout (10 seconds; NoBridge skips the bridge). It finds Chrome or
Chromium where chromedp looks; Chrome.Options takes another path, or chromedp.NoSandbox to
run as root in a container.
problems, err := pluginsdktest.CheckUI(ctx, "../loop-plugin", pluginsdktest.UIOptions{API: uiAPI})
require.NoError(t, err)
require.Empty(t, problems)Panels
A panel is a static bundle under /loop-plugin/ui/, shown in a sandboxed iframe. Its files are
served from a signed link with a content security policy that:
- gives the page an opaque origin (no cookies, storage or access to the app);
- loads scripts, styles, images, fonts and
fetches only from the plugin’s own bundle, plusdata:/blob:for images, fonts and media; - blocks inline
<script>,eval, frames and form submission.
The policy can’t stop a frame from navigating itself, so the desktop app does: a link or
location = … in a panel or card page that leads outside the plugin’s own signed link is refused.
So a bundle ships everything it needs, uses script files, and gets its data through the bridge.
A plugin approved for ui.panel adds its panels to the workspace’s add-pane menus, in split and
canvas layouts, after the built-in panels. A singleton panel can be shown once per layout. A
pane talks to the plugin’s container of the channel, thread or worktree thread it’s in.
Once the plugin has started an agent run in the pane’s channel, its panes show an agent label at the
right of their header. It opens the agent view over the layout, as the Learn view opens: the panel
on the left half, the run’s chat on the right, and the logo on their seam, which moves while a run
runs. The label closes it again. The panel isn’t reloaded on the way. The chat is read-only: only
the plugin starts runs in its thread, each in a new session of its own. Removing the plugin with
--purge deletes the thread with its runs; a plain removal keeps it for when it’s installed again.
Bridge
The TypeScript package under app/packages/plugin-ui, published to npm as
@radutopala/loop-plugin-ui, wraps the bridge: connect() waits for the port and returns typed api,
context, settings, state, events, ui and theme calls, with applyTheme/syncTheme
for the theme’s CSS variables and createStorage, a localStorage stand-in kept in the panel’s
state. Its README covers installing and using it. The protocol below
is all it speaks, so a panel can also do without it.
On load the host posts {"type": "loop-plugin:init"} to the frame with a MessagePort in
event.ports[0]. The port carries JSON-RPC 2.0: the frame sends
{"jsonrpc": "2.0", "id": 1, "method": "…", "params": {…}} and gets back result or
error: {"code", "message"}; notifications from the host have no id.
| Method | Params | Result |
|---|---|---|
api.request | {"method", "path", "body"?} | {"status", "body"}: the plugin’s /x/{path} in the panel’s channel, JSON bodies decoded |
context.get | {"pluginId", "panel", "channelId", "channelName", "dirPath"}; in a card panel is "", with card and data | |
state.get | the panel’s saved UI state, or null | |
state.set | {"value"} | null; up to 64 KB of JSON, kept per plugin, channel and panel |
theme.get | {"mode": "dark"|"light", "vars": {"--bg": "…", …}} | |
settings.get | the plugin’s settings in the channel’s project | |
ui.notification.notify | {"title", "body"} | null; kept in the notification drawer of the panel’s channel, like POST /notify; needs ui.notification.notify |
ui.open_file | {"path", "line"?} | null; opens a file of the channel in the editor; needs ui.open_file |
ui.copy | {"text"} | null; puts text, at most 1 MB, on the clipboard; needs ui.copy |
ui.resize | {"height"} | null; sets a card’s height in pixels, kept within 80 to 900; a panel fills its pane and gets an error |
ui.set_title | {"title"} | null; names the panel’s pane, at most 80 characters, until the page reloads; "" gives it back the panel’s label; a card gets an error |
The theme’s vars are --bg, --surface, --fg, --muted, --border, --accent,
--success, --danger, --font and --mono; a panel sets them on its root element.
Host notifications:
| Method | Params |
|---|---|
event | {"type", "data"}: an event the plugin’s container sent with POST /events |
theme | as theme.get, when the theme changes |
settings | as settings.get, when a config write changes them |
state | the state the same panel of the channel saved with state.set in another pane |
Cards
A plugin approved for ui.notification.card declares pages in the manifest’s ui.cards (id and entry,
checked like a panel’s) and posts one with POST /cards. Cards don’t go in the chat, which is
for talking with the agent: they land in the notification drawer of the channel or thread, opened
from the bell at the right of the layout bar, next to Loop’s own notifications about it
(scheduled runs, failed runs and scheduled scripts and plugin actions that failed, workflows). The sidebar row shows how many are unread. The drawer
shows the card under the plugin’s name and the title; the page is in a frame, under the same
rules as a panel’s. The page
gets the same bridge as a panel, with three differences: context.get has the card’s id as
card and the posted data, state lasts as long as the frame, and the page sets its own
height with ui.resize, 160 pixels until it does. api.request reaches the container of the
card’s channel or thread. A card of a plugin that was removed, or no longer approved for
ui.notification.card, shows a note in its place; removing the plugin drops its notifications.