Adom Viewer

The Adom Viewer lets you send visual content from the Docker container to the Adom web app front-end so the user can see it in their browser. Content appears in an iframe panel with tabs, dark theme, and auto-reconnecting WebSocket.

What the user asked to display

$ARGUMENTS

Rule: Always Reload AV Yourself

After restarting the AV server or making any change that requires a browser refresh, you MUST reload AV yourself using av_reload. Never ask the user to refresh — that's busywork you handle for them. This is non-negotiable.

  1. Call av_reload (uses mgmt relay on port 8772, works even during server restart)
  2. If it reports 0 viewers connected, wait 3 seconds and try once more
  3. Only if all retries fail, say: "I tried to reload AV for you automatically, but your viewer tab isn't connected right now. As a last resort, could you refresh the AV panel?"

Rule: Always Open AV Panel If Not Connected

Before displaying content, check if any AV viewer is connected (e.g. via av_status). If zero viewers are connected, the AV panel is not open — open it yourself using the workspace-control API. See adom-workspace-control.md for the full workspace API reference (environment setup, auth, layout structure, all endpoints).

Target layout: VS Code on the left half, Adom Viewer on the right half (50/50 horizontal split). Always split the pane rather than adding a tab to the existing leaf — the user should see both VS Code and AV side by side.

  1. Read the API key: API_KEY=$(cat /var/run/adom/api-key)
  2. Auto-discover owner/repo by extracting the slug from $VSCODE_PROXY_URI (everything after the last -, before .adom.cloud) and calling Carbon:
    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'])")
    
    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, ask the user for their owner and repo name. 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.
  3. GET the current layout to find the leaf node ID containing VS Code
  4. Split the VS Code pane horizontally to create a new pane on the right with a Web View tab:
    curl -s -X POST -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
      -d '{"panelId":"<vscode-leaf-id>","direction":"horizontal","position":"after","ratio":0.5,"panelType":"adom/a1b2c3d4-0031-4000-a000-000000000031","displayName":"Adom Viewer","displayIcon":"mdi:eye"}' \
      "$BASE/splits"
    
    This returns { "panelId": "<new-leaf-id>", "tabId": "<new-tab-id>" }.
  5. Navigate the new Web View to the AV URL:
    curl -s -X PATCH -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
      -d '{"panelId":"<new-leaf-id>","action":"navigate","url":"https://<av-url>.adom.cloud/"}' \
      "https://hydrogen.adom.inc/api/panels/webview/$OWNER/$REPO"
    
  6. Wait 3 seconds for the WebSocket to connect, then proceed with av_display

For a specific AV instance, append ?instance=<id> to the URL and use the matching displayName/displayIcon from INSTANCE_PRESETS.

Never tell the user "please open the AV panel" — open it yourself.

Supported content types

Type Extensions / format Notes
SVG .svg, raw SVG markup Vector graphics, diagrams, schematics
Images .png, .jpg, .jpeg, .gif, .webp Raster images
HTML .html, .htm Interactive HTML with <script> tags runs in a sandboxed iframe
Markdown .md, .markdown Rendered client-side in the viewer
iframe_url URL string Embeds a live web app in a real iframe (not sandboxed srcdoc)

How to display content

Option A: Display an existing file

Use the av_display_file MCP tool:

mcp__adom-viewer__av_display_file({ file_path: '/absolute/path/to/file.svg', title: 'My Diagram' })

This auto-detects the content type from the file extension.

Option B: Display generated content

Use the av_display MCP tool:

mcp__adom-viewer__av_display({
  content_type: 'html_interactive',  // or 'svg', 'image', 'html', 'markdown'
  content: '<html>...</html>',
  title: 'My Visualization'
})

For images, provide a data URI: data:image/png;base64,...

Option C: Internal HTTP API fallback

If MCP tools are not available, POST directly to the Adom Viewer internal API:

const http = require('http');
const crypto = require('crypto');

const payload = JSON.stringify({
  action: 'display',
  content: {
    contentType: 'svg',  // 'svg' | 'image' | 'html' | 'html_interactive' | 'markdown'
    content: '<svg>...</svg>',
    title: 'My Diagram',
    id: crypto.randomUUID()
  }
});

const req = http.request({
  hostname: '127.0.0.1',
  port: 8771,
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
}, (res) => {
  let data = '';
  res.on('data', c => data += c);
  res.on('end', () => console.log(res.statusCode, data));
});
req.write(payload);
req.end();

Embedding Live Web Apps (iframe_url)

