docs/EXTENSIONS.md
Rama main · réplica de lectura del repositorio original.
# Extensions
Extensions add pages, actions, keyboard shortcuts and scheduled work to AgentRQ.
They are ordinary Node modules, published as GitHub repositories, and installed
from the **desktop app**.
> **Desktop only, and deliberately.** An extension is code somebody else wrote,
> running with the privileges of the process it is in. On a self-hosted AgentRQ
> server that would mean running a stranger's code on your infrastructure, next
> to your database and your other users. On the desktop it runs on the machine
> of the person who chose to install it — which is the same trust decision they
> already make installing anything else. The server never loads extension code.
---
## What an extension is
Three files, at minimum:
```
my-extension/
agentrq-extension.json the manifest — what it is, and what it asks for
index.js the module — `name`, `inject`, `apply`
package.json ordinary npm metadata
```
`index.js` exports the same shape a Cordis plugin does, which is where the
pattern comes from:
```js
export const name = 'my-extension'
export const inject = ['ui', 'shortcuts']
export function apply(ctx, config) {
ctx.ui.add({ id: 'today', surface: 'page', label: 'Today', view: () => page(ctx) })
}
```
`apply` is called once, with a context carrying exactly the registries listed in
`inject`. Everything it registers is remembered against the extension's name, so
unloading is complete by construction rather than by an author remembering to
tidy up.
### The examples are the tutorial
They live in [`examples/extensions/`](../examples/extensions/). The first three
go from smallest to largest; the fourth is the odd one out, and deliberately so
— everything else contributes something the app draws, and it gets asked a
question instead. The fifth draws for itself, in the side panel.
| | Asks for | Shows |
|---|---|---|
| [`task-stats`](../examples/extensions/task-stats/) | nothing at all | The smallest possible extension: a task menu item, no permissions, no network |
| [`standup`](../examples/extensions/standup/) | three workspace actions | A page, a task menu item, a keyboard shortcut, a config field, brokered MCP calls |
| [`digest`](../examples/extensions/digest/) | the supervisor | Three surfaces, a secret, a declared host, and standing work on a schedule |
| [`guardrail`](../examples/extensions/guardrail/) | to be *asked* | The one registry that answers back: it reviews an agent's permission prompt and refuses the commands that cannot be undone |
| [`panel-notes`](../examples/extensions/panel-notes/) | nothing at all | A page of its own HTML, CSS and JS in the desktop side panel, talking to its extension — the page draws, the extension keeps the data |
Read the first two together: `task-stats` describes the task it was handed and
says out loud that it cannot see the conversation; `standup` asks for `task.get`
at install and can therefore count one. That difference is what the permission
model is *for*, and it is the difference between the two screens a user sees.
Read them in that order. Each one is commented as an explanation rather than as
a demo.
---
## The manifest
`agentrq-extension.json`, at the root of the repository.
```json
{
"name": "standup",
"displayName": "Standup",
"version": "1.0.0",
"description": "What moved in this workspace today.",
"license": "MIT",
"engines": { "agentrq": ">=0.5" },
"mcp": { "workspace": ["workspace.get", "task.get"] },
"hooks": { "toolCall": "deny" },
"net": ["api.example.com"],
"config": [{ "key": "since", "type": "number", "label": "Hours to look back" }],
"provides": { "ui": ["page"], "shortcuts": ["s"] },
"shortcuts": [{ "key": "s", "action": "open", "title": "Standup: today" }],
"artifact": {
"release": "v1.0.0",
"asset": "standup-1.0.0.tgz",
"sha256": "…64 hex characters…"
}
}
```
| Field | Required | Meaning |
|---|---|---|
| `name` | yes | Lowercase words joined by single hyphens. It is an **address**, not a label: it appears in routes, registry keys and the install directory |
| `version` | yes | `1.2.0`, or `1.2` |
| `license` | yes | An SPDX identifier, spelled exactly. `UNLICENSED` is accepted and means what it says |
| `engines.agentrq` | yes | `1.4.0`, `^1.4`, `~1.4` or `>=1.4`. Anything else is refused rather than guessed at |
| `artifact` | yes | The release, the asset file name, and its SHA-256 |
| `displayName` | no | Defaults to `name` |
| `description` | no | One line, shown in the catalogue |
| `mcp.workspace` | no | Workspace-server tools this extension may call: a tool, or one action of an action tool (`task.get`) — **see below** |
| `mcp.supervisor` | no | Supervisor tools this extension may call, in the same form |
| `hooks.toolCall` | no | `deny` or `decide` — that it wants to review an agent's permission prompts. **See below** |
| `net` | no | Hosts the author says it contacts — **see below** |
| `config` | no | Settings the user fills in: `string`, `number`, `boolean` or `secret` |
| `provides` | no | What it contributes, for the catalogue card |
| `shortcuts` | no | Keys it wants, under the `x` prefix |
A repository with a bad manifest is **listed as broken with its reason** rather
than dropped, because an author needs to see why and a silently missing
extension looks like a broken catalogue to whoever followed a link to it.
### Name only tools that exist
A manifest can name anything; the servers decide what is real, and
`checkCompatibility` refuses an install with *"the workspace server does not
offer listTasks"* — on somebody's machine, at install time, which is late.
The two surfaces are **not** the same, and the difference is deliberate rather
than an oversight to route around.
**The per-workspace server** — what an agent working a queue can do:
| Tool | Actions |
|---|---|
| `task` | `create` `get` `updateStatus` `reply` `elicit` `getAttachment` |
| `memory` | `load` `save` `delete` |
| `skills` | `search` `load` `save` `delete` |
| `workspace` | `get` `publishEvent` |
| `siteTools` | `search` `getDefinition` `call` |
| `pano` | `save` `read` `comment` `attach` `delete` |
| `help` | none: `{ action: "task" }` or `{ action: "task.create" }` explains one |
It answers in **prose, not JSON** — it is written for agents to read.
`workspace.get` returns the name, the description and a count per status;
`task.get` with `includeConversation` returns the task followed by a
`Conversation:` block of JSON. `standup` parses both, and those few lines are
worth reading before you write your own.
There is **no task listing here on purpose**. An agent connected to a workspace
acts on the task it was given and reads what the workspace remembers; it does
not enumerate the board. `workspace.get` answers with counts per status, which is
what a summary actually needs, and `task.get` takes an id you already have.
**The supervisor server** is account-wide and does list things. Its tools are
`workspace`, `task`, `memory`, `skills`, `events`, `workflows`, `machines`,
`pano` and `help` — `workspace.list`, `task.list`, `task.listAll`,
`events.list` and so on. Reaching it is reaching every workspace, which is why
it sits at the top of the grant ladder.
`standup` was first written asking for `listTasks` on the workspace server and
would never have installed. Two tests now guard this from both sides: a Go test
checks every example manifest against the servers' actual registrations and
actions, and `desktop/src/main/extensions/servers.js` — the lists the install
screen judges against, actions included — is checked against the same Go
source, so a tool added on one side fails the build on the other.
Those lists describe **the desktop app's idea of the servers**, not the one it is
connected to. A self-hosted backend older than the app may not have a tool listed
there; an extension needing it installs and is then refused at call time, by the
server, with the server's own message.
### Action tools, and asking for one action
Every tool but `help` takes two inputs, an action and its payload, rather than
being a tool per thing it does. A manifest asks for one action as
`tool.action` — `task.get`, `workspace.list` — or for the bare `tool`, which is
every action, `delete` included. Ask for the actions you call: the install
screen shows the list as what the extension may do, and the broker holds each
call to the action it names.
Asking how to call them needs no entry of its own. A tool's own `help` action
is allowed by any action of that tool, and the `help` tool by any grant at all
on that server: it only explains the others, and reads nothing of yours.
```js
await ctx.mcp.workspace('task', { workspaceId, action: 'get', payload: { taskId } })
```
The tools that became actions — `getTask`, `listWorkspaces`, `loadMemory`,
`createEventTrigger` and the rest — are read as those actions wherever they
still appear: in a manifest (`getTask` is `task.get`), in a saved grant, and in
a call, which is made as the action with the old arguments as its payload. An extension written
before the change keeps working; write new ones with the action names.
### Why the licence is required
An extension runs with full access to the machine. The terms it is offered under
are part of the decision to install it, not metadata about it. Free text was not
accepted because `MIT`, `mit` and `MIT License` would be three incomparable
licences; the list is fixed and case-sensitive, and a missing identifier is a
one-line addition to `desktop/src/main/extensions/manifest.js`.
### `net` is a description, not a restriction
Nothing enforces it. An extension is trusted Node code and can open any socket
it likes, and this field changes nothing about that.
It is there because it is still worth knowing — an extension that says it talks
to `hooks.slack.com` has told you something true about what it is for. The
install screen presents it as a claim by the author, kept out of the permission
list, because a list of hosts under a permission heading reads as a boundary
somebody is holding, and here nobody is.
---
## The capability model
There are exactly two things AgentRQ enforces, and it is worth being precise
about what they are and are not.
**They are not a sandbox.** That was considered and deliberately not built: the
value of this design is the ordinary Node ecosystem, and a sandbox costs exactly
that. The install screen says so in those words on every install, including —
especially — the ones with no permission list at all.
**What is enforced is everything AgentRQ owns:**
1. **Which registries an extension can reach**, and which names it may claim
inside them.
2. **Which MCP tools it may call, and against which workspaces.**
3. **Whether it is asked anything on your behalf**, and if so whether it may
only refuse or may also approve.
That is a real boundary around your AgentRQ data. It is not a boundary around
your machine.
The third is the odd one, because it is a permission to be *asked* rather than a
permission to reach something — see [`hooks`](#hooks--reviewing-a-tool-call-before-the-user-is-asked).
It has its own section on the install screen, starts switched off, and is the
only grant that can be changed afterwards without reinstalling.
### The credential never reaches the extension
An extension is an MCP *client*, and it never holds a token. It asks:
```js
const answer = await ctx.mcp.workspace('task', { workspaceId, action: 'get', payload: { taskId } })
if (!answer.ok) return somethingSensible(answer.reason)
```
The host attaches the workspace token or the supervisor session on the way out,
after checking the tool against the manifest and the workspace against the
grant. A refusal comes back as `{ ok: false, reason }` — **a value, not a
throw** — so a refused extension shows a sentence rather than becoming an
unhandled rejection somewhere in the host.
`workspaceId` says which workspace you mean. On the **workspace** surface that is
all it is: the per-workspace server has one endpoint per workspace, so the host
turns it into a URL and keeps it out of the arguments — those tools take no such
parameter and the server refuses one outright. On the **supervisor** surface it
stays, because there it genuinely is an argument.
**The supervisor needs your authorisation.** Its endpoint wants a token whose
audience is `coremcp`, and only the OAuth2 flow mints one — the session cookie
the app already holds is not enough. So the first time an account-wide
extension is installed, the Extensions screen offers an **Authorise** button,
and a window opens on your own server's sign-in page.
Asked, never assumed. A credential that reaches every workspace is not
something an app should acquire because you installed something; the prompt
appears only once an installed extension has actually been granted the account.
The token is stored encrypted, with a key only your user can read, and can be
given back at any time, after which the next account-wide call asks again.
This matters because a workspace token and a supervisor session outlive any
single extension and reach every workspace on the account. An extension that is
compromised, or merely careless with what it logs, cannot leak a key it was
never given.
### The grant ladder
At install, the user picks one rung. What is offered depends on what the
manifest asked for **and on where they are installing from**:
| Rung | Offered when | Means |
|---|---|---|
| This workspace only | any `mcp.workspace` tools, *and a workspace is in context* | The workspace they were in |
| Selected workspaces | any `mcp.workspace` tools | The ones they tick |
| All workspaces | any `mcp.supervisor` tools | The account, including workspaces created later |
Extensions are installed from the sidebar's Extensions screen, which belongs to
no workspace — so "this workspace only" is not offered there. It shipped offered,
meaning nothing, and a grant made from it carried an empty workspace list: not a
narrow grant but an inert one, refusing every call with *"not granted access to
that workspace"* for a workspace the user believed they had just allowed.
An extension that asks for nothing shows **no permission list at all** — only
the confirmation and the sentence about machine access.
The ladder above is about what an extension *reaches*. An extension asking to
review tool calls is asking for something that is not on it, and can ask for
that and nothing else: it gets its own section, with its own three rungs — not
at all, may refuse, may approve or refuse — starting at the first.
There is deliberately no "supervisor, but only this workspace". `task.listAll`
spans the platform and `task.create(workspaceId)` reaches anywhere, so the
supervisor surface *is* every workspace; that combination would be the same
grant wearing a narrower label, and the ladder makes it unrepresentable rather
than merely discouraged.
---
## What an extension can contribute
Five registries, reached through `inject`. Four of them are contributions the
app draws; `hooks` is the one that gets asked a question.
One more thing `inject` can name is not a registry either: [`panel`](#panel--a-page-of-your-own-in-the-side-panel),
which opens the side panel and talks to the page in it.
Two things are **not** registries and are always on `ctx` whatever you inject:
[`ctx.mcp`](#the-credential-never-reaches-the-extension), which is how you reach
AgentRQ, and [`ctx.storage`](#ctxstorage--settings-you-define-the-shape-of),
which is where you keep settings of your own shape.
### `ui` — pages, actions and menu items
```js
ctx.ui.add({ id, surface, label, order, view, run, when })
```
| `surface` | Where it appears | Invoked with |
|---|---|---|
| `page` | The sidebar, at `/extensions/:name/:id` | `view(context)` returns a view spec |
| `workspace-action` | The top of a workspace | `run(context)` |
| `task-menu` | A task's right-click menu | `run(task)`, filtered by `when(task)` |
| `workspace-settings-tab` | A tab in a workspace's settings | `view(context)`, always with `context.workspaceId` — [see below](#workspace-settings-tab--somewhere-to-configure-it) |
`run` and `view` are the same thing under two names — an action produces a
panel, a page produces a page — and either is accepted on any surface.
**Where each one appears.** A `page` gets a row in the sidebar under Extensions
and a route of its own at `/extensions/:name/:id`. **It may be opened with no
workspace**: a sidebar page belongs to the application, and AgentRQ passes one
only when it is unambiguous — the account's single workspace when there is
exactly one, never a guess among several. Every workspace tool needs one, so
write the page to say so when `context.workspaceId` is empty rather than making
a call that comes back "This call names no workspace." `standup` does exactly
that. A `workspace-action` and a `task-menu` item always have one. A `workspace-action` becomes a
button in the workspace header, after AgentRQ's own and behind a divider, so
installing something never shuffles a control somebody's hand already knows. A
`task-menu` item appears on right-click, also behind a divider, filtered by its
`when`.
**Your functions never leave the main process.** The renderer receives a
*description* of an entry and asks this side to run it, which is what keeps
third-party code away from a privileged origin that has a bridge to files, the
clipboard and the shell. `when(task)` is evaluated there too: the renderer sends
the task and receives the rows that apply to it.
A `run` that throws becomes a refusal with your extension's name on it, shown
where the panel would have been. A `run` that returns nothing draws no panel and
reports no error — that is the shape for an action that did something other than
display.
`order` defaults to 100, and ties break on owner then id — so what the user sees
never depends on which extension happened to load first.
**Give a task-menu item a `when`.** Without one, ten installed extensions mean
ten permanent rows on every task, and a menu that long is one nobody reads. An
entry that says nothing about when it applies is assumed to always apply, which
is right for AgentRQ's own items and rare for anybody else's.
### `workspace-settings-tab` — somewhere to configure it
```js
ctx.ui.add({
id: 'rules',
surface: 'workspace-settings-tab',
label: 'Guardrail',
async view(context) { // context.workspaceId, always
if (context.action === 'save') { /* context.values */ }
return { title: 'Guardrail', nodes: [...] }
},
})
```
A tab of your own in a workspace's settings, below the built-in ones and above
Danger Zone. The fourth UI surface, and the only one that answers *"configure
this extension, **here**"* — the other three are places to go and things to do.
**Always resolved for a workspace**, which is the point: one extension can hold
different settings for different workspaces without inventing a scheme for
keying them, and `ctx.storage.workspace(id)` is the other half of that.
The tab carries your extension's name, the same way the permission card names
whichever extension answered it. Once an extension can draw inputs, a tab can
look like AgentRQ asking somebody for something, and it must not be mistaken for
one.
### `shortcuts` — keys under the `x` prefix
```js
ctx.shortcuts.add({ id: 'open', key: 's', label: 'Standup: today', run })
```
Pressing <kbd>x</kbd> then <kbd>s</kbd> runs it, wherever you are in the app —
so what it opens is a panel rather than a page, since a page would mean
navigating away from whatever you were looking at.
The single bare letters are AgentRQ's — `k n w m t ?` are taken and more will
be. A blocklist would be a tiny namespace that freezes the application and still
lets extensions collide with each other; one reserved prefix fixes all three, and
`g`-then-key in Gmail and GitHub means people already know the scheme.
Conflicts are reported **at install**, naming the extension already holding the
key. A conflict discovered when somebody presses a key is a key that silently
does nothing, and no amount of looking at the screen explains it.
The application's own system-wide key grab (Cmd+Shift+N, registered in the main
process so it fires whether or not the window has focus) is not available to
extensions. That is a different order of capability from a key that works while
the app is in front of you.
### `renderers` — drawing a fenced code block
```js
ctx.renderers.add({
id: 'mermaid',
language: 'mermaid',
when: (context) => appliesTo(context.workspaceId),
run: (context) => ({ nodes: [{ type: 'diagram', format: 'mermaid', source: context.source }] }),
})
```
When a task body or a message contains a ```` ```mermaid ```` fence, whoever
claimed that language is asked what to do with it, and answers with a view spec
like any other surface. AgentRQ draws the answer and puts a **Text** / **Diagram**
toggle beside it, so the source is always one click away.
**A language belongs to one extension.** Two renderers both claiming `mermaid`
would be resolved by whichever loaded first, which is no answer at all — so the
second claim is refused at install, naming the first.
**Answering nothing is a legitimate answer**, and it leaves the fence as the
code block it was. That is the right response to a diagram that is too large, or
malformed, or asking for something the renderer will not do: the text is what
somebody needs in order to fix it, and replacing a readable block with a
complaint about it helps nobody.
`when(context)` is given the workspace the message is in, so *"draw diagrams in
this workspace and not that one"* is the extension's own answer rather than a
setting AgentRQ keeps on its behalf.
### `schedules` — work that runs when the app does not
```js
ctx.schedules.add({
id: 'daily',
value: { kind: 'task', workspaceId, title: 'Daily digest', body, cron: '0 9 * * *' },
})
```
This is what makes desktop-only a design rather than a compromise. Your
extension is not running at three in the morning — it does not need to be. It
*declares* a cron task or an event trigger, and the host reconciles that against
what is already on the server: creating what is missing, revising what has
drifted, and removing what you stopped declaring.
Two kinds:
```js
{ kind: 'task', workspaceId, title, body, assignee, cron }
{ kind: 'trigger', workspaceId, title, body, assignee, event, emitEvent, cron }
```
Cron granularity is **hourly at most** — the minute field must be a single fixed
number. `0 9 * * *` is fine; `*/5 * * * *` is refused.
Reconciliation means reinstalling leaves no duplicates and uninstalling leaves
nothing behind. The host makes these calls with its own credential rather than
through your grant, because otherwise every extension that wanted a nightly task
would have to ask for the ability to delete any task on the account. What bounds
it instead: it only ever touches what it created, and a schedule naming a
workspace your grant does not reach is refused.
### `hooks` — reviewing a tool call before the user is asked
```js
export const inject = ['hooks']
export function apply(ctx) {
ctx.hooks.add({
id: 'irreversible-shell',
review(request) {
// { workspaceId, taskId, requestId, toolName, description, inputPreview }
if (looksDestructive(request)) return { behavior: 'deny', reason: 'this cannot be undone' }
return undefined // no opinion: the user is asked, exactly as before
},
})
}
```
This is the only registry that points the other way. When an agent asks
permission to run something, AgentRQ normally writes the request into the task
and waits for a person. With a reviewer installed and consented to, it asks the
reviewer first and sends its answer as the user's.
**Declare it, or it is never called.** `"hooks": { "toolCall": "deny" }` in the
manifest asks to be able to refuse; `"decide"` asks to be able to approve as
well. The install screen offers nothing above what you declared, so an extension
that only means to block things cannot later approve one.
#### Three rules, all from one asymmetry
Refusing costs a tool call. Approving costs whatever the tool call does.
- **A single refusal settles it, immediately.** Reviewers are not voting — the
first `deny` is the answer and nobody after it is asked.
- **Silence is never an allow.** Abstaining, throwing, taking longer than five
seconds, or not being consented to all leave the request where it was: in
front of the user. Every bug in your reviewer fails that way.
- **Approving needs a consent that refusing does not.** A reviewer installed at
`deny` that answers `allow` is treated as having abstained, and told so in the
log.
#### What it is not
**It is not a security control, and must not be described as one.** Extensions
are desktop-only: with the app closed, every request waits for a person exactly
as it did before yours was installed. It is a convenience that can be absent,
never a guarantee — a request nobody reviewed is not a request that was
approved.
It also reads the *preview* the harness sent, which is a string that was easy to
produce rather than a promise about what will execute. A pattern list is worth
having for the ordinary case; it is not a sandbox, and nothing here is.
#### The user stays in charge of it
Consent starts at **not at all** on the install screen — it is the one thing
that never arrives pre-selected — and it can be changed or taken away from the
extension's row on the Extensions screen without uninstalling anything. An
extension refusing every tool call looks exactly like a broken agent, and
"uninstall it to find out" is not a diagnosis anybody should have to make.
Whatever a reviewer decides is recorded against its name: the permission card in
the task says *"by guardrail"* rather than showing a verdict the user is invited
to remember giving, and those decisions are counted separately from the ones a
person actually made. A reviewer also cannot answer `allow_always` — writing a
standing auto-allow rule is the user's own instruction, and it would outlive
both the review and the extension.
See [`guardrail`](../examples/extensions/guardrail/) for a complete one.
---
## `ctx.storage` — settings you define the shape of
```js
await ctx.storage.set('rules', { list: [...] }) // this install
await ctx.storage.workspace(id).set('rules', { ... }) // this workspace
await ctx.storage.workspace(id).get('rules')
await ctx.storage.secret.set('apiKey', value) // stored encrypted
```
`config` in the manifest is fields **AgentRQ renders and validates** — right for
an API key and a "look back N hours" number, hopeless for anything with
structure. `ctx.storage` is the other half: you draw the screen, you decide what
a setting is, and nothing here looks inside the value.
Also available: `get`, `all`, `keys`, `delete`, and `secret.has` / `secret.get` /
`secret.clear`. Every write answers `{ ok, reason }` rather than throwing, so a
refusal is a sentence you can show somebody.
**Why the container is AgentRQ's when the data is yours.** An extension is
trusted Node — it can `import fs` and write its own file today, with no
permission from anybody. This is not a capability being granted; it is a
container being offered, because writing that file yourself gives up four
things:
- **Uninstall stops meaning anything.** This is emptied on removal; your file is
not, and nothing in the app knows it is there.
- **Secrets lose their encryption.** A secret here is *refused* on a machine with
no secure storage rather than written in the clear.
- **Workspaces stop staying apart.** Key it yourself and only you know the data
is per-workspace.
- **Size is unbounded.** 64 KB per value, 512 KB per scope, **refused rather
than truncated** — an extension told its value is too large can split it; one
silently cut in half cannot know to.
---
## Asking for something: `field`, `select`, `toggle`, `form`
```js
{ type: 'form', action: 'save', submit: 'Save rules', children: [
{ type: 'field', key: 'patterns', label: 'Refuse when', input: 'multiline', value: saved },
{ type: 'select', key: 'mode', label: 'Mode', options: ['off', { value: 'on', label: 'Switched on' }] },
{ type: 'toggle', key: 'strict', label: 'Strict', value: true },
]}
```
`input` is `text`, `multiline`, `number` or `secret`. A `secret` renders masked
and is **never echoed back** into the box — put what comes back into
`ctx.storage.secret`.
Submitting calls your same `view` handler again with `context.action` set to the
form's action and `context.values` holding every key. One entry point rather
than two: a form needs no separate registration, no separate lookup and no
separate failure modes.
```js
async view(context) {
const at = ctx.storage.workspace(context.workspaceId)
if (context.action === 'save') await at.set('rules', context.values)
return draw(await at.get('rules'))
}
```
**You never style any of it.** These render as AgentRQ's own controls — that is
the only version of "an extension cannot break the design" that is a fact rather
than a request. What somebody is typing is held outside the inputs, so answering
a submit with a fresh view does not wipe a draft.
Inputs work in any view: a settings tab, a sidebar page, a task panel. They are
part of the one vocabulary, not a feature of one surface.
### `panel` — a page of your own in the side panel
```json
"provides": {
"panels": [{ "id": "notes", "label": "Notes", "entry": "panel/index.html" }]
}
```
```js
export const inject = ['panel']
export function apply(ctx) {
ctx.panel.onMessage(async (message) => ({ text: await ctx.storage.get('text') }))
ctx.panel.post({ type: 'changed', text }) // to your open page
await ctx.panel.open({ page: 'notes' }) // or { url: 'https://…' }
}
```
```js
// in panel/app.js, the page
const { text } = await window.agentrq.panel.send({ type: 'load' })
window.agentrq.panel.onMessage((message) => { … })
```
Everywhere else an extension *describes* and AgentRQ draws ([below](#drawing-a-page)).
The desktop side panel is the one place an extension draws for itself: the
page is your own HTML, CSS and JS, shipped in your package, and it can look
like anything. The panel's Pages menu lists it, and its toolbar names your
extension beside the address, so it is never mistaken for a website or for
AgentRQ.
**It is served from its own folder, and only from there.** `entry` must be an
`.html` file in a folder of its own — `panel/index.html`, never `index.html` —
and that folder is all that is served, at `agentrq-ext://<your-name>/…`. The
rest of the package, your `index.js` included, is a 404. Each extension is its
own origin, so its `localStorage` is its own.
**The page reaches nothing but you.** It runs in the panel with no Node, no
AgentRQ session, no MCP and no access to the app: `window.agentrq.panel` is
all it has, a line to this extension. Which extension a message reaches is
decided by the address of the page that sent it — not by anything in the
message — so a website in the panel has no bridge at all and one extension's
page cannot reach another extension. The page asks; your `onMessage` decides.
- `send(message)` resolves with what `onMessage` returned, or rejects with a
reason carrying your name — no handler, a handler that threw.
- Messages are plain data, at most 1 MB each way.
- `post(message)` reaches your pages that are open, and is dropped when none
is: keep state in your extension and have the page load it when it opens,
as `panel-notes` does.
- `open({ page })` names one of your declared pages; `open({ url })` takes an
`http(s)` address. Anything else is refused with your name on it.
- A page asking for the camera, a location and the rest is asked about like
any site, named by your extension.
Desktop only, like everything here. In the browser there is no side panel.
---
## Drawing a page
Extensions **describe**; AgentRQ renders. A page is a JSON spec in a closed
vocabulary, drawn with the application's own components. There is no
third-party markup in the renderer and no iframe — which is what keeps extension
UI looking like the rest of the product instead of approximating it, and keeps a
privileged origin free of anybody else's code.
```js
{
title: 'Standup',
nodes: [
{ type: 'text', tone: 'muted', value: '3 tasks moved today.' },
{ type: 'group', label: 'Waiting on somebody', children: [
{ type: 'row', label: 'Ship the release', value: 'Asked on Tuesday' },
] },
],
}
```
| Node | Fields |
|---|---|
| `text` | `value`, `tone` |
| `heading` | `value` |
| `rows` | `items` |
| `row` | `label`, `value`, `href` |
| `badge` | `value`, `tone` |
| `button` | `label`, `action`, `tone` |
| `link` | `label`, `href` |
| `empty` | `value` |
| `group` | `label`, `children` |
| `diagram` | `format`, `source`, `label` |
`tone` is one of `default`, `muted`, `positive`, `warning`, `critical`.
Limits: 200 nodes, 5 levels deep, 2000 characters per string. Longer strings are
truncated; the other two are refused.
**A `diagram` carries source, not markup.** `format` is `mermaid`; `source` is
the diagram's text, which AgentRQ draws. It is the one node whose content is not
clamped — half a diagram is a syntax error rather than a shorter diagram — so a
source longer than 20,000 characters is refused instead. There is deliberately
no node that carries HTML or SVG: an extension that could hand back markup would
be putting it on a privileged origin, which is what this whole vocabulary
exists to prevent.
**A node type nobody recognises is rejected, not skipped.** Skipping would draw a
page quietly missing whatever you thought you had written — worse for you than
being told, and worse for the user than seeing nothing.
Everything you send is treated as untrusted text. Not because extensions are
assumed hostile — they run as trusted code and could do far worse than inject
markup — but because text arriving from an extension often did not originate
there: an issue title, a commit message, a webhook payload. Only `http` and
`https` hrefs stay clickable, for the same reason a `file:` URL in a message body
is not followable.
---
## What the task you are handed actually contains
A `task-menu` entry is called with the task from the board, and **that task is a
summary**. The list endpoint sends the last message per task and nothing else, so
`task.messages` has one element however long the thread is. Counting it reports
one, always — which is what `task-stats` did before it was fixed.
The rule: **describe what you were handed, not what you assume is behind it.** If
you need the thread, ask for `task.get` in your manifest and call it with
`includeConversation: true`, then read `total` from the answer rather than the
length of `messages`, which is a page of at most five by default. `standup` does
exactly this, and the two examples side by side are the clearest statement of
what a permission buys.
That window also **starts at the oldest message**, so a page at `cursor: 0` is
the *first* of the thread and not the last. `standup` reads `total` from one call
and then asks for `cursor: total - 1` when there is more than one message —
which is two round trips, and the alternative is quietly showing the wrong
message under a heading that says otherwise.
## Configuration and secrets
```json
"config": [
{ "key": "since", "type": "number", "label": "Hours to look back" },
{ "key": "apiKey", "type": "secret", "label": "API key" }
]
```
Values arrive as `ctx.config` and as the second argument to `apply`.
**This is the half AgentRQ renders.** The manifest declares the fields, the app
draws the form and validates what comes back. For anything with structure — a
list of rules, per-workspace overrides, anything somebody builds rather than
fills in — use [`ctx.storage`](#ctxstorage--settings-you-define-the-shape-of)
and draw your own screen.
A `secret` is stored encrypted with a key only your user can read (not in the OS
keychain, which would prompt for a password), never shown back to the user or to
the catalogue, and — importantly — **refused rather than written in
the clear** if the machine has no secure storage available. Storing it anyway
would be the worst of both worlds: the user believes it is protected, and it is
sitting in a JSON file.
Your extension receives the decrypted value. Do the only correct thing with it:
send it, keep no copy, and never log it — not the value, not a prefix of it, and
not a URL it is embedded in. `digest` shows the shape, including the failure
paths that deliberately say the status code and nothing else.
---
## Publishing one
1. Add the **`agentrq-extension`** topic to the GitHub repository. That is the
whole of discovery — the desktop app searches for it.
2. Put `agentrq-extension.json` at the root.
3. Cut a release with the built asset attached, and put its SHA-256 in
`artifact.sha256`.
The digest is what makes an install honest: a git tag can be moved under one that
is already in place, and a hash cannot.
### Step 3 is not optional, and the catalogue says so
An extension is installed from the release asset its manifest names, fetched by
name and checked against that digest. Until all three of `release`, `asset` and a
real `sha256` are there, **there is nothing to install** — so the catalogue lists
the extension under **Unavailable**, with the reason written on the row, and
offers no Install button:
| What the manifest says | What the row says |
|---|---|
| no `artifact` block, or no `release` / `asset` | This extension has not published a release yet, so there is nothing to install. |
| an `artifact` whose `sha256` is absent, malformed, or all zeroes | This extension names a release but no checksum for it, so it cannot be verified. |
A placeholder digest of zeroes is the common case, because it is what a manifest
carries while the first release is still being prepared. It reads as *ready* to
its author and as *not installable* to everybody else, which is exactly why the
row says which it is rather than leaving somebody hunting for a button.
Hash the asset you actually upload, and put that digest on the default branch —
the catalogue reads `agentrq-extension.json` from the branch, not from inside the
package, so the two are allowed to differ and only the branch copy decides:
```bash
npm pack # or however the asset is built
shasum -a 256 your-extension-1.0.0.tgz # this goes in artifact.sha256
```
The asset must unpack to a **single top-level directory** — what `npm pack`
produces — because the installer strips one level when it extracts.
### Installing without publishing
**Extensions → Install from folder**, then pick the folder holding
`agentrq-extension.json`. The folder is **linked**, not copied: editing it edits
the installed extension, which is the only way developing one is tolerable. The
row says `linked folder` because it can change underneath the app, unlike
anything else that gets installed.
If the extension asks for MCP tools you are asked what it may reach before
anything is written, and any `config` fields are on the same screen. An extension
that asks for nothing installs without that step — a permission screen with
nothing on it is how people learn to click past the one that matters.
Try it with the examples:
```
Extensions → Install from folder → examples/extensions/task-stats
```
### Installing from the catalogue
Anything listed under **Available** has a release to fetch, and its row carries
an **Install** button. It asks the same permission-and-settings question the
folder install asks — and, unlike the folder install, **it asks every time**,
even when the extension wants no permission at all.
That difference is deliberate. Picking a folder is running code you already
have; installing from the catalogue is downloading a stranger's and running it
with full access to your machine. The screen is not empty in that case either —
it names the author, the repository, the version and the licence, and carries
the sentence about machine access — so there is something to read before
agreeing to it.
A release install is **copied**, not linked: the app owns what it downloaded, so
uninstalling removes it. Only a folder is left alone, because a folder is the
author's own working copy.
### Nothing here is reviewed
The catalogue is every repository carrying a topic. There is no review, no
signing, and no sandbox — an installed extension runs as this app's own code,
with the app's access to your files, your clipboard and your shell. The screen
says so above the list, and the install question says so again.
So the decision is about the author, which is why the row leads with who
published it rather than with what it does. **Install only extensions you
trust.**
The installer also supports **a git URL** cloned at a commit, for a private
repository.
### Managing what is installed
Each installed row says what it actually contributed (`1 view, 1 shortcut`) or
why it did not. **Disabled** and **not running** are different states and the row
distinguishes them: an enabled extension that threw on load is not running, and
the row says how many times it failed.
**Disable** stops it and takes its standing work down with it — a disabled
extension must not still be creating tasks at three in the morning.
**Uninstall** does that and then forgets its settings, its grant and its files. A
linked folder is unlinked, never deleted; it is your working copy.
A row for an extension that reviews tool calls also carries a **Tool calls**
switch — *not asked*, *may refuse*, *may decide* — never above what its manifest
asked for. It is the one permission that can be changed after install, because
it is the one whose misbehaviour is indistinguishable from the app itself going
wrong: an extension refusing everything looks exactly like a broken agent, and
finding that out should not require uninstalling it.
---
## Testing yours
The examples' tests are in `desktop/test/examples/` and are worth copying as a
pattern:
- **Pure functions for anything that shapes output.** `statsFor(task)`,
`buildPage(tasks)` and `summarise(workspaces, tasks)` take data and return a
description, so they test without a server anywhere near them.
- **Run every view you can produce through `normaliseView`.** The renderer
refuses a node type it does not know, and a page it will not draw is otherwise
a bug nobody finds until somebody installs your extension.
- **Test the refusals.** `{ ok: false, reason }` is a normal answer, not an edge
case: it is what a user who declined a permission, or narrowed a grant later,
will actually see.
- **If you review tool calls, test what you decline to answer.** `guardrail`'s
tests spend as much space on the commands it leaves alone as on the ones it
blocks. A reviewer that quietly narrows what its user is ever asked about is
worse than no reviewer, and only a test of the abstentions catches that.
---
## Where things live
| | |
|---|---|
| Manifest parsing and compatibility | `desktop/src/main/extensions/manifest.js` |
| Discovery through the GitHub topic | `desktop/src/main/extensions/discovery.js` |
| Installing, updating, uninstalling | `desktop/src/main/extensions/install.js` |
| Loading, and unloading completely | `desktop/src/main/extensions/host.js` |
| The registries | `desktop/src/main/extensions/registry.js` |
| The MCP boundary | `desktop/src/main/extensions/broker.js` |
| Reviewing a tool call | `desktop/src/main/extensions/tool-calls.js` |
| Config and secrets | `desktop/src/main/extensions/config.js` |
| Schedule reconciliation | `desktop/src/main/extensions/schedules.js` |
| Extensions' own settings storage | `desktop/src/main/extensions/storage.js` |
| The grant screen's rules | `frontend/src/composables/useExtensionGrant.js` |
| The view vocabulary | `frontend/src/composables/useExtensionView.js` |
| Side panel pages: serving, and the page's bridge | `desktop/src/main/side-panel/ext-scheme.js`, `bridge.js`, `desktop/src/preload/panel.js` |