Get startedInstallWrite a pluginPlugin reference

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:

  1. Open the Toolbox and press + Plugin.
  2. 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.
  3. 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.
  4. Tell it the commands you run often, as you’d say them: “a button for mycli deploy with a choice of environment”, “keep kubectl port-forward running”, “a list of my open PRs”.
  5. 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

  1. 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.
  2. JSON describes, commands decide. No if or loops in JSON. If you need logic, write a script and have it print JSON.
  3. 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.
  4. 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:

Action and service commands are different: they run in a real terminal, so they can prompt, show progress, open a browser.

Safety rules

While you write one

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

  1. Bump version for each changed plugin in the list (if you publish one).
  2. Zip the plugins: one zip per plugin, or one zip of several.
  3. With a list, put each zip’s sha256 in it (shasum -a 256 *.zip).
  4. Make a GitHub release with the zips (and the list) as assets: gh release create v1.4.0 *.zip team-plugins.json.
  5. Tell people the link once: …/releases/latest/download/team-plugins.json for a list, or …/releases/latest/download/team-plugins.zip for 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

Examples: the Containers and GitHub plugins that ship with the app (+ Plugin → Comes with the app).

Every field, with its type: Plugin reference.