Use iframe_url to embed a running web server (e.g., a review dashboard, admin panel, or dev tool) as an interactive iframe in AV. Unlike html_interactive (which uses srcdoc/blob), iframe_url sets the iframe's src to a real URL, so the embedded app can make network requests back to its own server.

# Push a live web app to AV
curl -s http://127.0.0.1:8771 -X POST \
  -H "Content-Type: application/json" \
  -d '{"action":"display","content":{"contentType":"iframe_url","content":"/proxy/3041/","title":"Review Server","id":"review","source":"my-skill"}}'

CRITICAL: Proxy-Safe URLs

Web apps embedded via iframe_url are loaded through the coder reverse proxy (e.g., /proxy/3041/). All URLs in the embedded page MUST be relative, never absolute.

Pattern Result
fetch('/api/status') BREAKS — resolves to coder root, not the app
fetch('api/status') WORKS — resolves relative to /proxy/3041/

This applies to all URL types: fetch(), <a href>, <form action>, <img src>, WebSocket URLs, etc. When building or reviewing any web server intended for AV embedding, audit all URLs for this.

Replacing Tabs

To update an embedded app (e.g., after restarting its server), close the old tab first:

# Close old tab, then push fresh one
curl -s http://127.0.0.1:8771 -X POST -H 'Content-Type: application/json' \
  -d '{"action":"close_tabs","title":"Review Server","source":"my-skill"}'
curl -s http://127.0.0.1:8771 -X POST -H 'Content-Type: application/json' \
  -d '{"action":"display","content":{"contentType":"iframe_url","content":"/proxy/3041/","title":"Review Server","id":"review","source":"my-skill"}}'

Built-in interactive views

The viewer dropdown menu includes built-in interactive views for several skills. These are not MCP tools — they're part of the viewer UI itself.

View Description
JLCPCB Search Interactive component search with rich cards showing images, pricing, stock, attributes. Search directly from the AV search bar, or results auto-appear when Claude uses jlcpcb_search/jlcpcb_category_search/jlcpcb_list from any channel. Click a component image to open a lightbox with front/back/blank views. Links to JLCPCB parts page (EDA symbol/footprint/3D), LCSC product page, and datasheet.
Google Chat Architecture overview and capabilities of the Kel Google Chat integration.
Service Dashboards Per-service status pages accessible from the "Standalone Services" and "Local Services" dropdown groups. Each dashboard shows the service's full public URL (click to copy), live health check with details, links to repo/editor/health endpoint, MCP server name, and container info. Services are registered in the SERVICE_REGISTRY object in index.html.

When adding a new skill to the gallia repo, always add a matching menu item to the Adom Viewer dropdown so users can discover it.

Tab Groups and Tab Management

What are tab groups?

Tab groups let you bundle related tabs (e.g., PCB + Schematic + 3D views of the same component) under a shared label with a single close button. The viewer renders the group name as a blue-bordered label in the tab bar.

Creating tab groups with av_display

Pass the group and source parameters:

av_display(
  content_type: "svg",
  content: "<svg>...</svg>",
  title: "PCB",
  group: "connector:1710000000",
  source: "tscircuit"
)
av_display(
  content_type: "svg",
  content: "<svg>...</svg>",
  title: "Schematic",
  group: "connector:1710000000",
  source: "tscircuit"
)
  • group — Tabs with the same group value are visually bundled. Use "name:timestamp" format for unique groups (e.g., "JST_PH_K_1x02:1710000000"). The part before : becomes the displayed group label.
  • source — Identifies who created the tab (e.g., "tscircuit", "sym-creator"). Used by av_close_tabs for filtering. Defaults to "av_display" if omitted.

Closing tabs

av_close_group — Remove all tabs in a named group:

av_close_group(group: "connector")

Matches groups with the given name OR name:* prefix (e.g., "connector" removes "connector:1710000000").

av_close_tabs — Remove tabs matching ALL provided filters (AND logic):

av_close_tabs(title: "PCB", source: "tscircuit")

Provide at least one of: id, title, source. All provided filters must match for a tab to be removed.

Replace-before-push pattern

When updating content, close old tabs first to avoid duplicates:

# Close previous group, then push new tabs
av_close_group(group: "mycomponent")
av_display(content_type: "svg", content: "...", title: "PCB", group: "mycomponent:1710000001", source: "my-skill")
av_display(content_type: "svg", content: "...", title: "Schematic", group: "mycomponent:1710000001", source: "my-skill")

For single-tab replacement:

av_close_tabs(title: "Preview", source: "my-skill")
av_display(content_type: "svg", content: "...", title: "Preview", source: "my-skill")

Other MCP tools

