This guide is for people who write PromptOps plugins. It describes what the desktop app enforces today. The
sources of truth are apps/desktop/src-tauri/src/plugins/ (runtime, broker, SDK, Studio) and
apps/web/src/app/views/admin-core/plugins/ (catalog, pages, Studio UI). When this guide and the code disagree,
the code wins.
A plugin is a folder with a manifest (promptops-plugin.json) and one JavaScript file (entry). The app runs
that file inside a sandboxed iframe and exposes a small SDK to it. A plugin never draws its own UI and never calls
the app directly: it returns plain JSON data, and PromptOps renders it with its own components. Every side effect
(network, storage, toasts, git reads, file writes, AI runs) is a request to a broker in Rust that checks the
permissions of the installed manifest.
Plugins can contribute:
- Pages: one to three entries in the side menu (
place: "menu") or at the top of the session sidebar (place: "sidebar"). Categorypagesonly. - Session buttons: up to two buttons per plugin, any category, with the
session:buttonspermission. - A section contract for their category (
tasks,code,data,providers,context). See Limits for what this edition calls. In this edition thetaskssection is hidden: it is not in the catalog or in the Studio.
The Plugin Studio creates this layout. Only the manifest and the entry file are read by the runtime.
my-plugin/
promptops-plugin.json manifest (required)
src/plugin.js entry file (required; any relative `.js` path, see the `entry` rule)
fixtures.json sample HTTP answers (page, sidebar and task-source templates)
test/plugin.test.mjs tests run with `node --test` (the button templates ship one; add your own for pages)
CLAUDE.md, AGENTS.md, GEMINI.md, PLUGIN_GUIDE.md instructions for coding agents (Studio)
File name: promptops-plugin.json, at the root of the plugin folder.
| Field | Type | Rule enforced by the runtime |
|---|---|---|
id |
string | <handle>.<slug>. Each part 2 to 60 characters of a-z, 0-9, -; no leading or trailing hyphen; exactly one dot. Studio drafts use local.<slug> |
name |
string | Stored truncated to 80 characters |
version |
string | 1 to 40 characters of letters, digits, ., - and +, starting with a letter or digit, with no ... For local folders a version that does not have this form becomes 0.0.0-dev; registry installs are refused unless the registry entry's version has it |
category |
string | One of tasks, code, data, providers, context, notify, agent, pages. The runtime does not check this list (any string is stored, cut to 20 characters), but only pages and the five section names are ever called. For registry installs it must equal the registry entry's category |
entry |
string | Relative path to a .js file: no .., no leading / or ., only A-Za-z0-9_./-, at most 200 characters. File at most 2,000,000 bytes, UTF-8 |
permissions |
string[] | Required (may be empty). See Permissions |
contributes.pages |
object[] | pages category only, 1 to 3 items: { id, title, icon?, place? } |
contributes.sessionButtons |
object[] | Requires session:buttons; 1 to 2 items: { id, title, icon?, target?, place? } |
config.fields |
object[] | Settings form: { key, type, label?, required?, default?, options? }. Not validated by the runtime. The form draws secret as a password box, boolean as a checkbox, select as a dropdown built from options and number as a number box; every other type, including multiselect and url, is a plain text box. required is not enforced yet |
repository |
string | Optional. For local folders it is kept only if it is https://github.com/<owner>/<repo> and is used to show the owner and avatar; registry installs show the registry entry's repo_url instead. Never a download source |
providers |
string[] | Optional, display only. Up to 12 distinct ids, 2 to 40 characters of a-z, 0-9, -, no leading or trailing hyphen. Invalid ids are dropped |
description |
string | Optional. The catalog shows the registry entry's description |
Item rules:
id(page or button): 2 to 60 characters ofa-z,0-9,-, unique within the list.title: required, 1 to 40 characters, no control characters.icon: a Tabler icon name such asti-file-pencil(defaultti-puzzle).- Page
place:menu(default) orsidebar. - Button
target:main,subagentorboth(default); validated but not used yet: the button appears under every agent terminal. Buttonplace:terminal(default, the toolbar under every agent terminal),header(session header),composer(spawn composer tools),runtime(the AI Squad, Goal and Worker row).
A pages plugin with one menu entry and no permissions:
{
"schemaVersion": 1,
"id": "local.hello-page",
"name": "Hello page",
"version": "0.1.0",
"category": "pages",
"entry": "src/plugin.js",
"permissions": [],
"contributes": {
"pages": [{ "id": "hello", "title": "Hello", "icon": "ti-hand-stop" }]
}
}// src/plugin.js
let clicks = 0;
const view = () => ({
title: 'Hello',
blocks: [
{ type: 'text', text: `Clicked ${clicks} times.` },
{ type: 'actions', items: [{ id: 'click', label: 'Click', style: 'primary' }] },
],
});
promptops.pages.register({
async open(pageId, context, sdk) { return view(); },
async event(pageId, event, sdk) {
if (event.type === 'action' && event.id === 'click') clicks++;
return view();
},
});A session button needs the permission and the declaration:
"permissions": ["session:buttons"],
"contributes": { "sessionButtons": [{ "id": "run", "title": "Summarize", "icon": "ti-player-play" }] }promptops.session.registerButtons({
async action(buttonId, ctx) {
return { prompt: 'Summarize what you did so far in 5 bullets.', target: ctx.paneAgentId };
},
});| Contribution | Where it appears | Called as |
|---|---|---|
Page, place: "menu" |
Under Plugins in the side menu | pages.open(pageId, context), pages.event(pageId, event) |
Page, place: "sidebar" |
Top of the session sidebar, above folders and workspaces | Same. With sessions:read, context.workspace / event.workspace carry a snapshot of folders, workspaces, sessions and agents (ids, names, providers and status; no paths, prompts or output) |
Button, place: "terminal" |
Toolbar under each agent terminal | session.action(buttonId, ctx) |
Button, place: "header" |
Session header | Same |
Button, place: "composer" |
Spawn composer tools | Same |
Button, place: "runtime" |
AI Squad / Goal / Worker row | Same |
Pages. A page is { title, subtitle, blocks }. Block types: heading, text, notice, divider, field,
actions, output, list, table, stats, progress, columns, floorplan. Field kinds: text, textarea,
select, choice, toggle, repo. Unknown blocks and kinds are dropped; all text is rendered as text. event is
{ type: 'action' | 'change', id, values }. A repo field lets the person pick a folder; you receive
{ handle, name, branch } and never the path. The full block reference is in
apps/desktop/src-tauri/src/plugins/studio_templates/CONTRACTS.md.
Session buttons. ctx is { sessionId, sessionName, provider, status, workspaceName, paneAgentId, agents },
plus composerText only with composer:read. Return one proposal: { insert }, { prompt, target },
{ spawn: { prompt, provider, mode, name } }, { panel: view } or { toast }. PromptOps carries it out; prompts
follow the confirmation policy the person sets for that button.
Inside the iframe, the global promptops exposes sdk, onActivate(fn), actions.register(id, fn),
events.on(name, fn) and one register* function per section (tasks.registerSource, code.registerHost,
data.registerExplorer, providers.registerUsage, context.registerProvider, pages.register,
session.registerButtons). Every contract method gets sdk as its last argument.
| SDK call | Permission | Notes |
|---|---|---|
sdk.http.fetch(url, { method, headers, body }) |
net:<host> for that exact host |
HTTPS, port 443, domain names only. Methods GET, POST, PUT, PATCH, DELETE, HEAD. Body is a string. Returns { status, ok, headers, body }. Redirects are not followed |
sdk.storage.get(key) / set(key, value) / remove(key) |
storage |
Private JSON map, keys up to 200 bytes, 1 MB total |
sdk.config.get() |
none | Non-secret settings plus __secretsSet (names of secrets that have a value) |
sdk.notify.toast(message) |
notify |
Truncated to 300 characters, control characters removed |
sdk.prompt.propose(text, title) |
prompt:propose |
Shown to the person as untrusted text; nothing is sent without them. Text up to 8,000 characters, title up to 120; control characters other than line breaks and tabs are removed |
sdk.git.status(repo) / log(repo, limit) / diff(repo, { staged }) |
git:read |
repo is a handle from a repo field |
sdk.workspace.writeFile(repo, path, content) |
workspace:write |
Only paths under docs/ (subfolders allowed, no hidden segments) ending in .md, .markdown or .txt; at most 500,000 bytes; the person confirms with the full content |
sdk.ai.generate({ prompt, model }) or sdk.ai.generate(prompt) |
ai:generate |
Runs the person's Claude CLI with no tools in an empty folder. Prompt up to 200,000 characters; one run at a time, 30 per hour; confirmed every time. Returns { text, truncated }. Not available on Windows yet. ai.generate and ai.benchmark share the one-run-at-a-time slot, and a declined request still counts toward the 30 |
sdk.ai.benchmarkModels() / sdk.ai.benchmark(modelIds) |
ai:benchmark |
1 to 12 models, 6 runs per hour, one confirmation per series. Progress arrives as the benchmark.progress event |
sdk.pages.update(pageId, view) |
none | Page must be declared; view at most 256,000 bytes |
sdk.log(...values) |
none | One line in the audit log, 500 characters, control characters removed |
Other permission rules:
git:read,workspace:write,ai:generateandai:benchmarkare accepted only for thepagescategory.sessions:readdelivers the session events (session.*,turn.completed,approval.requested) and the sidebar workspace snapshot.tasks:readdeliverstask.*events.composer:readaddscomposerTextto session button context.tasks:writeis required for the write methods of thetaskscontract (setStatus,createTask,updateTask,deleteTask,addComment): without it the app refuses the call.agents:proposeis not supported: a manifest that asks for it fails to install or link.- Any other permission string makes install or linking fail.
Confirmations that the person declines reject with an error starting with user_denied; no answer within three
minutes rejects with confirm_timeout. Treat both as normal outcomes.
Declare a config.fields entry with type: "secret". The person enters the value in the plugin settings; it is
stored by Rust and never returned to the UI or to the plugin. In requests, write placeholders:
{{secret:key}}in a header value, or in the URL path or query. Never in the host, never in the body.{{basic:user:secret}}in a header value only: the broker buildsbase64(user:password).useris a setting or a secret,secretmust be a secret, and the user value cannot contain:.
Placeholders require the secrets permission. A missing secret fails the request instead of sending the
placeholder. The host is validated before and after substitution and must not change. Secret values (6 characters
or more) and built Basic values are replaced with [secret] in response headers and bodies.
net:<host> must be a public domain name: no IP addresses, wildcards, localhost, single-label names, or the TLDs
local, internal, lan, home, corp. At request time the broker resolves DNS and refuses the call if any
address is IPv4 loopback, private, link-local, CGNAT (100.64.0.0/10), multicast, broadcast, documentation, IETF
protocol assignments (192.0.0.0/24), benchmarking (198.18.0.0/15), 0.0.0.0/8 or 240.0.0.0/4; or IPv6
loopback, unspecified, multicast, unique-local (fc00::/7), link-local (fe80::/10), site-local (fec0::/10),
Teredo (2001::/32) or documentation (2001:db8::/32). IPv6 addresses that carry an IPv4 address are judged by
the IPv4 inside: IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d), NAT64 (64:ff9b::/96) and 6to4
(2002::/16); the NAT64 local-use prefix 64:ff9b:1::/48 is always refused. It then pins the connection to the checked address. Plugins cannot set host, content-length, connection, transfer-encoding, upgrade,
proxy-authorization, cookie, te, expect, or any sec-* / proxy-* header.
A plugin that holds git:read must get the person's consent the first time it contacts each host during an app
launch. A refusal sticks until the plugin is turned off and on again.
- Plugin code runs in an iframe with
sandbox="allow-scripts"and a CSP ofdefault-src 'none'; script-src 'unsafe-inline'.fetch,XMLHttpRequest, storage, cookies and navigation do not work. - The iframe lives in a hidden
plugin-hostwebview that is listed in no Tauri capability, so it cannot invoke app commands. It talks to Rust only through thepo-plugin://protocol with a per-launch token. - The plugin is identified by the iframe a message comes from, not by anything it claims.
- The broker reads permissions from the installed record, rate-limits each plugin to 120 calls per minute, and logs every call (method, target, outcome; never bodies or secrets) to a local audit log.
- Registry installs download the manifest and entry from
raw.githubusercontent.comat a pinned 40-character commit. The manifest must equal the approved one and the entry's SHA-256 must match. The hash is checked again on every load; a mismatch disables the plugin. - An update keeps the plugin enabled only if it asks for no new permissions. Otherwise it waits for the person to approve the new permissions in the catalog. Automatic updates apply only updates that add no permissions.
- Plugins never see terminal output, absolute paths or the app's stored data. They see the composer draft only with
composer:read, and repository-relative paths, commits and diffs only withgit:readon a folder the person picked. - Secrets are encrypted with AES-256-GCM using a key kept in the system keychain. Without a keychain the file stays
in plain text, with
0600permissions on macOS and Linux, and the settings show that status. Development builds skip the keychain unlessPROMPTOPS_PLUGIN_KEYCHAIN=1.
- Scaffold. In the Studio, pick a zone: terminal buttons, session header, spawn composer, Squad/Goal/Worker row,
menu page or session sidebar. The app creates
~/promptops-plugins/<slug>/from a bundled template, sets the id tolocal.<slug>, writes the agent instruction files, runsgit init, and marks the folder as trusted for Claude Code. The Studio never overwrites a folder: each new draft gets a free folder (<slug>,<slug>-2, …), and a non-empty folder without a manifest is refused. - Let an agent write it. The Studio opens a terminal in the folder with the coding agent you choose (Claude Code,
Codex, Gemini CLI, and others in the list). The agent reads
CLAUDE.md/AGENTS.md/PLUGIN_GUIDE.md. - Reload. The Studio enables developer mode and links the folder with the same sandbox and broker as published plugins. Press Reload to relink and redraw the preview. The Studio also reloads on its own when the agent goes quiet with changed files or asks you to press Reload.
- Check. The Checks tab reports manifest errors,
node --testresults (60-second limit), and calls the broker refused. Failures can be sent back to the agent.
Without the Studio, enable developer mode on the Plugins page and link any folder that contains a manifest. A local link is refused if a registry version of the same id is installed.
The catalog is a static index fetched without credentials from
https://raw.githubusercontent.com/shellonback/promptops-plugin-templates/main/registry/official.json. To share a
plugin:
- Give it a permanent
idwith your own handle (notlocal.), and push the folder to a public GitHub repository. - Open a pull request to
https://github.com/shellonback/promptops-plugin-templatesthat adds an entry toregistry/official.json.
The app lists an entry only if it has status: "published", plugin_id, manifest, commit_sha and
bundle_sha256, and installs it only if it also has version and repo_url. category and name must be present,
and category must match the manifest (the app shows the entry's name); the manifest id must equal plugin_id.
source_dir is optional (subfolder of the repository). The manifest in the entry must equal
(as parsed JSON) the promptops-plugin.json at that commit. Each new version updates the plugin's entry with a new version, commit_sha and bundle_sha256; the app offers
the update when bundle_sha256 changes.
| Limit | Value |
|---|---|
| Entry file and downloaded files | 2,000,000 bytes |
| Broker calls | 120 per minute per plugin |
| HTTP request body / response body | 1,000,000 / 2,000,000 bytes; 20 s timeout |
| Storage | 1,000,000 bytes per plugin |
| Settings | 100,000 bytes; secret names 100 bytes, values 8,000 bytes |
| Pages per plugin / session buttons per plugin | 3 / 2 |
| One message between the plugin and the app (an SDK call or a contract reply) | 4,000,000 bytes |
| Page view | 200 blocks; sdk.pages.update views at most 256,000 bytes |
| Contract call timeout | 600 s for pages, 30 s otherwise |
| Confirmation timeout | 180 s |
| AI generation | 30 per hour, one at a time, 300 s timeout |
| Benchmarks | 6 per hour, 12 models per run |
| Git diff | 200,000 bytes; status 500 files; log 100 entries |
In this edition the app calls only the pages and session-button contracts during normal use. The tasks, code,
data, providers and context contracts can be exercised only from the test panel on the Plugins page; no other
screen calls them yet. The session and task events (session.*, turn.completed,
approval.requested, task.status_changed) and actions.register are wired in the runtime but no screen triggers
them yet (benchmark.progress does work), and the agent section (skills, quick actions) is not run by the app.