| name | plantt-remote |
| description | Edit the active plan in a live plantt tab (hosted or localhost) by natural-language request. Starts a localhost relay, drives the open plantt tab through it, and shuts the relay down when finished. Use when the user wants to change a plan they have open in plantt โ move tasks, add workstreams/milestones, adjust capacity, etc. |
plantt remote control
Edit the plan in a live, open plantt browser tab โ the user's normal browser, no extension or
browser automation. plantt (with ?agent=1) polls a small localhost relay you run; you push edits
through it. Edits go through plantt's own internals, so undo/redo, the JSON editor, the shareable URL
and persistence all stay consistent.
How it works
A web page can't listen on a socket, so the page polls a relay that you run on localhost.
The relay (relay.mjs, next to this file) bridges your curl calls to the page:
you โโcurlโโโถ relay (127.0.0.1:8787) โโโfetch pollโโ plantt tab (?agent=1, in the user's browser)
POST /edit GET /poll โ applies via window.plantt.setModel
GET /state POST /ack โ echoes fresh state back
This works even for a hosted HTTPS plantt tab: the relay sends Private Network Access + CORS
headers that let the page reach loopback (verified on Chrome 148).
Procedure
-
Start the relay (idempotent โ skip if /status already answers):
curl -s --max-time 1 http://127.0.0.1:8787/status \
|| node "<this-skill-dir>/relay.mjs" >/tmp/plantt-relay.log 2>&1 &
Use this skill's own directory for the path. Give it ~1s, then confirm:
curl -s http://127.0.0.1:8787/status.
-
Get the tab connected. Ask the user to open their plan in plantt with ?agent=1 appended,
e.g. https://THEIR-HOST/?agent=1 (or http://localhost:5173/?agent=1 for local dev). If they
run the relay on a non-default port, also append &relay=http://127.0.0.1:PORT. A "โ remote:
connected" badge appears bottom-right. Poll /status until connected:true (give up after ~30s
and tell the user the tab isn't reachable).
-
Fetch the live contract (especially if you don't have this repo). GET /schema returns the
authoritative, current data model + DSL semantics + the full op vocabulary + a valid example,
generated by the running app itself โ trust it over any prose if they ever disagree:
curl -s http://127.0.0.1:8787/schema
The "Plan model schema" and op tables lower in this file mirror it for quick offline orientation.
-
Read what exists before editing. Prefer the compact /outline over the full /state โ
it's names, deps, and dates only, so it costs far fewer tokens:
curl -s http://127.0.0.1:8787/outline
curl -s 'http://127.0.0.1:8787/deps?name=Launch'
curl -s 'http://127.0.0.1:8787/dependents?name=Evals'
curl -s 'http://127.0.0.1:8787/get?name=Launch'
curl -s http://127.0.0.1:8787/state
All of the above target the active plan. To work across all saved plans (the tab can hold
many; only one is active at a time), use the /plans* endpoints โ list/get read any plan
without switching; open/duplicate/create change which plan is active (same as clicking in
the UI). This is how you read or copy a plan the user doesn't currently have open:
curl -s http://127.0.0.1:8787/plans
curl -s 'http://127.0.0.1:8787/plans/get?uuid=<uuid>'
curl -s -X POST http://127.0.0.1:8787/plans/open -d '{ "uuid":"<uuid>" }'
curl -s -X POST http://127.0.0.1:8787/plans/duplicate -d '{ "uuid":"<uuid>", "name":"Copy", "open":true }'
curl -s -X POST http://127.0.0.1:8787/plans/create -d '{ "name":"New", "model":{...}, "open":true }'
To duplicate plan A into a new plan stripped of milestones/clusters without leaving the active
plan: GET /plans/get?uuid=A โ strip locally โ POST /plans/create { name, model }.
History (undo TREE). Every plan keeps a branching undo tree (not a linear stack). tree
and get read ANY saved plan without switching; jump/undo/redo move the ACTIVE plan (to
navigate a different plan, POST /plans/open it first). Use this to find and restore a past
state โ e.g. recover a version the user undid, or snapshot a node into a new plan:
curl -s http://127.0.0.1:8787/history
curl -s 'http://127.0.0.1:8787/history?uuid=<uuid>'
curl -s 'http://127.0.0.1:8787/history/get?id=<n>'
curl -s 'http://127.0.0.1:8787/history/get?id=<n>&uuid=<uuid>'
curl -s -X POST http://127.0.0.1:8787/history/jump -d '{ "id":<n> }'
curl -s -X POST http://127.0.0.1:8787/history/undo
curl -s -X POST http://127.0.0.1:8787/history/redo
currentId marks where the plan is now; activeChild is the linear redo target; a node with no
childIds is a leaf (branch tip). To checkpoint a node: GET /history/get?id=n โ POST /plans/create { name, model }. Navigating is non-destructive โ it never deletes tree nodes.
Hide / show (view-only). Hide or show any activity, milestone, workstream or cluster by
name โ a per-plan view preference that is NOT a model edit (not in the plan JSON, the shareable
URL, or undo). To delete for real, use the remove* ops instead. Accepts { names:[...] } or a
single { name }; a name matching more than one kind is hidden in each:
curl -s http://127.0.0.1:8787/hidden
curl -s -X POST http://127.0.0.1:8787/hide -d '{ "names":["Evals","256 H100"] }'
curl -s -X POST http://127.0.0.1:8787/show -d '{ "name":"Evals" }'
curl -s -X POST http://127.0.0.1:8787/toggle -d '{ "names":["Post-training"] }'
-
Apply the edit with /apply โ a batch of name-addressed ops applied atomically as ONE undo
step. Don't read-modify-write the whole model unless you truly need to:
curl -s -X POST http://127.0.0.1:8787/apply \
-H 'content-type: application/json' \
-d '{ "summary": "Push launch + add review gate", "ops": [
{ "op":"update", "name":"Launch", "set":{ "date":"2026-11-15" } },
{ "op":"addMilestone", "workstream":"Post-training",
"milestone":{ "name":"Launch review", "date":"2026-11-01", "emoji":"โ
", "deps":["Evals"] } }
] }'
Response: { ok, error, applied }. If ok:false the WHOLE batch was rejected and the live plan
is untouched โ read error (it names the failing op[i]), fix, retry. summary becomes the
undo label. (The whole-model POST /edit { model, summary } still exists as a fallback.)
-
Verify by reading /state back (or just trust the state returned by /edit) and tell the
user what changed. They can watch it update and undo it with Cmd/Ctrl-Z if they dislike it.
-
Always shut the relay down when the task is done (no auth token guards it, so don't leave it
running):
curl -s -X POST http://127.0.0.1:8787/shutdown
Plan model schema (for step 5)
Mirror of src/schema.js / GET /schema for quick reference. If this and /schema ever
disagree, /schema (generated by the running app) wins โ and the repo's npm test would
have caught the drift.
{
"title": "string", "note": "string",
"annotations": [ { "text": "string", "date": "YYYY-MM-DD", "target": "<workstream name>|@compute|<task|milestone name>|<cluster name>", "edge": "top|bottom", "color": "#hex?", "icon": "string? (default โ)" } ],
"capacity": [ {
"name": "string", "chip": "H100|H200|B200|A100|v4p|v5e|v5p|v6e", "chips": 0,
"flops": 0,
"from": "YYYY-MM-DD", "to": "YYYY-MM-DD?", "color": "#hex",
"grows": [ { "date": "YYYY-MM-DD", "to": 0 } ],
"note": "string?"
} ],
"workstreams": [ {
"name": "string", "note": "string?",
"tasks": [ {
"name": "string (unique across ALL tasks AND milestones)",
"start": "<taskName> | [\"date\",\"YYYY-MM-DD\"] | [\"after\",\"<taskName>\",[\"days\",N]]",
"end": "[\"days\"|\"weeks\"|\"months\",N] | [\"date\",\"YYYY-MM-DD\"]",
"significance": 0, "cluster": "<capacity.name>?", "chips": 0,
"link": "url?", "tooltip": "markdown?", "deps": ["<itemName>"]
} ],
"milestones": [ {
"name": "string (unique)", "date": "YYYY-MM-DD",
"emoji": "string?", "line": "#hex?", "tooltip": "markdown?", "deps": ["<itemName>"]
} ]
} ]
}
Validation rules plantt enforces (mirror them, or /edit will reject):
workstreams is required and an array; every workstream needs a name and a tasks array.
- Every task/milestone needs a
name, and names are unique across all tasks and milestones.
deps must be arrays of names that exist somewhere in the plan.
cluster on a task should reference an existing capacity[].name.
/apply op vocabulary
Each op is addressed by name (plantt is name-keyed, not index-keyed). Ops run in order on a
working copy; the batch is validated once at the end. set merges fields; set:{field:null} deletes
a field.
Items (tasks + milestones), addressed by their unique name:
{op:"addTask", workstream, task:{...}} ยท {op:"addMilestone", workstream, milestone:{...}}
{op:"update", name, set:{...}} โ patch fields (NOT name; use rename)
{op:"rename", name, to} โ auto-repoints every dependent's deps
{op:"setDeps", name, deps:[...]}
{op:"remove", name} โ auto-strips this name from all other deps
{op:"moveTask", name, toWorkstream}
Workstreams (by name):
{op:"addWorkstream", workstream:{name,note?,tasks?,milestones?}} (or {op:"addWorkstream", name, note?})
{op:"renameWorkstream", name, to} ยท {op:"updateWorkstream", name, set:{...}}
{op:"removeWorkstream", name} (drops its items + cleans deps) ยท {op:"moveWorkstream", name, toIndex}
Capacity / compute (by name; tasks reference it via cluster):
{op:"addCapacity", capacity:{...}} ยท {op:"updateCapacity", name, set:{...}}
{op:"renameCapacity", name, to} โ repoints task.cluster refs
{op:"removeCapacity", name} โ drops now-dangling cluster refs on tasks
{op:"moveCapacity", name, toIndex} โ reorder a pool (lane order)
Annotations (dated band-edge markers; addressed by index into annotations) and plan fields:
{op:"addAnnotation", annotation:{text,date,target,edge?,color?,icon?}} ยท {op:"updateAnnotation", index, set:{...}} ยท {op:"removeAnnotation", index}
target = a workstream name, "@compute", or any activity / milestone / cluster name (pins to that row or compute lane); edge = "top"|"bottom". An unknown or hidden target is skipped at render. (Old clusters[] auto-migrate to @compute bottom annotations.)
{op:"setPlan", set:{title?, note?}}
Themes (local-only)
Themes are CSS-variable token sets, stored only in the browser (localStorage) โ nothing is
uploaded. The selection binds one theme to the system light slot and one to the dark slot;
the app shows whichever matches the OS prefers-color-scheme. Built-in themes ship with the app;
imported ones live in localStorage and are tagged "imported" in the UI.
curl -s http://127.0.0.1:8787/themes
curl -s http://127.0.0.1:8787/theme/current
curl -s 'http://127.0.0.1:8787/theme/get?id=nord'
curl -s -X POST http://127.0.0.1:8787/theme/set -d '{ "slot":"dark", "id":"catppuccin-mocha" }'
curl -s -X POST http://127.0.0.1:8787/theme/set -d '{ "slot":"light", "id":"solarized-light" }'
curl -s -X POST http://127.0.0.1:8787/theme/import -d '{ "theme": { "id":"my-theme", "name":"My Theme", "appearance":"dark", "tokens":{ ... }, "barPalette":[ ... ] } }'
curl -s 'http://127.0.0.1:8787/theme/export?id=nord'
curl -s -X POST http://127.0.0.1:8787/theme/remove -d '{ "id":"my-theme" }'
A theme must define every token in GET /schema โ themes.tokens (also window.plantt.themes.tokens()).
Built-in ids: tufte (default), solarized-light, solarized-dark, latex, catppuccin-latte,
catppuccin-mocha, nord, nord-light, gruvbox-light, gruvbox-dark, dracula,
rose-pine, rose-pine-dawn, print. Plan-DATA colors (cluster/capacity/milestone) are not themed.
Notes & limits
- Enabled only with
?agent=1; a normal plantt visit exposes nothing.
- Latency is near-instant (long-poll). If the badge shows "offline", the relay was killed or the
tab was closed โ restart from step 1.
- One tab at a time. The relay serves whichever
?agent=1 tab is polling it.