Tool Purpose
av_close_group Close all tabs in a named group (matches name or name:* prefix)
av_close_tabs Close tabs matching ALL provided filters: id, title, source (AND logic)
av_switch_tab Switch to a specific tab by index
av_screenshot_paste Open clipboard screenshot paste utility in the viewer
av_clear Clear all displayed content from the viewer
av_status Check if the viewer is connected
av_reload Force browser to reload the AV page (survives server restarts)
av_capture Capture a screenshot of just the AV panel content
av_tab_capture Capture the full browser tab via Screen Capture API (captures everything including nested iframes)
av_set_camera Set the 3D camera position (alpha, beta, radius, target)
av_set_view Set a named camera preset (front, back, top, bottom, etc.)
av_set_bottom_light Toggle/set the bottom light for inspecting underside features
av_stop_tour Stop the cinematic camera tour, freeze camera

Management relay (port 8772)

A separate lightweight server (mgmt-server.js) runs on port 8772, independent of the main AV server (8770/8771). The browser connects to BOTH WebSockets. This solves two problems:

  1. Forced reload after server restart — when you restart the main AV server (for HTML/JS changes), the main WS breaks. The mgmt relay stays alive, so av_reload can tell the browser to reload and pick up the new code. No more manual reloading.

  2. AI-initiated screenshot captureav_capture asks the browser to screenshot whatever it's showing and returns the image. Use this to see what the user sees without asking them to screenshot manually. The capture uses a 5-strategy fallback powered by html2canvas: (1) postMessage for 3D/canvas iframes, (1b) html2canvas on cloned iframe DOM for html_interactive widgets, (2) SVG serialization, (3) <img> capture, (4) html2canvas on parent DOM. See the av-creator and adom-screenshot skills for full details.

Tab capture (Screen Capture API)

av_tab_capture captures the entire browser tab as a pixel-perfect screenshot using the browser's Screen Capture API (getDisplayMedia). This is the same API used by video conferencing apps.

When to use av_tab_capture vs av_capture:

  • av_capture — captures just the AV panel content. Fast, no setup. Works on all content types including html_interactive iframes (via html2canvas). Fails on multi-pane layouts (LibView 3-pane) where content spans nested iframes.
  • av_tab_capture — captures the full browser tab (IDE + editor + AV + everything). Works on all content including nested iframes. Requires one-time user setup.

Setup: The user must open the capture companion tab once per session:

  1. Click the camera button (next to forward/back) in AV → opens the capture tab
  2. Click "Share This Tab" → select the IDE tab in the browser dialog
  3. The capture stream persists even when AV reloads — the companion tab stays connected

How it works: The companion tab holds a live getDisplayMedia video stream of the shared tab. When av_tab_capture is called (or the user clicks the AV camera button), the server relays a request to the companion tab, which grabs a single frame from the video stream and sends it back. The stream has near-zero CPU cost when idle — frames are only decoded on demand.

Screenshots are saved to project-content/screenshots/av/.

Usage pattern after editing AV code:

1. Edit viewer HTML/JS
2. Restart AV server: pkill -f 'node.*viewer/server.js' && node ~/gallia/viewer/server.js &
3. Call av_reload (via MCP) → browser reloads with new code
4. Push content as usual (av_display, show_instapcb, etc.)
5. Call av_capture to verify the result visually

Direct API (curl):

# Force reload
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' -d '{"action":"reload"}'

# Capture screenshot (returns base64 PNG + saves to screenshots dir)
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' -d '{"action":"screenshot"}'

# Broadcast arbitrary message to all viewers (forwarded via WebSocket)
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' \
  -d '{"action":"broadcast","message":{"type":"stop_tour"}}'

# Set camera angle and enable FR4 board (for thumbnail capture)
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' \
  -d '{"action":"broadcast","message":{"type":"set_camera","alpha":3.3,"beta":0.8,"radius":1.3}}'
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' \
  -d '{"action":"broadcast","message":{"type":"set_fr4","visible":true}}'

# Health check
curl http://127.0.0.1:8772/health

The broadcast action sends any JSON message to all connected viewers via WebSocket. The viewer's index.html forwards recognized 3D control messages (stop_tour, start_tour, set_fr4, toggle_fr4, set_camera, show_origin, set_view, toggle_bottom_light, set_bottom_light, toggle_pads, set_pads) to the active 3D iframe — routing to the library-review iframe when in LibView mode, or directly to the standalone 3D iframe otherwise. This enables programmatic thumbnail capture workflows (e.g., for wiki 3D component pages).

