adom-workspace-control
Public Made by Adomby adom
Adom workspace panel and pane management — open, close, split, resize, swap, and move tabs in the Hydrogen editor layout
name: adom-workspace-control description: Use when the user wants to open, close, add, or remove a panel; split a pane; move or swap tabs between panes; change the workspace layout; resize a split; bring a background tab to the foreground; show or hide a sensor view, camera, log, control panel, or any Adom editor panel; or asks "what panels are available". Examples: "open the 3D viewer", "add a webview tab", "close the robot log", "resize the split to 70/30", "split the pane horizontally", "move that tab to the other pane", "swap the two panes", "close that pane", "show the IMU sensor tab", "bring the webview to the front", "what panels can I open?". user-invocable: true
Adom Workspace Control
🛑 Never wipe the user's full Hydrogen layout — and never remove the VS Code tab 🛑
The danger is leaving the Hydrogen screen empty, or without VS Code. The user panics because "everything is gone" and they can't tell what happened.
What's safe
- Per-tab CLI operations via
adom-cli hydrogen workspace ...:add-tab,remove-tab,move-tab,swap,split,resize,active-tab,close-panel. These are bounded — each mutates one tab/pane at a time. Use them freely. - GET on the editor endpoint is fine (read-only).
What's NOT safe
- Never call
curl,gh api,fetch, or any raw HTTP client withPUT,PATCH, orDELETEagainsthttps://hydrogen.adom.inc/api/workspaces/editor/<user>/<ws>/current. That endpoint is the WHOLE LAYOUT; any write replaces it wholesale and wipes every panel, tab, and split. - Never remove the VS Code tab — even via a safe CLI op — unless the user explicitly asks. VS Code is where the user lives; removing it is indistinguishable from "everything is broken" to them.
- Never remove the last remaining tab in a pane without the user asking (pane goes blank; if it's the only pane, the workspace looks wiped).
Just-in-time confirmation: ALWAYS use AskUserQuestion before any authorized destructive op
Even when the user has explicitly asked for a destructive action
(workspace wipe, screenshot-the-empty-state dance, mass tab close,
anything that would leave the screen empty), call the harness-level
AskUserQuestion tool as the IMMEDIATE LAST ACTION before firing
the destruction. The user clicks "Proceed" and the destructive call
fires in the same tool-turn — no drift between click and wipe.
Why: the user is typing to Claude while you work. If you said "going
now" three tool calls ago, they've resumed typing; firing destruction
now eats whatever they're mid-drafting. AskUserQuestion is blocking
and synchronous in the Claude Code chat UI, but non-modal for the rest
of the workspace — the user finishes typing / saves their work, then
clicks. Tested 2026-04-22: in-flight chat drafts survive the prompt
intact, so it's safe to use mid-conversation.
Shape of the call:
AskUserQuestion({
questions: [{
question: "About to <specific destructive action>. Save any work you're typing first. Ready?",
header: "Confirm destruction",
multiSelect: false,
options: [
{ label: "Proceed — saved", description: "Fire the destructive operation immediately." },
{ label: "Cancel", description: "Abort; no change." }
]
}]
})
If the user picks "Cancel" or any "Other" text that isn't an unambiguous yes, ABORT. Don't argue, don't retry.
Pair this with:
- A disk-backed backup of any state you're about to mutate (e.g.
/tmp/hydrogen-layout-backup-<ts>.jsonfor a layout wipe) so you can restore deterministically after. - An announced restore command printed to the user so they can run it themselves if something goes wrong before you get to restore.
Incidents the rule comes from
2026-04-22 (morning) — AI probed the editor API for debug
(curl -X PUT -d '{}', then -X DELETE), trying to understand a 409
state. Each call returned 204 and silently replaced the whole layout
with an empty one. User had to re-open VS Code from scratch. Filed as
hydrogen#322 for server-side guardrails.
2026-04-22 (midday) — AI was authorized to wipe-screenshot-restore the layout for a Colby bug report but did not interpose a just-in-time confirmation, so the user was typing mid-paragraph when the destructive step was about to fire. AskUserQuestion is the fix for that class of failure; before this rule existed, even an authorized destructive op was unsafe because the destruction timing drifted seconds-to-minutes after the user's verbal "go."
Recovery if it already happened
- Tell the user immediately — don't try to "quietly restore" a guess; the guess will differ from what they had.
- Reload the Hydrogen page via
adom-cli hydrogen reload(broadcasts SSE reload to all connected tabs). The client re-registers the editor session with whatever default/saved layout Hydrogen boots. If no browser is connected, ask the user to open the editor tab first. - If you had a disk-backed backup from step 1 of the destruction
preamble, restore from that via a single
PUTwith the saved JSON — but again, call AskUserQuestion first to confirm the user is ready and has saved anything else in flight. - File a bug capturing exactly what you did (method + URL + body) so server-side guardrails can catch the next one — see hydrogen#322 for the template.
Related feature requests (tracked upstream)
- adom-inc/hydrogen#322 — "Detect wiped / empty workspace layout and offer a 'Restore default layout' recovery banner" + server-side PUT/DELETE guardrails.
- adom-inc/hydrogen#323 — Editor session 409 auto-recovery (separate failure mode that misleadingly resembles a wipe).
Architecture: Hydrogen vs VS Code vs Docker
Browser
└── Hydrogen (workspace shell — panels, tabs, splits)
├── 3D Viewer panel
├── webview panels (shotlog, AV, etc.)
├── Sensor panels, camera feeds, etc.
└── VS Code panel (iframe → code-server running on Docker)
└── code-server (its own world, own API, own extensions)
Hydrogen is the outer workspace shell. The adom-cli hydrogen workspace API and the REST endpoints documented below control Hydrogen — adding/removing tabs, splitting panes, resizing, etc. These operate on the panel layout, not on what's inside any panel.
VS Code (code-server) runs inside the Docker container and is embedded as an iframe in one of Hydrogen's panels. Hydrogen cannot control what happens inside VS Code — it can only add/remove the VS Code panel itself. To control VS Code (open files, reveal in explorer, run commands), you must use code-server's own mechanisms:
Code-server remote CLI (safe for opening files, NOT folders):
/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh --reuse-window /path/to/file.pngThis opens a file in a new editor tab without changing the workspace root. NEVER pass a folder path — that changes the workspace root and breaks the user's session.
Code-server IPC socket (
$VSCODE_IPC_HOOK_CLI): Low-level Unix socket the CLI uses under the hood. POST{ type: "open", fileURIs: [...] }.VS Code extensions: A code-server extension can expose an HTTP API on a local port, giving Docker processes full access to the VS Code API (
vscode.window.showTextDocument(),vscode.commands.executeCommand('revealInExplorer'), etc.).
Key rule: Hydrogen API = panel layout. Code-server CLI/extensions = VS Code internals. Don't mix them up.
Manipulate the Adom editor workspace: add/remove tabs, split/close panes, move/swap tabs between panes, resize splits, take screenshots, and query the layout.
USE adom-cli — NOT curl
adom-cli auto-detects auth, owner, and repo. Use it instead of raw curl for all workspace operations. The curl examples further below are reference only — prefer the CLI commands.
# These just work — no setup, no API key, no owner/repo needed:
adom-cli hydrogen workspace tabs # list all tabs (flat)
adom-cli hydrogen workspace find-tab "Schematic Editor" # find by name
adom-cli hydrogen screenshot workspace # screenshot (auto-saved)
adom-cli hydrogen screenshot panel --name "Schematic Editor" # screenshot by name
adom-cli hydrogen workspace add-tab --panel-id <id> --panel-type "adom/..." --display-name "My Tab" --alert
adom-cli hydrogen workspace remove-tab --name "My Tab" # remove by name
adom-cli hydrogen workspace active-tab --name "Schematic Editor" # activate by name
adom-cli hydrogen workspace close-panel "My Tab" # close by name
Name-based addressing: Most commands accept --name to target tabs by display name instead of UUIDs. workspace tabs returns a flat list with names — no tree parsing needed.
Sharing & Permissions
adom-cli hydrogen screenshot status # check what's available
adom-cli hydrogen sharing request --share tab # request tab sharing
adom-cli hydrogen sharing request --share screen --audio --reason "Recording demo" # screen + mic
Screenshots, Recording, Audio, Captions, Alerts, Nav
Media and overlay commands are covered in a reference file: see media-and-alerts-reference.md for:
- Screenshots (
adom-cli hydrogen screenshot workspace|panel|screen, unified approval flow,html2canvasfallback) - Screen recording (4 mandatory flags:
--share,--mic,--countdown,--reason; smart-delta approval matrix; WebM output) - Audio (mic enable/disable, recording, level/gain)
- Captions (DOM overlay via
adom-cli hydrogen caption; always-on-topadom-desktop desktop_captionfor native apps) - Tab alerts (blink to get attention)
- Navigation bar (minimize/expand/toggle)
Panel-specific APIs (e.g. navigating a webview to a URL) are documented in separate skills under .claude/skills/adom-panels/<panel-name>/SKILL.md. See .claude/skills/adom-panels/SKILL.md for the full catalog and links.
Environment (for raw curl — prefer adom-cli above)
All API calls require:
- Base URL:
https://hydrogen.adom.inc/api/workspaces/editor/{owner}/{repo}/current - JSON parsing:
jqis not available in the container. Usepython3 -m json.toolto pretty-print JSON output instead. - Auth:
X-Api-Keyheader. Obtain the key by reading/var/run/adom/api-key— this file is always present in the container, is read-only, and contains the token with no leading or trailing whitespace:
Fall back to env vars (API_KEY=$(cat /var/run/adom/api-key)ADOM_API_KEY,API_KEY,X-Api-Key) only if that file is missing. Ask the user only as a last resort.
Critical Rules — Read Before Doing Anything
NEVER
PUTorDELETEthe/currentroot. Both are destructive at the workspace level:PUT /currentreplaces the entire layout and breaks all node IDs.DELETE /currenttears down the editor session, wiping every pane and tab (including the user's VS Code session hosting this chat). Mutate workspaces only through the granular sub-paths:POST /current/tabs,DELETE /current/tabs,POST /current/splits,PATCH /current/splits/{id},POST /current/moves,POST /current/swaps,DELETE /current/panels/{id}. Build up the desired layout one operation at a time. If an operation you want isn't on that list, it isn't supported for AI use — do not probe with other methods to find out.
NEVER point a Hydrogen webview at a
http://localhost:<port>/orhttp://127.0.0.1:<port>/URL. The webview runs in the user's BROWSER, not inside the Docker container. From the browser's perspective,localhostis the user's host machine, not the container — so anything served by a container-local service (tsci dev on 3040, adom-tsci on 8850, shotlog on 8820, etc.) is unreachable vialocalhost. The tab loads, the iframe tries to fetch, and the user sees the tscircuit "loading cloud" placeholder or a blank page forever. Always rewrite the URL to use the container's external proxy:# Canonical template is stored in the VSCODE_PROXY_URI env var, with {{port}} placeholder: # VSCODE_PROXY_URI=https://john-workspace-8v0y8o3547h2.adom.cloud/proxy/{{port}}/ PROXY_URL="${VSCODE_PROXY_URI/\{\{port\}\}/8850}" adom-cli hydrogen webview navigate --name "My Tab" "$PROXY_URL"This matches every existing working tab (Video Post, Wiki publish, shotlog channels, adom-tsci instances) — they all use
https://<host>/proxy/<port>/.... If you catch yourself typinglocalhost:or127.0.0.1:in awebview navigate,add-tab --url, or anydisplayName/urlpair, you're about to hand the user a broken tab. The same rule applies in reverse forcurlfrom inside the container —curl http://127.0.0.1:<port>/works from the container's own shell (that's how to self-test an HTTP endpoint), but the URL you PUT IN THE TAB must be the proxy URL. Two different contexts, two different URLs:Context Correct URL curlorBashtool (runs in container)http://127.0.0.1:<port>/...Hydrogen webview url=,navigate,add-tab$VSCODE_PROXY_URIwith{{port}}substitutedAnything a browser fetches (img src, iframe src on a served page) Proxy URL Shotlog's
shotlog openalready does the right substitution internally.adom-cli hydrogen workspace add-tab --urlandhydrogen webview navigate <url>do NOT — you must supply the proxy URL. If you're unsure,echo "${VSCODE_PROXY_URI/\{\{port\}\}/<port>}"prints the correct URL to use.NEVER ask the user to do something you can do yourself. If you tell them "reload the shotlog tab and let me know what you see" or "refresh the webview," you've punted manual busywork onto the person you're supposed to be helping. That's a failure. Claude has tools for almost every workspace action:
- Refresh a webview:
adom-cli hydrogen webview refresh --name "<tab name>"(returns 204 on success) - Navigate to a URL:
adom-cli hydrogen webview navigate --name "<tab name>" --url "<url>" - Bring a tab to the front:
adom-cli hydrogen workspace active-tab --name "<tab name>" - Screenshot a panel:
adom-cli hydrogen screenshot panel --name "<tab name>"(capture IS visible to you via the returned path) - Open a tab on a specific pane:
adom-cli hydrogen workspace add-tab --panel-id <non-vscode-id> ... - Alert a tab:
adom-cli hydrogen alert ... - Send a desktop notification:
adom-cli hydrogen notify send --title "..." "body" - Force-reload all browser tabs:
adom-cli hydrogen reload(hot-reload after deploy, recovery) - Show/hide address bar:
adom-cli hydrogen webview set-header ...
Before saying "check the tab", ask yourself: can I refresh it for them? can I screenshot it to verify? can I bring it to the front? If yes — do it. Only hand off to the user when: (a) the action genuinely requires their eyes (taste judgements, choosing between layouts), (b) their physical keyboard/mouse is needed (desktop app outside Hydrogen), or (c) you've done the automated parts and need them to confirm the result matches intent. If you find yourself about to type "let me know if X works," stop and actually check X.
curl,screenshot panel,stateendpoints — they exist so you don't have to outsource verification.- Refresh a webview:
NEVER echo or display the API key in tool output. Store it in a shell variable — never
catit on its own or log it.Add before removing when swapping tabs in a leaf — the API returns 400 if you try to remove the last tab, so always add the new tab first, then remove old ones.
Remove tabs from highest index first when removing multiple tabs from a leaf, to avoid index shifting.
Re-fetch the layout before each batch of operations — state can change between calls. Always confirm current node IDs and tab indices are still valid.
Be cautious when closing panes or removing VS Code tabs — one VS Code panel instance is hosting this chat session. Closing it terminates the conversation. Since there is no reliable way to identify which specific VS Code instance is running the session, avoid closing panes that contain a VS Code tab or removing VS Code tabs unless the user explicitly requested it.
VS Code pane guard is now enforced by the CLI (v0.5.4+).
adom-cli hydrogen workspace add-tabandwebview open-or-refreshautomatically redirect away from any pane that hosts VS Code. If you pass a--panel-idthat hosts VS Code, the CLI finds another pane (or auto-splits to create one) and adds a_vscode_guardfield to the response explaining the redirect. You no longer need the Python script below to avoid the VS Code pane — the CLI handles it. The script is kept for reference only / for raw-curl callers who bypass the CLI.If you're using
adom-cli, just pass any--panel-id— even the VS Code one — and the guard will redirect silently. The old rule ("query the layout, find a non-VS-Code pane, target it") is still correct but no longer necessary to do manually.# VS Code's panelType is the canonical 'eeee' UUID below. VSCODE_PT="adom/a1b2c3d4-eeee-4000-a000-00000000000e" # Walk every leaf in the layout. Return the first leaf whose tabs # do NOT include VS Code. Fall back to the currently-focused pane # only if every pane has VS Code (single-pane layout). When there # are multiple non-VS-Code panes, prefer the one whose activeTab # matches something webview-like, or just pick the first. adom-cli hydrogen workspace get | python3 -c ' import json, sys VSCODE_PT = "adom/a1b2c3d4-eeee-4000-a000-00000000000e" layout = json.load(sys.stdin) def leaves(node): if "tabs" in node: yield node else: if "first" in node: yield from leaves(node["first"]) if "second" in node: yield from leaves(node["second"]) focused = layout.get("focusedPanelId") non_vscode = [l for l in leaves(layout["root"]) if not any(t.get("panelType") == VSCODE_PT for t in l["tabs"])] if non_vscode: # Prefer the focused pane IF it is itself non-VS-Code, otherwise # take the first non-VS-Code pane in tree order. picked = next((l for l in non_vscode if l["id"] == focused), non_vscode[0]) print(picked["id"]) else: print("") # single-pane layout, caller must split or ask 'Every CLI and every webapp that opens a Hydrogen tab MUST do this check. The
video-postcrate implements it insrc/webapp/tab.rs; copy that pattern. Do not trustfocusedPanelIdfor target selection. Do not useadom-cli hydrogen workspace active-tabto "fix" the wrong placement after the fact — by that point the user has already seen their chat get covered.Third-party CLIs that open their own tabs —
shotlog open,adom-tsci start,av_statustools, any webapp written before this rule existed — usually have their own unfiltered leaf-picker and will happily land on the VS Code pane. Do not trust them. Before running such a command, either:- Compute the non-VS-Code panel id yourself (snippet above), then pass it as
--panel-id <id>if the CLI supports that flag (adom-cli hydrogen workspace add-tabdoes;shotlog openas of 2026-04 does NOT). - If the third-party CLI has no
--panel-idflag: skip the wrapper entirely. Useadom-cli hydrogen workspace add-tab --panel-id <non-vscode-id> --panel-type "adom/a1b2c3d4-0031-4000-a000-000000000031" --display-name "..." --url "<the underlying URL>"directly — the wrapper's extra polish (icons, titles) is not worth the risk of covering VS Code. - If you already opened a tab on the wrong pane, move it via
POST /moves(granular) rather than re-creating; don't leave it landed on VS Code "just this once."
Known problem URLs you can target directly when skipping wrappers:
shotlog:https://<wiki-host>/proxy/8820/log/<channel>/adom-tsci preview:http://127.0.0.1:<port>/AV:/proxy/8770/
- Compute the non-VS-Code panel id yourself (snippet above), then pass it as
Workflow
Always follow this order when making changes:
- Setup — resolve credentials, repo, and target user (ask once, remember for the session)
- GET the current layout to find leaf node IDs
- Identify the correct leaf
idfor where the user wants the tab placed - Look up the
panelTypefull ID from the catalog below - POST/DELETE/PATCH to make the change
- Confirm success to the user
Step 0 — Setup: resolve credentials, repo, and target user
At the start of the session, read the API key and auto-discover the owner and repo via the Carbon API:
API_KEY=$(cat /var/run/adom/api-key)
# Auto-discover owner and repo from the Carbon container registry
SLUG=$(echo "$VSCODE_PROXY_URI" | sed 's|.*-\([^.]*\)\.adom\.cloud.*|\1|')
CONTAINER_INFO=$(curl -s -H "X-Api-Key: $API_KEY" "https://carbon.adom.inc/containers/$SLUG")
OWNER=$(echo "$CONTAINER_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['repository']['owner']['name'])")
REPO=$(echo "$CONTAINER_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['repository']['name'])")
BASE="https://hydrogen.adom.inc/api/workspaces/editor/$OWNER/$REPO/current"
The slug is extracted from $VSCODE_PROXY_URI (everything after the last -, before .adom.cloud). The Carbon API returns the full container info including repository.owner.name and repository.name. Do not ask the user for owner/repo — always use this endpoint to resolve them automatically.
Legacy containers: If the Carbon API returns {"error":"CONTAINER_NOT_FOUND"}, this is likely a legacy container that predates the Carbon registry. In that case, fall back to asking the user:
"What is the Adom owner (username or org) and Adom repository name for this project?"
As a last resort, you can try to parse $VSCODE_PROXY_URI (format {owner}-{repo}-{slug}.adom.cloud), but this is unreliable if the repo name contains hyphens.
Remember the answers for the rest of the conversation — do not ask again.
Discover the target user. Workspaces are scoped per-user. You must discover who has an open editor:
curl -s -H "X-Api-Key: $API_KEY" "$BASE/users" | python3 -m json.tool
# → { "users": [{ "username": "kcknox" }] }
- One user (most common): auto-detected by the server — no extra header needed. Set
TARGET_HEADER="". - Multiple users: ask the user which person's workspace to modify. Remember the choice and set:
TARGET_HEADER='-H "X-Target-Username: <chosen-username>"' - Zero users: the editor is not open in any browser. Tell the user to open the editor first.
Save OWNER, REPO, API_KEY, BASE, and TARGET_HEADER (if needed) in your memory file so you don't repeat this setup.
Step 1 — GET current layout
curl -s -H "X-Api-Key: $API_KEY" $TARGET_HEADER "$BASE" | python3 -m json.tool
Parse the response to find:
- All
leafnodes and theiridvalues (needed for adding/removing tabs) - All
splitnodes and theiridvalues (needed for resizing) - The
tabsarray on each leaf andactiveTabIndex
Step 2 — POST: Add a tab
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"<full-panel-id>"}' \
"$BASE/tabs"
Returns { "tabId": "<uuid>" } on success.
Custom tab name and icon: You can optionally set displayName and displayIcon to override the tab header. MDI icons are monochrome and inherit the theme color. For colored icons, use a custom SVG via data: URL.
MDI icon example:
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"adom/a1b2c3d4-0031-4000-a000-000000000031","displayName":"eBay","displayIcon":"mdi:cart"}' \
"$BASE/tabs"
Custom SVG icon example (colored):
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"adom/a1b2c3d4-0031-4000-a000-000000000031","displayName":"RMFG","displayIcon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iIzRDQUY1MCIvPjwvc3ZnPg=="}' \
"$BASE/tabs"
displayName— custom label shown on the tab (instead of the panel's default name)displayIcon— MDI icon string (e.g.mdi:github, monochrome) ordata:URL for custom colored SVG/image (e.g.data:image/svg+xml;base64,...)- Both are optional and stored in
panelState. Omit them to use the panel's default name/icon.
If the user doesn't specify which panel (leaf) to add to and there is more than one leaf, ask them where they want it placed, or place it in the largest/most relevant leaf.
Step 3 — DELETE: Remove tab(s) — 5 modes
Target the reference tab by name or panelId + tabIndex. The mode field controls what gets closed.
| Mode | Effect |
|---|---|
close (default) |
Close just this tab |
others |
Close every other tab, keep this one (becomes active) |
all |
Close the entire pane (fails if it's the last pane) |
right |
Close everything to the right of this tab |
left |
Close everything to the left of this tab |
For others / right / left, the referenced tab becomes active. For all, the pane is removed and its sibling is promoted.
# Close a single tab (default)
curl -s -X DELETE \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"name":"Bug Review"}' \
"$BASE/tabs"
# Close all other tabs in the pane
curl ... -d '{"name":"Bug Review","mode":"others"}' "$BASE/tabs"
# Close the entire pane
curl ... -d '{"name":"Bug Review","mode":"all"}' "$BASE/tabs"
# Close tabs to the right / left
curl ... -d '{"name":"Bug Review","mode":"right"}' "$BASE/tabs"
curl ... -d '{"name":"Bug Review","mode":"left"}' "$BASE/tabs"
# By UUID + index (all modes work the same way)
curl ... -d '{"panelId":"<leaf-id>","tabIndex":<n>,"mode":"others"}' "$BASE/tabs"
Or via CLI: adom-cli hydrogen workspace remove-tab --name "Bug Review" [--mode close|others|all|right|left].
Cannot remove the last tab in a leaf via
mode: close— API returns 400. Usemode: allto remove the whole pane instead (the sibling is promoted).
Step 4 — PATCH: Resize a split
curl -s -X PATCH \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"ratio":0.3}' \
"$BASE/splits/<split-id>"
ratio is the fraction given to the first child (0.1–0.9). So "70/30" → ratio: 0.7.
Step 5 — POST: Split a pane
Split an existing pane into two, creating a new split node.
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","direction":"horizontal","panelType":"<full-panel-id>"}' \
"$BASE/splits"
Returns { "panelId": "<new-leaf-id>", "tabId": "<new-tab-id>" }.
| Field | Required | Description |
|---|---|---|
panelId |
yes | Leaf node to split |
direction |
yes | "horizontal" or "vertical" |
panelType |
yes | Panel type for the new pane's tab |
position |
no | "before" or "after" (default "after") |
ratio |
no | Split ratio 0.1–0.9 (default 0.5) |
displayName |
no | Custom tab header label |
displayIcon |
no | MDI icon string (e.g. mdi:github, monochrome) or custom SVG/image as data: URL — use data: for colored icons |
Step 6 — POST: Move a tab between panes
Move a tab from one pane to another without creating a split. The moved tab is auto-activated and scrolled into view in the target pane — no separate activate call needed.
# By name (preferred)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourceName":"Bug Review","targetName":"Schematic Editor"}' \
"$BASE/moves"
# By UUID
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourcePanelId":"<leaf-id>","tabId":"<tab-uuid>","targetPanelId":"<other-leaf-id>"}' \
"$BASE/moves"
# With alert (blink the moved tab until clicked)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourceName":"Bug Review","targetName":"Schematic Editor","alert":true,"alertDuration":0}' \
"$BASE/moves"
Or via CLI: adom-cli hydrogen workspace move-tab --source-name "Bug Review" --target-name "Schematic Editor" [--target-index N] [--alert] [--alert-duration SECS].
Returns 204 No Content. If the moved tab was the last one in the source pane, the source pane is automatically removed.
| Field | Required | Description |
|---|---|---|
sourcePanelId + tabId |
yes* | Source tab (by UUID) |
targetPanelId |
yes* | Destination pane (by UUID) |
sourceName |
alt* | Resolves to sourcePanelId + tabId |
targetName |
alt* | Resolves to targetPanelId (pane containing that tab) |
targetIndex |
no | Insert position (appends if omitted) |
alert |
no | true to blink the moved tab in its new pane |
alertDuration |
no | Seconds to blink. 0 = indefinite until user clicks |
*Provide either UUIDs or names.
Step 7 — POST: Swap content between panes
Swap all tabs between two panes.
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourcePanelId":"<leaf-id-1>","targetPanelId":"<leaf-id-2>"}' \
"$BASE/swaps"
Returns 204 No Content.
Step 8 — POST: Bring a tab to the foreground
Switch the visible tab within a pane. Use this when a pane has multiple tabs and you want to show one that is currently in the background.
# By tab index (0-based)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","tabIndex":2}' \
"$BASE/active-tab"
# By tab UUID (preferred — stable if tabs are reordered)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","tabId":"<tab-uuid>"}' \
"$BASE/active-tab"
Returns 204 No Content.
| Field | Required | Description |
|---|---|---|
panelId |
yes | Leaf node containing the tabs |
tabIndex |
one of | 0-based position of the tab to show |
tabId |
one of | UUID of the tab to show |
When the user says "show the IMU sensor" or "switch to the Robot Log tab", GET the layout, find the leaf and tab by
panelType, then callPOST /active-tabwith that leaf'sidand the tab'sid.
Step 9 — DELETE: Close a pane
Remove a pane entirely. Its sibling is promoted to replace the parent split.
curl -s -X DELETE \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
"$BASE/panels/<leaf-id>"
Returns 204 No Content. Cannot close the last pane — the API returns 400.
Step 10 — PUT and DELETE on /current: PROHIBITED
Do not
PUTorDELETEthe/currentroot.
PUT /currentreplaces the entire workspace layout — every pane, every tab, every node ID. Past incidents: AI sentPUT {}to probe what the endpoint accepts; server happily 204'd and the user's workspace (VS Code + multiple panes) was reduced to an empty layout.DELETE /currenttears down the editor session itself — same blast radius. Past incidents: AI probed withDELETE /currentto debug a 409Editor is not openerror; the call succeeded and the user lost their entire session.Neither has a valid AI use case. Use the granular endpoints above (
POST /current/tabs,DELETE /current/tabs,POST /current/splits, etc.) instead. Do not probe for additional methods — if the docs above don't list it, it isn't supported.
Step 11 — GET /events: SSE event stream (optional, for real-time sync)
curl -s -H "X-Api-Key: $API_KEY" "$BASE/events"
Emits { "type": "connected" } once, then { "type": "workspace_updated" } on every mutation. The container is typically the mutator, not the listener — this is mainly useful if you need to wait for another actor's change before proceeding.
Layout Structure Reference
The workspace is a binary tree:
- SplitNode —
{ type: "split", id, direction: "horizontal"|"vertical", ratio: 0.1–0.9, first: PanelNode, second: PanelNode } - LeafNode —
{ type: "leaf", id, tabs: PanelTab[], activeTabIndex: number } - PanelTab —
{ id, panelType: "<full-panel-id>", panelState?: { displayName?, displayIcon?, ... } }
Singleton Panels
These may only appear once in the entire workspace. Check the current layout before adding — if already present, tell the user rather than adding a duplicate.
| Full ID | Name |
|---|---|
adom/a1b2c3d4-1111-4000-a000-000000000001 |
3D Viewer |
adom/a1b2c3d4-0012-4000-a000-000000000012 |
Schematic Editor |
Panel Catalog, Tab Customization, and Panel-Specific Control
Full panel catalog (all panelType UUIDs for Development, Control, Visualization, Media, Utility), tab rename/icon patterns (MDI strings and base64 SVG data URLs), webview commands (navigate, refresh, set-header, set-proxy), Sandbox commands (eval, reset, set-header), and the full troubleshooting table: see panels-catalog-reference.md.
Quick links to the most common panelType UUIDs:
- webview:
adom/a1b2c3d4-0031-4000-a000-000000000031 - VS Code:
adom/a1b2c3d4-eeee-4000-a000-00000000000e - 3D Viewer (singleton):
adom/a1b2c3d4-1111-4000-a000-000000000001 - Schematic Editor (singleton):
adom/a1b2c3d4-0012-4000-a000-000000000012
Window mode: check before opening a window
Before opening a window (webview tab or Pup browser window), check the user's preference:
adom-cli hydrogen probe # → { ..., "window_mode": "webview" | "pup" }
"webview"(default) → open as a Hydrogen webview tab (useopen-or-refreshbelow)"pup"→ open via Adom Desktop (pup browser_open_window). If Adom Desktop isn't reachable, fall back to webview and warn.
Don't cache window_mode — read it from probe each time you're about to open a window so user toggles take effect immediately. Get/set via adom-cli hydrogen window-mode get|set.
webviews: use open-or-refresh for long-lived viewers
For any webview tab you create once and re-render many times (tscircuit viewer, log panel, custom dashboard), use the idempotent one-shot command — don't cache tabId/panelId:
# First call: creates the tab in the chosen pane
adom-cli hydrogen webview open-or-refresh \
--name "tscircuit viewer" \
--url "https://.../proxy/3000/" \
--panel-id <target-pane-id>
# Subsequent calls: navigates the existing tab, follows user's pane drags.
# --panel-id only needed on the create path.
adom-cli hydrogen webview open-or-refresh \
--name "tscircuit viewer" \
--url "https://.../proxy/3000/?v=$(date +%s)"
Lifecycle contract: The tab name is the stable handle. tabId is stable across pane drags, but the tab's panelId changes. Always re-resolve by --name on each call. If the user closed the tab, find-tab returns 404 loudly — open-or-refresh handles this by falling back to create (requires --panel-id). Response includes "action": "created" or "action": "refreshed".
See panels-catalog-reference.md for the remaining webview commands (navigate, refresh, set-header, set-proxy) and other panel types.
---
name: adom-workspace-control
description: Use when the user wants to open, close, add, or remove a panel; split a pane; move or swap tabs between panes; change the workspace layout; resize a split; bring a background tab to the foreground; show or hide a sensor view, camera, log, control panel, or any Adom editor panel; or asks "what panels are available". Examples: "open the 3D viewer", "add a webview tab", "close the robot log", "resize the split to 70/30", "split the pane horizontally", "move that tab to the other pane", "swap the two panes", "close that pane", "show the IMU sensor tab", "bring the webview to the front", "what panels can I open?".
user-invocable: true
---
# Adom Workspace Control
## 🛑 Never wipe the user's full Hydrogen layout — and never remove the VS Code tab 🛑
The danger is leaving the Hydrogen screen empty, or without VS Code. The
user panics because "everything is gone" and they can't tell what
happened.
### What's safe
- **Per-tab CLI operations** via `adom-cli hydrogen workspace ...`:
`add-tab`, `remove-tab`, `move-tab`, `swap`, `split`, `resize`,
`active-tab`, `close-panel`. These are bounded — each mutates one
tab/pane at a time. Use them freely.
- **GET** on the editor endpoint is fine (read-only).
### What's NOT safe
- **Never** call `curl`, `gh api`, `fetch`, or any raw HTTP client with
`PUT`, `PATCH`, or `DELETE` against
`https://hydrogen.adom.inc/api/workspaces/editor/<user>/<ws>/current`.
That endpoint is the WHOLE LAYOUT; any write replaces it wholesale and
wipes every panel, tab, and split.
- **Never remove the VS Code tab** — even via a safe CLI op — unless the
user explicitly asks. VS Code is where the user lives; removing it is
indistinguishable from "everything is broken" to them.
- **Never remove the last remaining tab** in a pane without the user
asking (pane goes blank; if it's the only pane, the workspace looks
wiped).
### Just-in-time confirmation: ALWAYS use AskUserQuestion before any authorized destructive op
Even when the user has explicitly asked for a destructive action
(workspace wipe, screenshot-the-empty-state dance, mass tab close,
anything that would leave the screen empty), call the harness-level
**`AskUserQuestion`** tool as the IMMEDIATE LAST ACTION before firing
the destruction. The user clicks "Proceed" and the destructive call
fires in the same tool-turn — no drift between click and wipe.
Why: the user is typing to Claude while you work. If you said "going
now" three tool calls ago, they've resumed typing; firing destruction
now eats whatever they're mid-drafting. `AskUserQuestion` is blocking
and synchronous in the Claude Code chat UI, but non-modal for the rest
of the workspace — the user finishes typing / saves their work, then
clicks. **Tested 2026-04-22: in-flight chat drafts survive the prompt
intact, so it's safe to use mid-conversation.**
Shape of the call:
```
AskUserQuestion({
questions: [{
question: "About to <specific destructive action>. Save any work you're typing first. Ready?",
header: "Confirm destruction",
multiSelect: false,
options: [
{ label: "Proceed — saved", description: "Fire the destructive operation immediately." },
{ label: "Cancel", description: "Abort; no change." }
]
}]
})
```
If the user picks "Cancel" or any "Other" text that isn't an
unambiguous yes, **ABORT**. Don't argue, don't retry.
Pair this with:
- A **disk-backed backup** of any state you're about to mutate (e.g.
`/tmp/hydrogen-layout-backup-<ts>.json` for a layout wipe) so you can
restore deterministically after.
- An **announced restore command** printed to the user so they can run
it themselves if something goes wrong before you get to restore.
### Incidents the rule comes from
**2026-04-22 (morning)** — AI probed the editor API for debug
(`curl -X PUT -d '{}'`, then `-X DELETE`), trying to understand a 409
state. Each call returned 204 and silently replaced the whole layout
with an empty one. User had to re-open VS Code from scratch. Filed as
hydrogen#322 for server-side guardrails.
**2026-04-22 (midday)** — AI was authorized to wipe-screenshot-restore
the layout for a Colby bug report but did not interpose a just-in-time
confirmation, so the user was typing mid-paragraph when the
destructive step was about to fire. AskUserQuestion is the fix for that
class of failure; before this rule existed, even an authorized
destructive op was unsafe because the destruction timing drifted
seconds-to-minutes after the user's verbal "go."
### Recovery if it already happened
1. **Tell the user immediately** — don't try to "quietly restore" a
guess; the guess will differ from what they had.
2. **Reload the Hydrogen page** via `adom-cli hydrogen reload` (broadcasts
SSE reload to all connected tabs). The client re-registers the
editor session with whatever default/saved layout Hydrogen boots.
If no browser is connected, ask the user to open the editor tab first.
3. If you had a disk-backed backup from step 1 of the destruction
preamble, restore from that via a single `PUT` with the saved JSON —
but again, call AskUserQuestion first to confirm the user is ready
and has saved anything else in flight.
4. **File a bug** capturing exactly what you did (method + URL + body)
so server-side guardrails can catch the next one — see hydrogen#322
for the template.
### Related feature requests (tracked upstream)
- [adom-inc/hydrogen#322](https://github.com/adom-inc/hydrogen/issues/322)
— "Detect wiped / empty workspace layout and offer a 'Restore default
layout' recovery banner" + server-side PUT/DELETE guardrails.
- [adom-inc/hydrogen#323](https://github.com/adom-inc/hydrogen/issues/323)
— Editor session 409 auto-recovery (separate failure mode that
misleadingly resembles a wipe).
---
## Architecture: Hydrogen vs VS Code vs Docker
```text
Browser
└── Hydrogen (workspace shell — panels, tabs, splits)
├── 3D Viewer panel
├── webview panels (shotlog, AV, etc.)
├── Sensor panels, camera feeds, etc.
└── VS Code panel (iframe → code-server running on Docker)
└── code-server (its own world, own API, own extensions)
```
**Hydrogen** is the outer workspace shell. The `adom-cli hydrogen workspace` API and the REST endpoints documented below control Hydrogen — adding/removing tabs, splitting panes, resizing, etc. These operate on the **panel layout**, not on what's inside any panel.
**VS Code (code-server)** runs inside the Docker container and is embedded as an iframe in one of Hydrogen's panels. Hydrogen cannot control what happens inside VS Code — it can only add/remove the VS Code panel itself. To control VS Code (open files, reveal in explorer, run commands), you must use code-server's own mechanisms:
- **Code-server remote CLI** (safe for opening files, NOT folders):
```bash
/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh --reuse-window /path/to/file.png
```
This opens a file in a new editor tab without changing the workspace root. **NEVER pass a folder path** — that changes the workspace root and breaks the user's session.
- **Code-server IPC socket** (`$VSCODE_IPC_HOOK_CLI`): Low-level Unix socket the CLI uses under the hood. POST `{ type: "open", fileURIs: [...] }`.
- **VS Code extensions**: A code-server extension can expose an HTTP API on a local port, giving Docker processes full access to the VS Code API (`vscode.window.showTextDocument()`, `vscode.commands.executeCommand('revealInExplorer')`, etc.).
**Key rule:** Hydrogen API = panel layout. Code-server CLI/extensions = VS Code internals. Don't mix them up.
---
Manipulate the Adom editor workspace: add/remove tabs, split/close panes, move/swap tabs between panes, resize splits, take screenshots, and query the layout.
## USE `adom-cli` — NOT curl
**`adom-cli` auto-detects auth, owner, and repo.** Use it instead of raw curl for all workspace operations. The curl examples further below are reference only — prefer the CLI commands.
```bash
# These just work — no setup, no API key, no owner/repo needed:
adom-cli hydrogen workspace tabs # list all tabs (flat)
adom-cli hydrogen workspace find-tab "Schematic Editor" # find by name
adom-cli hydrogen screenshot workspace # screenshot (auto-saved)
adom-cli hydrogen screenshot panel --name "Schematic Editor" # screenshot by name
adom-cli hydrogen workspace add-tab --panel-id <id> --panel-type "adom/..." --display-name "My Tab" --alert
adom-cli hydrogen workspace remove-tab --name "My Tab" # remove by name
adom-cli hydrogen workspace active-tab --name "Schematic Editor" # activate by name
adom-cli hydrogen workspace close-panel "My Tab" # close by name
```
**Name-based addressing:** Most commands accept `--name` to target tabs by display name instead of UUIDs. `workspace tabs` returns a flat list with names — no tree parsing needed.
## Sharing & Permissions
```bash
adom-cli hydrogen screenshot status # check what's available
adom-cli hydrogen sharing request --share tab # request tab sharing
adom-cli hydrogen sharing request --share screen --audio --reason "Recording demo" # screen + mic
```
## Screenshots, Recording, Audio, Captions, Alerts, Nav
Media and overlay commands are covered in a reference file: see [`media-and-alerts-reference.md`](media-and-alerts-reference.md) for:
- Screenshots (`adom-cli hydrogen screenshot workspace|panel|screen`, unified approval flow, `html2canvas` fallback)
- Screen recording (4 mandatory flags: `--share`, `--mic`, `--countdown`, `--reason`; smart-delta approval matrix; WebM output)
- Audio (mic enable/disable, recording, level/gain)
- Captions (DOM overlay via `adom-cli hydrogen caption`; always-on-top `adom-desktop desktop_caption` for native apps)
- Tab alerts (blink to get attention)
- Navigation bar (minimize/expand/toggle)
**Panel-specific APIs** (e.g. navigating a webview to a URL) are documented in separate skills under `.claude/skills/adom-panels/<panel-name>/SKILL.md`. See `.claude/skills/adom-panels/SKILL.md` for the full catalog and links.
## Environment (for raw curl — prefer adom-cli above)
All API calls require:
- **Base URL:** `https://hydrogen.adom.inc/api/workspaces/editor/{owner}/{repo}/current`
- **JSON parsing:** `jq` is **not available** in the container. Use `python3 -m json.tool` to pretty-print JSON output instead.
- **Auth:** `X-Api-Key` header. Obtain the key by reading `/var/run/adom/api-key` — this file is always present in the container, is read-only, and contains the token with no leading or trailing whitespace:
```bash
API_KEY=$(cat /var/run/adom/api-key)
```
Fall back to env vars (`ADOM_API_KEY`, `API_KEY`, `X-Api-Key`) only if that file is missing. Ask the user only as a last resort.
## Critical Rules — Read Before Doing Anything
- **NEVER `PUT` or `DELETE` the `/current` root.** Both are destructive at the workspace level:
- `PUT /current` replaces the entire layout and breaks all node IDs.
- `DELETE /current` tears down the editor session, wiping every pane and tab (including the user's VS Code session hosting this chat).
Mutate workspaces **only** through the granular sub-paths: `POST /current/tabs`, `DELETE /current/tabs`, `POST /current/splits`, `PATCH /current/splits/{id}`, `POST /current/moves`, `POST /current/swaps`, `DELETE /current/panels/{id}`. Build up the desired layout one operation at a time. If an operation you want isn't on that list, it isn't supported for AI use — do not probe with other methods to find out.
- **NEVER point a Hydrogen webview at a `http://localhost:<port>/` or `http://127.0.0.1:<port>/` URL.** The webview runs in the user's BROWSER, not inside the Docker container. From the browser's perspective, `localhost` is the user's host machine, not the container — so anything served by a container-local service (tsci dev on 3040, adom-tsci on 8850, shotlog on 8820, etc.) is unreachable via `localhost`. The tab loads, the iframe tries to fetch, and the user sees the tscircuit "loading cloud" placeholder or a blank page forever. **Always rewrite the URL to use the container's external proxy**:
```bash
# Canonical template is stored in the VSCODE_PROXY_URI env var, with {{port}} placeholder:
# VSCODE_PROXY_URI=https://john-workspace-8v0y8o3547h2.adom.cloud/proxy/{{port}}/
PROXY_URL="${VSCODE_PROXY_URI/\{\{port\}\}/8850}"
adom-cli hydrogen webview navigate --name "My Tab" "$PROXY_URL"
```
This matches every existing working tab (Video Post, Wiki publish, shotlog channels, adom-tsci instances) — they all use `https://<host>/proxy/<port>/...`. If you catch yourself typing `localhost:` or `127.0.0.1:` in a `webview navigate`, `add-tab --url`, or any `displayName`/`url` pair, you're about to hand the user a broken tab. The same rule applies in reverse for `curl` from inside the container — `curl http://127.0.0.1:<port>/` works from the container's own shell (that's how to self-test an HTTP endpoint), but the URL you PUT IN THE TAB must be the proxy URL. Two different contexts, two different URLs:
| Context | Correct URL |
|---|---|
| `curl` or `Bash` tool (runs in container) | `http://127.0.0.1:<port>/...` |
| Hydrogen webview `url=`, `navigate`, `add-tab` | `$VSCODE_PROXY_URI` with `{{port}}` substituted |
| Anything a browser fetches (img src, iframe src on a served page) | Proxy URL |
Shotlog's `shotlog open` already does the right substitution internally. `adom-cli hydrogen workspace add-tab --url` and `hydrogen webview navigate <url>` do NOT — you must supply the proxy URL. If you're unsure, `echo "${VSCODE_PROXY_URI/\{\{port\}\}/<port>}"` prints the correct URL to use.
- **NEVER ask the user to do something you can do yourself.** If you tell them "reload the shotlog tab and let me know what you see" or "refresh the webview," you've punted manual busywork onto the person you're supposed to be helping. That's a failure. Claude has tools for almost every workspace action:
- Refresh a webview: `adom-cli hydrogen webview refresh --name "<tab name>"` (returns 204 on success)
- Navigate to a URL: `adom-cli hydrogen webview navigate --name "<tab name>" --url "<url>"`
- Bring a tab to the front: `adom-cli hydrogen workspace active-tab --name "<tab name>"`
- Screenshot a panel: `adom-cli hydrogen screenshot panel --name "<tab name>"` (capture IS visible to you via the returned path)
- Open a tab on a specific pane: `adom-cli hydrogen workspace add-tab --panel-id <non-vscode-id> ...`
- Alert a tab: `adom-cli hydrogen alert ...`
- Send a desktop notification: `adom-cli hydrogen notify send --title "..." "body"`
- Force-reload all browser tabs: `adom-cli hydrogen reload` (hot-reload after deploy, recovery)
- Show/hide address bar: `adom-cli hydrogen webview set-header ...`
Before saying "check the tab", ask yourself: *can I refresh it for them? can I screenshot it to verify? can I bring it to the front?* If yes — do it. Only hand off to the user when:
(a) the action genuinely requires their eyes (taste judgements, choosing between layouts),
(b) their physical keyboard/mouse is needed (desktop app outside Hydrogen), or
(c) you've done the automated parts and need them to confirm the result matches intent.
If you find yourself about to type "let me know if X works," stop and actually check X. `curl`, `screenshot panel`, `state` endpoints — they exist so you don't have to outsource verification.
- **NEVER echo or display the API key** in tool output. Store it in a shell variable — never `cat` it on its own or log it.
- **Add before removing** when swapping tabs in a leaf — the API returns 400 if you try to remove the last tab, so always add the new tab first, then remove old ones.
- **Remove tabs from highest index first** when removing multiple tabs from a leaf, to avoid index shifting.
- **Re-fetch the layout before each batch of operations** — state can change between calls. Always confirm current node IDs and tab indices are still valid.
- **Be cautious when closing panes or removing VS Code tabs** — one VS Code panel instance is hosting this chat session. Closing it terminates the conversation. Since there is no reliable way to identify which specific VS Code instance is running the session, avoid closing panes that contain a VS Code tab or removing VS Code tabs unless the user explicitly requested it.
- **VS Code pane guard is now enforced by the CLI (v0.5.4+).** `adom-cli hydrogen workspace add-tab` and `webview open-or-refresh` automatically redirect away from any pane that hosts VS Code. If you pass a `--panel-id` that hosts VS Code, the CLI finds another pane (or auto-splits to create one) and adds a `_vscode_guard` field to the response explaining the redirect. **You no longer need the Python script below to avoid the VS Code pane** — the CLI handles it. The script is kept for reference only / for raw-curl callers who bypass the CLI.
If you're using `adom-cli`, just pass any `--panel-id` — even the VS Code one — and the guard will redirect silently. The old rule ("query the layout, find a non-VS-Code pane, target it") is still correct but no longer necessary to do manually.
```bash
# VS Code's panelType is the canonical 'eeee' UUID below.
VSCODE_PT="adom/a1b2c3d4-eeee-4000-a000-00000000000e"
# Walk every leaf in the layout. Return the first leaf whose tabs
# do NOT include VS Code. Fall back to the currently-focused pane
# only if every pane has VS Code (single-pane layout). When there
# are multiple non-VS-Code panes, prefer the one whose activeTab
# matches something webview-like, or just pick the first.
adom-cli hydrogen workspace get | python3 -c '
import json, sys
VSCODE_PT = "adom/a1b2c3d4-eeee-4000-a000-00000000000e"
layout = json.load(sys.stdin)
def leaves(node):
if "tabs" in node:
yield node
else:
if "first" in node: yield from leaves(node["first"])
if "second" in node: yield from leaves(node["second"])
focused = layout.get("focusedPanelId")
non_vscode = [l for l in leaves(layout["root"])
if not any(t.get("panelType") == VSCODE_PT for t in l["tabs"])]
if non_vscode:
# Prefer the focused pane IF it is itself non-VS-Code, otherwise
# take the first non-VS-Code pane in tree order.
picked = next((l for l in non_vscode if l["id"] == focused), non_vscode[0])
print(picked["id"])
else:
print("") # single-pane layout, caller must split or ask
'
```
Every CLI and every webapp that opens a Hydrogen tab MUST do this check. The `video-post` crate implements it in `src/webapp/tab.rs`; copy that pattern. **Do not trust `focusedPanelId` for target selection.** Do not use `adom-cli hydrogen workspace active-tab` to "fix" the wrong placement after the fact — by that point the user has already seen their chat get covered.
**Third-party CLIs that open their own tabs** — `shotlog open`, `adom-tsci start`, `av_status` tools, any webapp written before this rule existed — usually have *their own* unfiltered leaf-picker and will happily land on the VS Code pane. **Do not trust them.** Before running such a command, either:
1. Compute the non-VS-Code panel id yourself (snippet above), then pass it as `--panel-id <id>` *if the CLI supports that flag* (`adom-cli hydrogen workspace add-tab` does; `shotlog open` as of 2026-04 does NOT).
2. If the third-party CLI has no `--panel-id` flag: skip the wrapper entirely. Use `adom-cli hydrogen workspace add-tab --panel-id <non-vscode-id> --panel-type "adom/a1b2c3d4-0031-4000-a000-000000000031" --display-name "..." --url "<the underlying URL>"` directly — the wrapper's extra polish (icons, titles) is not worth the risk of covering VS Code.
3. If you already opened a tab on the wrong pane, **move it** via `POST /moves` (granular) rather than re-creating; don't leave it landed on VS Code "just this once."
Known problem URLs you can target directly when skipping wrappers:
- `shotlog`: `https://<wiki-host>/proxy/8820/log/<channel>/`
- `adom-tsci preview`: `http://127.0.0.1:<port>/`
- `AV`: `/proxy/8770/`
## Workflow
**Always follow this order when making changes:**
0. **Setup** — resolve credentials, repo, and target user (ask once, remember for the session)
1. **GET** the current layout to find leaf node IDs
2. Identify the correct leaf `id` for where the user wants the tab placed
3. Look up the `panelType` full ID from the catalog below
4. **POST/DELETE/PATCH** to make the change
5. Confirm success to the user
### Step 0 — Setup: resolve credentials, repo, and target user
At the start of the session, read the API key and auto-discover the owner and repo via the Carbon API:
```bash
API_KEY=$(cat /var/run/adom/api-key)
# Auto-discover owner and repo from the Carbon container registry
SLUG=$(echo "$VSCODE_PROXY_URI" | sed 's|.*-\([^.]*\)\.adom\.cloud.*|\1|')
CONTAINER_INFO=$(curl -s -H "X-Api-Key: $API_KEY" "https://carbon.adom.inc/containers/$SLUG")
OWNER=$(echo "$CONTAINER_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['repository']['owner']['name'])")
REPO=$(echo "$CONTAINER_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['repository']['name'])")
BASE="https://hydrogen.adom.inc/api/workspaces/editor/$OWNER/$REPO/current"
```
The slug is extracted from `$VSCODE_PROXY_URI` (everything after the last `-`, before `.adom.cloud`). The Carbon API returns the full container info including `repository.owner.name` and `repository.name`. **Do not ask the user** for owner/repo — always use this endpoint to resolve them automatically.
**Legacy containers:** If the Carbon API returns `{"error":"CONTAINER_NOT_FOUND"}`, this is likely a legacy container that predates the Carbon registry. In that case, fall back to asking the user:
> "What is the Adom owner (username or org) and Adom repository name for this project?"
As a last resort, you can try to parse `$VSCODE_PROXY_URI` (format `{owner}-{repo}-{slug}.adom.cloud`), but this is unreliable if the repo name contains hyphens.
Remember the answers for the rest of the conversation — do not ask again.
**Discover the target user.** Workspaces are scoped per-user. You must discover who has an open editor:
```bash
curl -s -H "X-Api-Key: $API_KEY" "$BASE/users" | python3 -m json.tool
# → { "users": [{ "username": "kcknox" }] }
```
- **One user** (most common): auto-detected by the server — no extra header needed. Set `TARGET_HEADER=""`.
- **Multiple users**: ask the user which person's workspace to modify. Remember the choice and set:
```bash
TARGET_HEADER='-H "X-Target-Username: <chosen-username>"'
```
- **Zero users**: the editor is not open in any browser. Tell the user to open the editor first.
Save `OWNER`, `REPO`, `API_KEY`, `BASE`, and `TARGET_HEADER` (if needed) in your memory file so you don't repeat this setup.
### Step 1 — GET current layout
```bash
curl -s -H "X-Api-Key: $API_KEY" $TARGET_HEADER "$BASE" | python3 -m json.tool
```
Parse the response to find:
- All `leaf` nodes and their `id` values (needed for adding/removing tabs)
- All `split` nodes and their `id` values (needed for resizing)
- The `tabs` array on each leaf and `activeTabIndex`
### Step 2 — POST: Add a tab
```bash
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"<full-panel-id>"}' \
"$BASE/tabs"
```
Returns `{ "tabId": "<uuid>" }` on success.
**Custom tab name and icon:** You can optionally set `displayName` and `displayIcon` to override the tab header. MDI icons are monochrome and inherit the theme color. For colored icons, use a custom SVG via `data:` URL.
**MDI icon example:**
```bash
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"adom/a1b2c3d4-0031-4000-a000-000000000031","displayName":"eBay","displayIcon":"mdi:cart"}' \
"$BASE/tabs"
```
**Custom SVG icon example (colored):**
```bash
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","panelType":"adom/a1b2c3d4-0031-4000-a000-000000000031","displayName":"RMFG","displayIcon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iIzRDQUY1MCIvPjwvc3ZnPg=="}' \
"$BASE/tabs"
```
- `displayName` — custom label shown on the tab (instead of the panel's default name)
- `displayIcon` — [MDI icon](https://icon-sets.iconify.design/mdi/) string (e.g. `mdi:github`, monochrome) **or** `data:` URL for custom colored SVG/image (e.g. `data:image/svg+xml;base64,...`)
- Both are optional and stored in `panelState`. Omit them to use the panel's default name/icon.
> If the user doesn't specify which panel (leaf) to add to and there is more than one leaf, ask them where they want it placed, or place it in the largest/most relevant leaf.
### Step 3 — DELETE: Remove tab(s) — 5 modes
Target the reference tab by `name` or `panelId + tabIndex`. The `mode` field controls what gets closed.
| Mode | Effect |
|------|--------|
| `close` *(default)* | Close just this tab |
| `others` | Close every other tab, keep this one (becomes active) |
| `all` | Close the entire pane (fails if it's the last pane) |
| `right` | Close everything to the right of this tab |
| `left` | Close everything to the left of this tab |
For `others` / `right` / `left`, the referenced tab becomes active. For `all`, the pane is removed and its sibling is promoted.
```bash
# Close a single tab (default)
curl -s -X DELETE \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"name":"Bug Review"}' \
"$BASE/tabs"
# Close all other tabs in the pane
curl ... -d '{"name":"Bug Review","mode":"others"}' "$BASE/tabs"
# Close the entire pane
curl ... -d '{"name":"Bug Review","mode":"all"}' "$BASE/tabs"
# Close tabs to the right / left
curl ... -d '{"name":"Bug Review","mode":"right"}' "$BASE/tabs"
curl ... -d '{"name":"Bug Review","mode":"left"}' "$BASE/tabs"
# By UUID + index (all modes work the same way)
curl ... -d '{"panelId":"<leaf-id>","tabIndex":<n>,"mode":"others"}' "$BASE/tabs"
```
Or via CLI: `adom-cli hydrogen workspace remove-tab --name "Bug Review" [--mode close|others|all|right|left]`.
> Cannot remove the last tab in a leaf via `mode: close` — API returns 400. Use `mode: all` to remove the whole pane instead (the sibling is promoted).
### Step 4 — PATCH: Resize a split
```bash
curl -s -X PATCH \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"ratio":0.3}' \
"$BASE/splits/<split-id>"
```
`ratio` is the fraction given to the **first** child (0.1–0.9). So "70/30" → `ratio: 0.7`.
### Step 5 — POST: Split a pane
Split an existing pane into two, creating a new split node.
```bash
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","direction":"horizontal","panelType":"<full-panel-id>"}' \
"$BASE/splits"
```
Returns `{ "panelId": "<new-leaf-id>", "tabId": "<new-tab-id>" }`.
| Field | Required | Description |
|-------|----------|-------------|
| `panelId` | yes | Leaf node to split |
| `direction` | yes | `"horizontal"` or `"vertical"` |
| `panelType` | yes | Panel type for the new pane's tab |
| `position` | no | `"before"` or `"after"` (default `"after"`) |
| `ratio` | no | Split ratio 0.1–0.9 (default `0.5`) |
| `displayName` | no | Custom tab header label |
| `displayIcon` | no | MDI icon string (e.g. `mdi:github`, monochrome) or custom SVG/image as `data:` URL — use `data:` for colored icons |
### Step 6 — POST: Move a tab between panes
Move a tab from one pane to another without creating a split. The moved tab is **auto-activated and scrolled into view** in the target pane — no separate activate call needed.
```bash
# By name (preferred)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourceName":"Bug Review","targetName":"Schematic Editor"}' \
"$BASE/moves"
# By UUID
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourcePanelId":"<leaf-id>","tabId":"<tab-uuid>","targetPanelId":"<other-leaf-id>"}' \
"$BASE/moves"
# With alert (blink the moved tab until clicked)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourceName":"Bug Review","targetName":"Schematic Editor","alert":true,"alertDuration":0}' \
"$BASE/moves"
```
Or via CLI: `adom-cli hydrogen workspace move-tab --source-name "Bug Review" --target-name "Schematic Editor" [--target-index N] [--alert] [--alert-duration SECS]`.
Returns `204 No Content`. If the moved tab was the last one in the source pane, the source pane is automatically removed.
| Field | Required | Description |
|-------|----------|-------------|
| `sourcePanelId` + `tabId` | yes* | Source tab (by UUID) |
| `targetPanelId` | yes* | Destination pane (by UUID) |
| `sourceName` | alt* | Resolves to `sourcePanelId` + `tabId` |
| `targetName` | alt* | Resolves to `targetPanelId` (pane containing that tab) |
| `targetIndex` | no | Insert position (appends if omitted) |
| `alert` | no | `true` to blink the moved tab in its new pane |
| `alertDuration` | no | Seconds to blink. `0` = indefinite until user clicks |
*Provide either UUIDs or names.
### Step 7 — POST: Swap content between panes
Swap all tabs between two panes.
```bash
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"sourcePanelId":"<leaf-id-1>","targetPanelId":"<leaf-id-2>"}' \
"$BASE/swaps"
```
Returns `204 No Content`.
### Step 8 — POST: Bring a tab to the foreground
Switch the visible tab within a pane. Use this when a pane has multiple tabs and you want to show one that is currently in the background.
```bash
# By tab index (0-based)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","tabIndex":2}' \
"$BASE/active-tab"
# By tab UUID (preferred — stable if tabs are reordered)
curl -s -X POST \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
-H "Content-Type: application/json" \
-d '{"panelId":"<leaf-id>","tabId":"<tab-uuid>"}' \
"$BASE/active-tab"
```
Returns `204 No Content`.
| Field | Required | Description |
|-------|----------|-------------|
| `panelId` | yes | Leaf node containing the tabs |
| `tabIndex` | one of | 0-based position of the tab to show |
| `tabId` | one of | UUID of the tab to show |
> When the user says "show the IMU sensor" or "switch to the Robot Log tab", GET the layout, find the leaf and tab by `panelType`, then call `POST /active-tab` with that leaf's `id` and the tab's `id`.
### Step 9 — DELETE: Close a pane
Remove a pane entirely. Its sibling is promoted to replace the parent split.
```bash
curl -s -X DELETE \
-H "X-Api-Key: $API_KEY" $TARGET_HEADER \
"$BASE/panels/<leaf-id>"
```
Returns `204 No Content`. Cannot close the last pane — the API returns 400.
### Step 10 — `PUT` and `DELETE` on `/current`: PROHIBITED
> **Do not `PUT` or `DELETE` the `/current` root.**
>
> - `PUT /current` replaces the entire workspace layout — every pane, every tab, every node ID. Past incidents: AI sent `PUT {}` to probe what the endpoint accepts; server happily 204'd and the user's workspace (VS Code + multiple panes) was reduced to an empty layout.
> - `DELETE /current` tears down the editor session itself — same blast radius. Past incidents: AI probed with `DELETE /current` to debug a 409 `Editor is not open` error; the call succeeded and the user lost their entire session.
>
> Neither has a valid AI use case. Use the granular endpoints above (`POST /current/tabs`, `DELETE /current/tabs`, `POST /current/splits`, etc.) instead. **Do not probe for additional methods** — if the docs above don't list it, it isn't supported.
### Step 11 — GET /events: SSE event stream (optional, for real-time sync)
```bash
curl -s -H "X-Api-Key: $API_KEY" "$BASE/events"
```
Emits `{ "type": "connected" }` once, then `{ "type": "workspace_updated" }` on every mutation. The container is typically the mutator, not the listener — this is mainly useful if you need to wait for another actor's change before proceeding.
## Layout Structure Reference
The workspace is a binary tree:
- **SplitNode** — `{ type: "split", id, direction: "horizontal"|"vertical", ratio: 0.1–0.9, first: PanelNode, second: PanelNode }`
- **LeafNode** — `{ type: "leaf", id, tabs: PanelTab[], activeTabIndex: number }`
- **PanelTab** — `{ id, panelType: "<full-panel-id>", panelState?: { displayName?, displayIcon?, ... } }`
## Singleton Panels
These may only appear **once** in the entire workspace. Check the current layout before adding — if already present, tell the user rather than adding a duplicate.
| Full ID | Name |
|---------|------|
| `adom/a1b2c3d4-1111-4000-a000-000000000001` | 3D Viewer |
| `adom/a1b2c3d4-0012-4000-a000-000000000012` | Schematic Editor |
## Panel Catalog, Tab Customization, and Panel-Specific Control
Full panel catalog (all panelType UUIDs for Development, Control, Visualization, Media, Utility), tab rename/icon patterns (MDI strings and base64 SVG data URLs), webview commands (`navigate`, `refresh`, `set-header`, `set-proxy`), Sandbox commands (`eval`, `reset`, `set-header`), and the full troubleshooting table: see [`panels-catalog-reference.md`](panels-catalog-reference.md).
Quick links to the most common panelType UUIDs:
- webview: `adom/a1b2c3d4-0031-4000-a000-000000000031`
- VS Code: `adom/a1b2c3d4-eeee-4000-a000-00000000000e`
- 3D Viewer (singleton): `adom/a1b2c3d4-1111-4000-a000-000000000001`
- Schematic Editor (singleton): `adom/a1b2c3d4-0012-4000-a000-000000000012`
### Window mode: check before opening a window
Before opening a window (webview tab or Pup browser window), check the user's preference:
```bash
adom-cli hydrogen probe # → { ..., "window_mode": "webview" | "pup" }
```
- `"webview"` (default) → open as a Hydrogen webview tab (use `open-or-refresh` below)
- `"pup"` → open via Adom Desktop (`pup browser_open_window`). If Adom Desktop isn't reachable, fall back to webview and warn.
Don't cache `window_mode` — read it from probe each time you're about to open a window so user toggles take effect immediately. Get/set via `adom-cli hydrogen window-mode get|set`.
### webviews: use `open-or-refresh` for long-lived viewers
For any webview tab you create once and re-render many times (tscircuit viewer, log panel, custom dashboard), use the idempotent one-shot command — don't cache `tabId`/`panelId`:
```bash
# First call: creates the tab in the chosen pane
adom-cli hydrogen webview open-or-refresh \
--name "tscircuit viewer" \
--url "https://.../proxy/3000/" \
--panel-id <target-pane-id>
# Subsequent calls: navigates the existing tab, follows user's pane drags.
# --panel-id only needed on the create path.
adom-cli hydrogen webview open-or-refresh \
--name "tscircuit viewer" \
--url "https://.../proxy/3000/?v=$(date +%s)"
```
**Lifecycle contract:** The tab **name** is the stable handle. `tabId` is stable across pane drags, but the tab's `panelId` changes. Always re-resolve by `--name` on each call. If the user closed the tab, `find-tab` returns 404 loudly — `open-or-refresh` handles this by falling back to create (requires `--panel-id`). Response includes `"action": "created"` or `"action": "refreshed"`.
See [`panels-catalog-reference.md`](panels-catalog-reference.md) for the remaining webview commands (`navigate`, `refresh`, `set-header`, `set-proxy`) and other panel types.