name: bridge-dev-template user-invocable: false description: "TEMPLATE / reference for building, publishing, and maintaining ANY Adom Desktop bridge (the cloud Claude/Codex thread that owns a bridge copies this into its own dev-skills/ and fills it in). NOT a user-facing skill — general users want the bridge's own user skill. Read this when editing bridge code, cutting a new version, publishing to the wiki, wiring a runtime/heavy-dependency cold-start self-heal, or understanding the pkg-vs-release-vs-bundled artifact model. Trigger words: bridge dev, build adom-desktop bridge, publish bridge, ship bridge, bridge_install, adom-desktop--bridge, cold-start self-heal, bridge readiness internals, bridge release, bridge manifest, updateManifestUrl, pkg vs release vs bundled."

bridge-dev-template — building & shipping an Adom Desktop bridge

The canonical source of truth for the <bridge> bridge is its wiki repo (wiki.adom.inc/adom/adom-desktop-<bridge>-bridge), cloud-owned (like the kicad/fusion/puppeteer bridges) so bridge iteration is decoupled from adom-desktop core. Read CLAUDE.md (ownership boundary) and PUBLISHING.md (step-by-step recipe) in this repo alongside this skill.

The THREE artifacts — know which is which (this is the #1 confusion)

A user installs TWO different things from this one page. They are not the same:

Artifact What it is Who installs it How
pkg tarball (Packages tab) the container-side Claude/Codex skills (SKILL.md + skills/*) — docs that teach the cloud AI how to drive <bridge>. No bridge runtime. a cloud container adom-wiki pkg install adom/adom-desktop-<bridge>-bridge, or auto via AD's sync_skills
release zip (Releases tab) the bridge RUNTIME (entrypoint + bridge code + bridge.json + BRIDGE_VERSION; heavy deps reconstructed on spawn) — the code that actually runs on the desktop Adom Desktop, streamed into bridges-cache\<bridge> bridge_install {manifestUrl} / refresh_bridges (via updateManifestUrl)
bundled seed (inside the AD installer) a copy of the same runtime, shipped in the NSIS for first-run/offline every AD install ships with Adom Desktop; superseded by a newer cache copy (cache-over-bundled, numeric version)

So: the pkg is skills (container). The bridge is the zip (desktop) + the bundled seed. Keep the pkg description saying exactly that, or people think the tarball contains the bridge — it does not.

How <bridge> reaches a container — auto-discovery → auto-install → verbs

The full chain when a user on a fresh container says "":

  1. Discovery. The wiki's discover index (fed by this page's discovery_triggers — which should lead with your bridge's natural phrases, e.g. its short name + the top user actions) matches the phrase. The container's regenerated adom-wiki-discover skill surfaces this page, so the AI proposes it.
  2. Auto-install the pkg. The AI runs adom-wiki skills install adom/adom-desktop-<bridge>-bridge (a.k.a. pkg install). That extracts the tarball and runs install.sh, which drops SKILL.md + skills/* into both ~/.claude/skills/ and ~/.codex/skills/. The <bridge> user skill is now loaded.
  3. The skill calls the bridge. The user skill's SKILL.md tells the AI to run adom-desktop <prefix>_* verbs. AD routes every <prefix>_* verb to the <bridge> bridge.
  4. The bridge runtime. It's already on the desktop: AD ships <bridge> as a bundled seed (and supersedes it from this page's release zip via updateManifestUrl / bridge_install). On a truly fresh PC the cold-start self-heal installs <your runtime / heavy dependency> on first use.

Two install paths, keep BOTH healthy:

  • Discovery path (above) — works once discovery_triggers include the user phrases + the discover index is regenerated. This is the "user said <bridge> → AI installs the skill" path.
  • AD sync_skills path — AD's daily maybe_background_skill_sync reads bridge_list → each bridge's skillPkgpkg installs it into the container. For this to work AD must report skillPkg = adom/adom-desktop-<bridge>-bridge for your bridge. AD now derives that slug from the bridge's docs field automatically, except for any bridge hardcoded into AD_CORE_SKILL_BRIDGES in bridge_registry.rs (that list forces skillPkg = null for bridges whose skill historically lived in the AD-core pkg). If your bridge's skill ships from THIS page (not core), make sure your name is NOT in that list — if it is, removing it is an AD-core handoff item.

Repo layout

  • src/ — the runnable bridge (what the release zip contains, at zip-root). Entrypoint + bridge modules, bridge.json, BRIDGE_VERSION, dependency manifest/lockfile, any html/icons the runtime needs.
  • SKILL.md (root) — the user skill (name: <bridge>). Deployed to ~/.claude/skills/adom-desktop-<bridge>-bridge/.
  • dev-skills/<bridge>-bridge-dev/ — THIS dev skill. dev-skills/<bridge>-bridge-publish/ — the publish recipe. Both are source-only (in dev-skills/, NOT skills/): NOT in the installable pkg and NOT on the wiki Skills tab — you get them via adom-wiki repo clone. Everyday users only get the <bridge> user skill.
  • adom-bridge-<bridge>-manifest.json (root) — the streaming manifest AD's updateManifestUrl points at.
  • package.json (root) — the adompkg descriptor (slug/version/type/description) for pkg publish. NOT a language/runtime package descriptor (e.g. not the node package.json) — keep them distinct.
  • install.sh / uninstall.sh — deploy SKILL.md + skills/* into the container's ~/.claude/skills/.
  • page.json, README.md, *.png heroes — the wiki page presentation (the heroes are gitignored so the container pkg stays lean — they're already uploaded as page assets).

Cold-start self-heal — the architecture pattern (the reason a cloud-owned repo earns its keep)

On a fresh PC the installer pre-installs neither your bridge's language runtime nor its heavy dependency. Without a self-heal, the first <prefix>_<verb> fails with a generic launch error + a dev string. The pattern that fixes it — adapt the names to your runtime:

  • A detect/install/ready moduledetectDependency() (validate the ACTUAL artifact, not "a dir exists"), installDependency() (programmatic, pinned to the exact version your code expects — never a floating 'latest'/'stable', or the launch path won't find what it pinned against), an install-state singleton (dedup concurrent installs + report progress), ensureReady() (the gate — kicks off a background install and returns immediately), readiness() (powers the verb + the status chip).
  • The first action verb gates on readiness: missing dependency → kick off a non-blocking install + return {errorCode:"<dependency>_installing", statusVerb:"<prefix>_readiness", _hint:"poll then retry"}. Install fail → a specific <dependency>_install_failed / <dependency>_download_failed error — never silently continue into a doomed launch.
  • A <prefix>_readiness verb (poll) + a <prefix>_prewarm verb (install/warm without doing the user-visible action).
  • /status (or your healthEndpoint) emits led/summary/tooltip truthfully (yellow installing, red failed, green ready). AD paints these verbatim.
  • The language runtime itself is handled by AD core BEFORE the bridge spawns (e.g. a node_not_founddesktop_install_node flow) — NOT your bridge's job. The bridge can't bootstrap the very runtime it needs to run; AD installs the runtime, then spawns you.
  • The one thing the bridge can't do: pre-warm before first use (it isn't running until the first call). AD/HD must call <prefix>_prewarm on embedded first-run — track that as a handoff item.

Worked example (pup's case): the puppeteer bridge's runtime dependency is Chrome for Testing. Its module is src/chrome.js (detectChrome / installChrome via the bundled @puppeteer/browsers pinned to puppeteer's expected buildId / ensureChromeReady / readiness); browser_open_window is the gated first verb (CfT path only; its native-drive path skips the gate), returning chrome_for_testing_installing; and it exposes browser_readiness + browser_prewarm. Node is the language runtime AD core installs first (node_not_founddesktop_install_node). That's the concrete shape of every bullet above.

Caller provenance: knowing WHICH AI thread asked

A typical user has ~20 AI conversations pointed at one desktop. Since AD v1.9.180 every relayed command carries the identity of the conversation that made it, and AD hands that identity down to your bridge. (Full contract: AD's adom-desktop-caller-identity skill.)

1. What you receive for free. AD stamps four headers on every request it dispatches to your bridge. No manifest field, no opt-in, nothing to declare:

Header Carries
X-Adom-Caller-Thread the AI conversation/tab name, e.g. chip-fetcher tab 3
X-Adom-Caller-Container the container that thread runs in
X-Adom-Caller-Reason the per-call justification, when the caller supplied one
X-Adom-Caller-Delegate present when the call itself came second-hand (another bridge or Hydrogen relayed it)

Read them at the top of your request handler, and always code for absent (|| 'unidentified'): a local CLI call, an older caller, or your own internal timer will have none.

2. Log the thread. Highest value per line of code in this whole section, and it is three lines:

const thread = req.headers['x-adom-caller-thread'] || 'unidentified';
log(`[verb] <prefix>_eval session=${sessionId} caller="${thread}"`);

Why it matters: [verb] <prefix>_eval session=rev caller="chip-fetcher tab 3" makes an interference report reconstructable (two tabs were driving one session, and the log says which did what, when). A bare [verb] <prefix>_eval session=rev never can, no matter how carefully anyone reads it afterwards.

3. Arbitrate, but only if your bridge owns sessions or resources an agent can own. If two threads can fight over the same window, session, document, or device:

  • Record ownerThread at creation time (the thread header on the verb that created it).
  • When a different thread arrives, warn loudly in the response (_hint / a warning field). Do not fail silently, and do not quietly let the second thread stomp the first.
  • Refuse only for destructive ops (close, reset, delete, overwrite). Reads and ordinary drive verbs should warn and proceed.
  • Always name the current owner in the response. The second agent can then go coordinate ("session rev belongs to chip-fetcher tab 3") instead of guessing or forcing.

4. REQUIRED: forward the chain on your own AD callbacks. When your bridge calls an AD verb (desktop_screenshot_window, desktop_set_window_identity, notify_user, desktop_taskbar) while carrying out a verb an AI asked for, you are acting on that thread's behalf. Echo the three headers you received and add your own X-Adom-Caller-Delegate: <bridge>. If you don't, the chain dies at your doorstep: the user's Activity Log credits your bridge for work someone else asked for, and an approval toast asks them to authorize a nameless component. Forward it and the user sees chip-fetcher tab 3 (via <bridge>), which is the whole point.

// node. `incoming` is the request AD dispatched to your verb handler.
const FORWARD = ['thread', 'container', 'reason'];
const headers = {
  'Content-Type': 'application/json',
  'X-Adom-Bridge-Token': process.env.ADOM_BRIDGE_TOKEN,
  'X-Adom-Caller-Delegate': '<bridge>',     // name YOURSELF; never forward this one
};
for (const part of FORWARD) {
  const v = incoming.headers[`x-adom-caller-${part}`];
  if (v) headers[`X-Adom-Caller-${part[0].toUpperCase()}${part.slice(1)}`] = v;
}
await fetch(`${process.env.ADOM_DIRECT_API_URL}/command`, {
  method: 'POST', headers,
  body: JSON.stringify({ command: 'desktop_screenshot_window', args: { hwnd } }),
});
# python, same shape
FORWARD = ("thread", "container", "reason")
headers = {
    "X-Adom-Bridge-Token": os.environ["ADOM_BRIDGE_TOKEN"],
    "X-Adom-Caller-Delegate": "<bridge>",   # name YOURSELF
}
for part in FORWARD:
    value = incoming_headers.get(f"x-adom-caller-{part}")
    if value:
        headers[f"X-Adom-Caller-{part.capitalize()}"] = value

requests.post(
    f"{os.environ['ADOM_DIRECT_API_URL']}/command",
    headers=headers,
    json={"command": "notify_user", "args": {"title": "<Bridge Name>: export finished"}},
)

When you act on your own behalf (a health poll, a timer, a cleanup sweep), do NOT forward a stale identity from some earlier request. Send an explicit caller block in the args instead, which always beats the forwarded headers:

{ "caller": { "aiThread": "<bridge> bridge (self)", "containerName": "local" } }

5. The trust rule, stated plainly: these values are SELF-ASSERTED. They are for attribution, logging, UX, and arbitration hints. They are never authorization and never a security boundary. AD records and displays them; it cannot verify them, and neither can you. if (thread === "admin-tab") allow(...) is a hole, not a check.

Publishing (the wiki has TWO storage layers — don't mix them)

Full recipe in PUBLISHING.md. The load-bearing rules:

  1. The bridge .zip → a RELEASE asset (adom-wiki release create + release upload). NEVER repo push a .zip — it's gitignored and silently skipped; the manifest URL would 404.
  2. The manifest JSON + source → the git repo (adom-wiki repo push). The manifest's url is the RELEASE download URL; its sha256/size MUST match the served asset (releases preserve your sha; re-download to confirm or bridge_install rejects it).
  3. The skills → a PKG (adom-wiki pkg publish). The pkg description (root package.json) must say "container-side skills." pkg pack honors .gitignore for exclusion (no separate ignore file) — that's why the heroes are gitignored, to keep the tarball lean. Set per-version notes with adom-wiki pkg notes.
  4. Version display gotcha: a RELEASE shows up in adom-wiki pkg info as a row with type:null (a wiki UI conflation). The real pkg rows have type:"app". Use release list for true releases.
  5. bridge.json + BRIDGE_VERSION lockstep, and keep the version > the AD bundled seed so cache-over-bundled wins.
  6. Declare updateManifestUrl in your bridge.json (the full URL of this page's manifest) so a user's AD re-polls THIS page for updates — and the same value lives in the bundled seed AD ships, so a fresh install auto-updates with no user opt-in. A republish that drops it makes AD fall back to the legacy convention and stop seeing your updates.

Testing a new version on a real desktop

adom-desktop targets    # multiple desktops → --target <name> is required
adom-desktop --target <name> bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-desktop-<bridge>-bridge/files/adom-bridge-<bridge>-manifest.json","force":true}'
adom-desktop --target <name> <prefix>_readiness '{}'   # proves the new code is live
  • The language runtime is missing on the box<prefix>_* returns the runtime-not-found error from AD core before the bridge spawns (correct). That's a genuine cold-start demo of the FIRST link, but it can't exercise your self-heal module (there's no runtime to run it).
  • A box WITH the runtime but WITHOUT the heavy dependency is the only place to see the full dependency self-install live (<prefix>_<verb><dependency>_installing → poll <prefix>_readiness → succeeds). Most dev boxes already have the dependency, so readiness() just returns ready:true.
  • The streamed zip is source-only; AD reconstructs heavy deps in the cache on first spawn (ship prebuilt native deps where possible). The bundled seed already has its deps materialized. Don't put reconstructable dependency trees (node_modules, venvs, vendored build output) in the zip.

Don't

  • Don't edit the bundled seed in the adom-desktop repo (plugins/<bridge>) as if it's live, and don't run release-bridge.sh <bridge> from the adom-desktop repo — that clobbers this cloud-owned page. (The adom-desktop release script hard-refuses the cloud-owned bridges; the rule is "don't try.")
  • Don't ship the bridge runtime in the pkg tarball or the skills in the release zip. Two artifacts, two homes.
  • Don't pin your heavy dependency to a floating channel ('latest'/'stable') — pin to the exact version your code launches against, or the install and the launch disagree.
  • Don't gate any bridge behavior on a caller's aiThread / X-Adom-Caller-Delegate value, and don't drop the X-Adom-Caller-* headers on your own AD callbacks. The first is a security hole; the second breaks the user's attribution chain at your bridge.