Note: Prefer the dedicated MCP tools (av_set_camera, av_set_view, av_set_bottom_light, av_stop_tour) over raw broadcast — they provide better error handling and cleaner API.

3D debug/camera commands:

# Show XYZ origin axes (red=X, green=Y, blue=Z)
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' \
  -d '{"action":"broadcast","message":{"type":"show_origin","visible":true}}'

# Set camera to a named view (front, back, left, right, top, bottom, isometric)
curl -X POST http://127.0.0.1:8772/command -H 'Content-Type: application/json' \
  -d '{"action":"broadcast","message":{"type":"set_view","view":"front"}}'

The mgmt-server is started automatically by install.mjs and should rarely need restarting.

Screenshot paste

The av_screenshot_paste tool opens an interactive paste utility in the viewer. The user can paste screenshots from their clipboard (Ctrl+V / Cmd+V) and they are automatically saved as PNG files to project-content/screenshots/ inside the Docker container. This is the fastest way to get screenshots from the user's browser into the container — one step instead of saving to a file and drag-dropping.

mcp__adom-viewer__av_screenshot_paste({ title: 'Paste Screenshots' })

Viewer panel setup

The Adom Viewer page is displayed inside a Web View panel in the Adom app. If av_status reports 0 connected viewers, the user needs to set this up:

  1. Ensure the AV port (8770) has a DNS mapping via adom-cli carbon containers port-add. This gives it a subdomain like https://<av-url>.adom.cloud/.
  2. In the Adom app, add a Web View panel and enter the AV URL:
    https://<av-url>.adom.cloud/
  3. The panel persists across Adom app reloads — this only needs to be done once.

AV Instances — Multiple Independent Viewer Panels

A single AV server (port 8770) supports unlimited independent AV panels. Each panel is an AV instance with its own tabs, nav history, dropdown state, and content — no cross-contamination.

How it works

Each AV panel gets a unique URL via the ?instance=<id> query parameter:

/proxy/8770/?instance=sym     → Symbols panel
/proxy/8770/?instance=fp      → Footprints panel
/proxy/8770/?instance=3d      → 3D Models panel
/proxy/8770/?instance=sch     → Schematics panel
/proxy/8770/                  → Default (main) panel

The server routes content to specific instances — when you call an MCP tool with instance: "sym", only the Symbols panel receives it.

Instance presets

Known instance IDs auto-configure panel name and default view:

Instance ID Panel Name Default View
sym Symbols SymView
fp Footprints FpView
fp3d 3D Pads Fp3dView
3d 3D Models 3dView
sch Schematics SchView
lib Libraries LibView
search Search JlcSearch

Custom instance IDs work too — pass ?instance=myid&name=My Panel for a custom name.

Using instances with MCP tools

All AV MCP tools accept an optional instance parameter:

mcp__adom-viewer__av_display({ html: "...", instance: "sym" })   → sends to Symbols panel only
mcp__adom-viewer__av_display({ html: "..." })                    → sends to all panels

AV2 (DEPRECATED)

AV2 (separate ports 8790/8791/8792) is deprecated. Use AV instances instead — just open another Web View panel with ?instance=<id>. The adom-viewer-2 MCP server has been removed.

Adom Theme — styling guide for widgets

All Adom Viewer widgets MUST use the Adom theme for consistent styling that matches the Adom app interface. The theme is defined in ~/gallia/viewer/adom-theme.js.

Using the theme in widget generators (JS modules)

import { THEME, themeStyleTag, toolbarCSS, tooltipCSS, infoPanelCSS } from './adom-theme.js';

const html = `<!DOCTYPE html>
<html><head>
${themeStyleTag()}  <!-- injects CSS vars + body reset -->
<style>
  ${toolbarCSS()}   <!-- standard toolbar -->
  ${tooltipCSS()}   <!-- hover tooltips -->
  ${infoPanelCSS()} <!-- bottom info panel -->
</style>
</head>
<body>...</body></html>`;

Or reference tokens directly in template literals:

`background: ${THEME.bg}; color: ${THEME.text}; border: 1px solid ${THEME.border};`

Color tokens (CRITICAL — always use these, never invent new colors)

Token Value Usage
bg #0d1117 Page / deepest background
bgSurface #161b22 Toolbars, cards, panels
bgElevated #1c2128 Hover states, elevated cards
bgOverlay #21262d Tooltips, modals
border #30363d Standard borders
borderMuted #21262d Subtle/inner borders
text #e6edf3 Primary text
textSecondary #8b949e Labels, secondary text
textMuted #484f58 Hints, disabled, placeholders
accent #00b8b0 Primary teal accent
accentBright #00e6dc Hover/focus highlight
accentMuted rgba(0,184,176,0.12) Tinted backgrounds
success #3fb950 Success states
warning #d29922 Warning states
danger #f85149 Error states

