Writing a plugin
A plugin gives the Toolbox your team’s buttons: logins, deploys, shells into pods,
proxies to keep running, lists like “which AI skills are on”. It’s a folder (usually a
git repo) with a nojace.json and the scripts it calls.
my-plugin/
nojace.json
scripts/
envs.sh
status.sh
Quick start
The quickest way is to let an agent write it, from the app:
- Open the Toolbox and press + Plugin.
- Under Create your own, give it a name, choose where its folder goes, and pick the agent to build it with: Claude Code, Codex or OpenCode.
- Press Create and open. Nojace makes the folder with a hello-world example, gives the agent the plugin-writing skill, and opens a session there. The agent explains the example and asks what the plugin should do.
- Tell it the commands you run often, as you’d say them: “a button for
mycli deploywith a choice of environment”, “keepkubectl port-forwardrunning”, “a list of my open PRs”. - The plugin shows up in the Toolbox. Approve it to run it: nothing in a plugin runs before that, and any change asks again.
To add someone else’s plugin instead, paste its link into + Plugin: a zip, a git URL, a folder, or a team’s plugin list.
Principles
- The plugin says what exists; the app decides how it looks. You describe actions, services and lists. Layout, confirmations and safety are the app’s job, the same for every plugin.
- JSON describes, commands decide. No
ifor loops in JSON. If you need logic, write a script and have it print JSON. - Read live lists, don’t hardcode. Envs, services, skills, pods: ask the CLI that knows
(
mycli login status,mycli services --json). Hardcode only what no command can tell you. - Your team’s CLI does the work. A button runs exactly what you’d type in a terminal. The plugin adds a place to click, not a second implementation.
The file
{
"$schema": "https://nojace.com/nojace.schema.json",
"format": 1,
"name": "My team",
"actions": [ … ],
"lists": [ … ],
"services": [ … ]
}
$schema gives your editor autocomplete and flags typos as you type
(nojace.schema.json). The app checks every plugin against the same
schema: a misspelt field ("keeplog") is an error with a “did you mean”, and a plugin with
errors can’t be approved.
description, icon (an emoji) and requires (the CLIs it needs, e.g. ["gh"], or
["docker|podman"] when either will do) are shown when people browse plugins (+ Plugin),
with ✓ next to the CLIs they already have.
platforms (["macos"], ["linux"]) is for a plugin that only works on some systems, e.g.
one built on a Mac-only CLI. Left out, it’s for all of them. Elsewhere it isn’t offered, and
if added anyway it’s shown as not for this system and nothing in it runs. Nojace on Linux
puts an open on the PATH that hands files and links to the desktop (xdg-open), so a
plugin’s open https://… works on both.
format is the version of this format the plugin is written for (1). A newer app reads
older plugins; an older app refuses a newer plugin instead of half-working.
The Toolbox shows, per plugin: actions in their groups, services, then lists
of their own, each in its own card. (apps, methods, envs and links exist for an
apps × environments table; most plugins won’t need them.)
Actions: buttons
{ "id": "gcloud-login", "group": "Login", "row": "gcloud", "label": "login",
"run": "gcloud auth login", "keepLog": false }
| Field | What it does |
|---|---|
id |
Stable id. Your saved buttons and needs refer to it; don’t rename it lightly. |
label |
The button’s text. |
title |
What the run is called in the log and history: "deploy {{item}} {{service}}". |
run |
The command. Runs through your login shell, in the plugin’s folder, in a terminal you can type into. |
group |
A card heading: “Login”, “Deploy”. |
row |
A labelled row inside the card: “AWS CLI” → [dev] [prod]. |
inputs |
Things to pick before running (see below). |
each |
One button per item: a list, or { "run": "…" } printing one. |
check |
A ✓/✗ badge next to the button (see below). |
needs |
Checks that must pass first; offers to fix them (see below). |
tone: "danger" |
Red, and running it means typing its name. Use for anything on production. |
keepLog: false |
Its output isn’t kept after it ends. Use for logins and shells. |
cwd: "app" |
Run in the app’s code folder instead of the plugin’s (apps only). |
Every action shows its exact command and asks before it runs. You can’t turn that off.
each: one button per item
{ "id": "awscli", "group": "Login", "row": "AWS CLI", "label": "AWS CLI login",
"each": { "run": "./scripts/aws-envs.sh" },
"run": "mycli login awscli {{item}}" }
aws-envs.sh prints [{"value": "dev"}, {"value": "prod", "tone": "danger"}] and you
get [dev] [prod], the second in red. An item can carry:
| Item field | |
|---|---|
value |
Fills {{item}}. |
label |
Shown instead of value. |
hint |
Shown on hover / next to it. |
tone |
"danger" for this item only. |
vars |
More values for the command: {"cluster": "prod"} → {{cluster}}. |
inputs: things to choose
"inputs": [{ "id": "service", "options": { "run": "./scripts/services.sh {{item}}" } }],
"run": "mycli deploy {{item}} {{service}}"
| Input field | |
|---|---|
id |
Fills {{id}}. |
options |
A list (["dev", "prod"]), or { "run": "…" } printing one. Short lists show as buttons, long ones as a search box. Values not listed can be typed. |
default |
Chosen at first. |
multi |
A list of values; fills the command as separate words. (Services.) |
emptyLabel |
What an empty multi means: “all services”. |
An options command can use other inputs ({{item}}, {{env}}): it’s run again when they change.
From the dialog you can Save as button: your own one-click button with these choices,
kept in ~/.nojace/plugins.json, not in the plugin.
check: a badge
"check": { "run": "./scripts/login-status.sh", "key": "{{item}}", "every": 120 }
The command prints {"ok": true, "text": "0h52m left"}, or a map of them
({"dev": {…}, "prod": {…}, "gcloud": {…}}) that key picks from. One command serves every
button that uses it, run once. Checks run every every seconds (default 120) while the
Toolbox is open, and again right after any of the plugin’s actions finishes.
needs: fix it first
"needs": ["awscli:{{item}}"]
Each entry is an action id, optionally :item. If that action’s check shows ✗, pressing
the button says “This needs: ✗ AWS CLI prod, not logged in → [AWS CLI first]”,
runs the login, then comes back to what you wanted. Services take needs too.
Services: things that keep running
{ "id": "proxy", "run": "mycli proxy {{cluster}} {{services}}",
"inputs": [
{ "id": "cluster", "options": ["dev", "prod"], "default": "dev" },
{ "id": "services", "multi": true, "options": { "run": "./scripts/services.sh" }, "emptyLabel": "all services" }
],
"confirm": "Port-forward to {{cluster}}: production.", "confirmWhen": { "cluster": ["prod"] },
"needs": ["gcloud-login"], "ports": [13000] }
Start / Stop / Log, with a dot for its state. Changing inputs while it runs shows
Restart to apply. Stop sends Ctrl+C and waits up to 4 seconds before killing it,
so a CLI can clean up (close port-forwards); handle SIGINT if yours needs to.
autostart: true starts it when the plugin loads. People can save presets of a
service’s inputs (“usual”).
Lists: a table from a command
{ "id": "skills", "label": "Manage AI Skills", "run": "./scripts/skills.sh",
"on": "mycli skills enable {{item}} global {{column}}",
"off": "mycli skills disable {{item}} global {{column}}" }
The command prints:
{ "columns": ["claude", "codex"],
"rows": [{ "value": "scientist", "hint": "Build dashboards…", "on": { "claude": true } }] }
Each row × column gets a ●/○ toggle that runs on or off. Instead, or as well,
actions puts buttons on each row ({{item}} is the row, its vars fill the rest):
{ "id": "last-pods", "label": "Last pods", "group": "Exec into a pod", "run": "./scripts/last-pods.sh",
"actions": [{ "id": "same", "label": "same pod", "run": "mycli exec {{cluster}} {{item}} --continue", "keepLog": false }] }
With group, the rows sit inside that action card; without, the list is its own
foldable card (open: true to start unfolded). Lists reload after any of the plugin’s
actions finishes. "every": 60 also reloads it every minute while the Toolbox is showing
(at least 15 seconds), keeping what’s shown until the new rows arrive.
Notifications. A script can show a Mac notification by writing a JSON file
({"title": "…", "body": "…"}, ending in .json) into the folder in $NOJACE_NOTIFY_DIR;
Nojace shows it as its own notification and deletes the file. Add "plugin": "<your plugin's folder>" and it’s also counted on your plugin’s chip and the Toolbox switch (orange) until
you look. See plugins/calendar.
display: "cards": cards with filters and grouping
For tickets, pull requests, incidents: anything people scan, filter and group.
{ "id": "board", "label": "Your board", "run": "./scripts/issues.sh", "display": "cards",
"actions": [{ "id": "open", "label": "open", "run": "gh issue view {{number}} -R {{repo}} --web" }] }
Rows may add ref (a short id like api#12, shown small above the title), status (a coloured pill and a filter chip), group and tags. The output
may add groups (headers in order, each with a hint and a link; empty ones still show)
and view ({"groupBy": "group" | "status" | "tag" | "none", "folded": true}) to start with:
{ "columns": [],
"rows": [{ "value": "api#12", "label": "Retry webhooks", "ref": "api#12", "hint": "alice",
"status": "QA", "group": "product#5", "tags": ["api"], "vars": {"repo": "org/api", "number": "12"} }],
"groups": [{ "value": "product#5", "label": "◆ Webhooks", "hint": "Development · 1/3 done", "link": "https://…" }],
"view": { "groupBy": "group", "folded": true } }
People get a filter box, status and tag chips, a group-by choice and fold / unfold; what they pick is remembered.
Pages: your own web page
"pages": [{ "id": "chess", "label": "Chess", "file": "chess/index.html", "icon": "♟️",
"description": "Play the computer" }]
A page is an HTML file in the plugin (with its own scripts, styles and images next to it),
opened inside the Toolbox. It runs sandboxed: its scripts work, but it can’t call the
app, read files, run the plugin’s commands or keep storage. Use it for things that want
their own UI (a game, a chart); use actions and lists for anything that runs commands.
Load scripts with plain <script src> (not type="module"). See plugins/games.
Settings: choices made once
For things a person picks once for the whole plugin (an account, a role, a region), not per
click. They show under ⚙ on the plugin’s chip, are kept in ~/.nojace/plugins.json, and
any command uses them as {{setting.<id>}}. Changing one reloads the plugin’s lists and checks.
"settings": [
{ "id": "role", "label": "Board view", "options": ["developer", "pm"], "default": "developer" },
{ "id": "region", "label": "Region", "options": { "run": "./scripts/regions.sh" } },
{ "id": "verbose", "label": "Verbose output", "type": "toggle" },
{ "id": "team", "label": "Team", "type": "text", "hint": "As in the team's GitHub slug" }
]
type is choice (with options, a list or a command printing one), text, or toggle
(fills true / false); without it, a setting with options is a choice, else text. Don’t
put secrets in settings: they’re stored in plain text.
Writing text in the dialog
An input with "multiline": true is a text box, for a note, a message, a description. Its
default can be a command whose output starts the box, so editing something in place is
one action:
{ "id": "edit", "label": "edit",
"inputs": [{ "id": "text", "label": "text", "multiline": true,
"default": { "run": "./scripts/notes.sh show {{item}}" } }],
"run": "./scripts/notes.sh save {{item}} {{text}}" }
The text reaches the command as one shell-quoted argument; ⌘Enter runs. A list’s row buttons take inputs the same way (the example above is one).
Placeholders
{{name}} in a command is filled with a shell-quoted value, so a value can’t break
out into another command. A list (multi) becomes separate words. Unknown names become ''.
{{item}} |
the button’s item (each) or the list’s row |
{{column}} |
the list’s column (toggles) |
{{<input id>}} |
what was chosen |
item / row vars |
e.g. {{cluster}} |
{{plugin.dir}} |
the plugin’s folder |
{{setting.<id>}} |
your value for one of its settings |
Scripts
Scripts that feed the app (each, options, check, lists) must:
- print JSON on stdout, nothing else; errors go to stderr and a non-zero exit (the app shows the last lines of stderr);
- never prompt: they run in the background with no terminal; if something needs a
login, fail with a clear message and let a
needsaction fix it; - finish within 60 seconds (they’re killed after);
- be quick and safe to run often: checks run every couple of minutes;
- prefer the CLI’s
--jsonoutput over parsing its text; when you have to parse text, strip colour codes and match loosely.
Action and service commands are different: they run in a real terminal, so they can prompt, show progress, open a browser.
Safety rules
- Approving covers every file in the plugin, scripts included. Any change, by a pull or by hand, needs approving again, with the changed files listed.
- Mark anything that touches production
"tone": "danger", on the action or on the item (eachitems, list rows). - Use
"keepLog": falsefor anything that prints tokens, credentials or URLs with secrets, and for interactive shells. - Use
needsfor logins, so people get “log in first” rather than a failed deploy. - No secrets in the plugin. Scripts read them from where the CLI keeps them.
- Commands from a script’s output never run: output only fills placeholders, and values are quoted.
While you write one
- It reloads by itself. Save
nojace.jsonor a script and the Toolbox shows the new version within a few seconds, marked as changed, to approve. - Problems show up in place with the path and what’s wrong
(
actions[3] (deploy).needs: should be a list). - test next to each data command in the review panel (≡) runs it and shows what it printed (pretty JSON, or “not JSON” with the raw text). Placeholders are empty there, so you also see how the script copes with that. Available once the plugin is approved.
Sharing plugins: zip links and plugin lists
The plugin file can be called plugin.json instead of nojace.json (same format), for a
plugin that shouldn’t carry the app’s name. coderapp.json, its name from before the app was
renamed, still works too.
One zip, one plugin or several
Zip the plugin’s folder and share the zip’s link: anyone pastes it into + Plugin. The plugin file goes at the zip’s root, or in its one top folder.
A zip can also hold several plugins, each in its own folder with its own plugin file; each is added as its own plugin (its own tab, its own approval), named after its folder:
team-plugins.zip
deploys/plugin.json → the "deploys" plugin
deploys/scripts/…
board/nojace.json → the "board" plugin
cd team-plugins && zip -r ../team-plugins.zip deploys board
Plugins are unpacked into ~/.nojace/plugins/<name>/. Zips with absolute paths, .. or
links in them are refused.
Updates
⟳ on a zip plugin fetches its zip again and updates every plugin in it; a plugin that’s new in the zip is added too (waiting for approval). Plugins dropped from the zip stay until you remove them. Pasting a newer link for the same zip updates it the same way.
Publish zips as GitHub release assets and updates take care of themselves: for a link to
one release (…/releases/download/v1.2.0/team-plugins.zip), ⟳ takes the newest release’s
file of the same name; …/releases/latest/download/team-plugins.zip always is the newest.
Release links download through the GitHub login on the computer (gh auth login), so a
private repo works for anyone with access to it.
Any change to a plugin’s files asks for approval again before it runs, however it arrived.
Plugin lists
To offer plugins to pick from, with versions, publish a plugin list: a JSON file next to the zips, e.g. as assets of the same release. Add its link once (+ Plugin → “Link to a plugin list”) and its plugins show as their own section, with Add, and Update when the list has a higher version.
{
"name": "My team",
"plugins": [
{ "name": "Deploys", "icon": "🚀", "description": "…", "requires": ["kubectl"], "platforms": ["macos", "linux"],
"version": "1.4.0", "url": "deploys.zip", "sha256": "…" }
]
}
url may be relative to the list’s link. With sha256 (of the zip), a download that
doesn’t match isn’t installed.
Releasing, step by step
- Bump
versionfor each changed plugin in the list (if you publish one). - Zip the plugins: one zip per plugin, or one zip of several.
- With a list, put each zip’s
sha256in it (shasum -a 256 *.zip). - Make a GitHub release with the zips (and the list) as assets:
gh release create v1.4.0 *.zip team-plugins.json. - Tell people the link once:
…/releases/latest/download/team-plugins.jsonfor a list, or…/releases/latest/download/team-plugins.zipfor a zip. They get every later release with ⟳ or Update.
Where things live
| The plugin | its repo or folder; git ones are cloned into the app’s data folder and pulled at launch / ⟳; zip ones are unpacked into ~/.nojace/plugins/ |
| Your side of it | ~/.nojace/plugins.json: approvals, saved buttons, service choices, presets |
| Run history | the app; output of keepLog: false runs is never stored |
Checklist
-
"$schema","format": 1and aname; no problems shown in the Toolbox - stable
ids - envs / services / items come from commands, not lists in JSON
- production is
danger; logins and shells arekeepLog: false - logins are
needsof what depends on them - every script: JSON out, errors to stderr, no prompts, < 60 s
- tried each button once, approve shows no surprises
Examples: the Containers and GitHub plugins that ship with the app (+ Plugin → Comes with the app).
Every field, with its type: Plugin reference.