Workbench plugin development
A Cheers workbench plugin is one sandboxed HTML file that renders a channel file
as an interactive UI β a checklist, a paper tracker, a review board β and writes edits back.
No build step, no SDK: an embedded JSON manifest plus a small postMessage protocol.
1. Mental model: renderers are CSS for files
- A file is pure content (Markdown first). It never declares which renderer to use.
- A renderer plugin carries all the judgment β what it accepts, how it parses, how it draws β and can save edits back to that one file.
- An environment template only seeds initial files; it never references a renderer.
Renderers can be narrow ("markdown checklists with - [ ] lines") or broad. Small, focused
renderers coexist, like CSS rules that each match a specific selector.
2. Anatomy of a plugin
A single .html file containing:
- an embedded manifest β parsed with
DOMParseron upload, never executed; - your rendering logic β vanilla JS or bundled framework code, all inlined;
postMessagecalls to talk to the host.
It runs in an <iframe sandbox="allow-scripts"> with an opaque (null) origin:
it cannot read the host's token, cookies, or localStorage, and it can only reach the one file the host assigns to it.
<script type="application/json" id="cheers-plugin">
{
"id": "md-checklist",
"title": "Markdown checklist",
"renderers": [
{ "id": "checklist", "title": "Checklist", "match": { "format": "markdown" } }
]
}
</script>
| Manifest field | Meaning |
|---|---|
id | Globally unique plugin id (primary key on install) |
title | Human-readable name |
renderers[] | Renderers this plugin provides (one or more) |
renderers[].match.format | markdown / json / toml / xml / text |
renderers[].match.glob | Optional path narrowing, e.g. "reviews/*.md" |
renderers[].match.requireAll / requireAny | Content must contain all / at least one of these substrings |
renderers[].match.jsonHas | JSON only: parsed object must have all these top-level keys |
match is a cheap host-side pre-filter
(your sandbox is not started). The final verdict is yours at runtime: when cheers:render arrives,
actually parse the content β if it doesn't fit, reply cheers:unsupported {reason}.3. The postMessage protocol
| Direction | type | Payload | When |
|---|---|---|---|
| plugin β host | cheers:ready | β | iframe loaded; "assign me work" |
| host β plugin | cheers:render | { path, format, content, version, rendererId } | Assigns one file; re-sent when it changes externally |
| plugin β host | cheers:unsupported | { reason? } | Runtime verdict: can't render this content |
| plugin β host | cheers:save | { content } | Write the assigned file back |
| host β plugin | cheers:saved | { ok, version, error? } | Optimistic-lock result; on ok, update your version |
| plugin β host | cheers:resource | { reqId, resource, params } | Read-only channel info (whitelisted) |
| host β plugin | cheers:resource:result | { reqId, ok, data|error } | Resource read result |
- Single-file capability. One
cheers:render= one file. You can only render and save thatpath. - Optimistic locking. On a version conflict,
cheers:saved.ok=falseand the host re-sends a freshcheers:renderβ just re-render. - Safe rendering.
contentis untrusted (it may come from a bot or another member). UsetextContentor controlled form values β never concatenate intoinnerHTML.
4. Minimal skeleton
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<script type="application/json" id="cheers-plugin">
{ "id": "my-plugin", "title": "My plugin",
"renderers": [{ "id": "main", "title": "My renderer", "match": { "format": "markdown" } }] }
</script>
</head>
<body>
<div id="root"></div>
<script>
var ASSIGN = null;
window.addEventListener("message", function (e) {
var m = e.data; if (!m || typeof m !== "object") return;
if (m.type === "cheers:render") {
ASSIGN = m;
// 1. parse m.content β if it doesn't fit, bail out:
// parent.postMessage({ type: "cheers:unsupported", reason: "β¦" }, "*");
// 2. draw UI (textContent only for untrusted text)
// 3. on edit:
// parent.postMessage({ type: "cheers:save", content: newContent }, "*");
} else if (m.type === "cheers:saved") {
if (m.ok) ASSIGN.version = m.version;
}
});
parent.postMessage({ type: "cheers:ready" }, "*");
</script>
</body>
</html>
5. Working examples
Ready to upload as-is (Settings β Workbench extensions β upload the .html):
{"papers": []} JSON β status dropdown, star ratings.
π§βπ» code-review
Markdown review findings with P0/P1/P2 severity badges and progress bar.
Matching environment templates (Workbench drawer β upload the .json):
md-demo Β·
lit-review Β·
code-project.
Full index: examples README.
6. Security model
- Opaque origin β
sandbox="allow-scripts"withoutallow-same-origin: the plugin cannot steal host credentials. - Single-file capability β the host proxy pins the plugin to the one assigned path; server-side channel-role auth is unchanged.
- Inert manifest β parsed with
DOMParser, never executed.
7. Install & bind
- Install (admin): Settings β Workbench extensions β upload the
.html. - Bind: open a file in the Files panel and pick a renderer from the dropdown; the choice
persists per path in
.workbench.json. Candidates are ordered CSS-style β the most specificmatchfirst β and the user's explicit choice always wins. Bindings never live in the file itself.