Rules

  1. Never use the old palette (#1a1a2e, #16213e, #0f3460, #2a2a40, #e0e0e0, #00b8b1, #e04040, #f0a030, #4caf50). These are deprecated.
  2. All widget HTML must set its own background to #0d1117 (or use themeStyleTag()). Widgets render in sandboxed iframes — they cannot inherit styles from the parent viewer.
  3. Use the font stack: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif (available as THEME.fontStack).
  4. Semantic colors only for status: green=success, amber=warning, red=error. Never use red/green for decorative purposes.
  5. Border radius: 4px (small), 6px (medium), 8px (large) — available as THEME.radiusSm/Md/Lg.

3D Model Display

Use the av_3d_display MCP tool to display GLB files in the viewer's built-in Babylon.js 3D view:

mcp__adom-viewer__av_3d_display({
  glb_path: '/path/to/model.glb',
  body_size: { x: 4.4, y: 5.0, z: 1.2 },
  part_name: 'PART_NAME',
  manufacturer: 'Manufacturer',
  package_type: 'TSSOP-14',
  pad_count: 14,
  title: 'PART 3D Model'
})

3D content appears as a separate tab alongside HTML tabs. The viewer includes orbit camera, ground plane, skybox, and laser-etched chip markings.

Important: The 3D tab only appears when av_3d_display is called — av_display_file with a .glb path does NOT activate the 3D viewer. Always use av_3d_display for GLB files.

Synchronous load + immediate capture

av_3d_display blocks until the model is fully loaded and rendered (up to 15s timeout). This means you can call av_capture immediately after — no sleep needed:

av_3d_display({ glb_path: '/tmp/model.glb', skip_tour: true })
av_capture()  // model is already loaded — screenshot is immediate

3D camera and lighting controls

After loading a model, use these tools to control the view:

av_stop_tour()                           // freeze the cinematic tour
av_set_camera({ beta: 2.8, alpha: 0.5 }) // look from below
av_set_bottom_light({ enabled: true })   // illuminate underside
av_capture()                             // screenshot
av_set_view({ view: 'isometric' })       // named preset
av_capture()                             // another screenshot

Camera angles (Babylon.js ArcRotateCamera):

  • alpha = azimuth (orbit around vertical). 0 = front, π/2 = right side
  • beta = elevation. 0 = top-down, π/2 = eye-level, π = bottom-up
  • radius = zoom multiplier on current distance
  • absolute_radius = set exact distance

Bottom light: Illuminates the underside of components. Useful for inspecting board pads, vias, heatsink copper, and under-chip features that are normally in shadow.

Widget Design Guidelines

When generating interactive HTML widgets for the viewer, follow these rules:

Hover Highlight Rule (CRITICAL)

When any element has a tooltip, it MUST visually highlight on hover so the user knows what the tooltip refers to. Add a visible change on mouseenter (e.g., brighter border, glow, opacity change) and revert on mouseleave. Never show a tooltip on an element that doesn't change appearance on hover.

Minimum Font Sizes

Bottom panels and info bars must be readable at a glance:

  • Info values / legend text: 13px minimum
  • Source / provenance text: 12px minimum
  • Labels / secondary text: 11px minimum
  • Never use 9-10px for any user-facing text in panels

Explain Unusual Features

When a widget shows something unusual or non-obvious (e.g., oval through-hole pads, overlapping pads), add a hover tooltip explaining why. Users who aren't familiar with the design pattern should be able to learn from the viewer.

General Guidelines

  • Existing files: If the user provides a file path, use av_display_file directly.
  • Generated content: If the user asks you to create something (a chart, diagram, dashboard, etc.), generate it as HTML or SVG, write it to a temp file, then display it.
  • Interactive HTML: Use html_interactive content type when the content includes <script> tags or needs JavaScript execution. Use html for static HTML.
  • Styling: The viewer has a dark background (#0d1117). All generated HTML widgets MUST use the Adom theme colors from viewer/adom-theme.js. Import and use THEME constants or themeStyleTag() — never hardcode colors.
  • Large content: Files over 5MB may be slow to render. Warn the user if content is very large.
  • No viewer connected: If 0 viewers are connected, content is queued and will appear when the user opens the viewer panel. Guide the user through the panel setup steps above.
  • After editing viewer HTML/JS and restarting the AV server: Always call av_reload to refresh the user's browser panel. Never tell the user to refresh manually — use the tool.