Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
220cb9d
2mo ago
Talking to Adom Bridge from a web page
A page served in a browser can reach the copy of Adom Bridge running on the same
machine, over its loopback HTTP API at http://127.0.0.1:24680. Detection is open;
running a verb asks the human for permission in Adom Bridge's own window.
One verb surface, not two
Web control is not a separate API with its own verbs. It is the same verbs, over a different transport, with a different consent rule. There is no "web-control verb list" to keep in sync with a "main verb list", because there is only one list.
src-tauri/src/direct_api.rs states the commitment in its own header:
…dispatches through the same
commands::handle_commandfunction the WS path uses… Every verb that works over WS works over the direct API too - no per-verb plumbing.
All three callers converge on one dispatcher:
the AI, via the adom-bridge CLI --+
Hydrogen / a bridge, on loopback --+--> commands::handle_command --> the verb
a web page, via fetch() --+
Concretely: 170 verbs are documented in ab's own explorer (GET /verbs), and a web page
can call 166 of them. The four it cannot are listed below, and the reason is transport,
not policy.
So adding a verb "for web control" is almost always a mistake. Write the verb once and it
is automatically available to the CLI, to Hydrogen, to bridges, and to web pages. If you catch
yourself writing web_* anything, stop.
The model (v2.0.31)
- Detection is open.
GET /healthand/statusanswer any web origin (liveness, version, which bridges are installed). This is non-sensitive, and it has to be open because the wiki renders its readme inside a sandboxed iframe (allow-scriptsWITHOUTallow-same-origin), so the iframe'sfetch()carriesOrigin: null, nothttps://wiki.adom.inc. An origin allowlist can't gate that. - Running a verb asks for consent. A
POST /commandfrom a browser is not refused and is not silent. Adom Bridge pops its own prompt in its own window naming the page and the verb, with Allow once / Allow 1 hr / Deny (no Bypass or 24 hr for a web caller). The human clicking is the authorization. - Consent is the gate, not origin. Because a human approves each web action in
a window no web page can script, the origin does not need to be trusted. That is
why this works for an
Origin: nulliframe, and why the blast radius of even a compromised page is "a prompt the user can Deny," never a takeover. - Anti-spam. A Deny mutes re-prompts from that page for 30s; the write path is
over loopback only (anti-DNS-rebind on the
Hostheader).
Allow 1 hr grants that page an hour of verbs without re-prompting; Allow once
runs just the one. The port 24680 is fixed and stable; browsers treat
http://127.0.0.1 as a trustworthy secure context, so an HTTPS page reaches it
with no mixed-content error.
The four real differences
1. Transport: JSON over HTTP, not the CLI's WebSocket
A web page has fetch() and nothing else - no SDK, no library, no binary channel. The CLI
holds a WebSocket to the relay and can send binary frames. That single capability
difference is the origin of every remaining item on this list.
2. Four verbs need the CLI (cliRequired)
pull_file streaming binary frames
send_files the binary streaming variant lives only on the relay
hd_send_files relayed to Hydrogen's /files/inject - relay-only transfer
shell_execute multi-minute approval polling, not one synchronous request
These are refused up front with errorCode: "cli_required" and a pointer to the CLI, rather
than failing obscurely. The live list is always at GET /status.directApi.cliRequired - read
it there rather than trusting this page.
Note what is not on that list: run_script, write_file, registry_set, process_kill,
every desktop_* verb, every bridge verb. Web control is not a reduced "safe subset" - it is
the full surface minus four things that physically need a different transport.
3. Consent replaces the relay gate
Both paths are gated; they are gated differently, because the trust question differs.
| caller | gate |
|---|---|
| cloud AI over the relay | Tier-2 gate (via_relay): shell/write/registry/process verbs need a standing human grant |
| local CLI, Hydrogen, a bridge | trusted by transport - loopback plus install consent, no gate |
| a web page | per-origin consent: the first state-changing call pops Allow once / Allow 1 hr / Deny |
A web page is on loopback, so it is not via_relay and does not hit the Tier-2 gate - the
consent gate stands in for it. Grants are keyed per origin and visible at
/status.webControl.grants, in ab's window, and via web_control_grants / web_control_revoke.
The built-in demo page is deliberately served on port 24681 while the command API is on 24680, so the demo is cross-origin to ab and gets no special privilege - it faces the identical consent prompt any other site would. Serving it same-origin would let its calls skip the gate, which is exactly the bypass to avoid.
4. A cross-origin GET /status is projected down
Any page may detect ab - that is the point of an open /health and /status - but a
cross-origin /status returns only service, ok, version, schema, and a small
webControl block. The full document (machine name, install path containing the username,
permission state, the list of every origin the user has approved) is local callers only.
Fixed in 2.0.65 (ab#89); before that, any site could read all of it with no consent step.
So is anything redundant?
One thing, and it is worth naming rather than hiding:
| verb | transport | reachable from a web page |
|---|---|---|
send_files |
relay WebSocket, 1 MiB binary frames | no (cliRequired) |
write_file_chunk |
base64 chunks over /command JSON |
yes |
Both move a large file onto the PC. That is one capability with two transports, and the
duplication is forced by the browser: send_files streams binary frames over the relay WS,
and a page posting JSON to loopback cannot speak that. Exposing send_files over HTTP would
have meant reimplementing its transport anyway - the same work, wearing a name that promised
something it could not deliver.
write_file_chunk is not a web-control verb. It is on the one shared surface, and the CLI,
Hydrogen and bridges can all call it. It exists because the ~2.5 MB /command body cap is a
property of HTTP, which the CLI also hits when it uses the direct API.
Everything else has exactly one implementation.
Large files: write_file_chunk (ab 2.0.72+, ab#90)
write_file carries the whole payload as base64 inside the JSON body. Base64 inflates ~33%,
the body cap is ~2.5 MB, and a real .step 3D model is routinely 1-10 MB - so staging one
returned 413 Payload Too Large and the 3D body silently never arrived.
write_file_chunk {path, dataB64, offset, final?, sha256?, createDirs?}
-> {path, offset, bytesWritten, size, nextOffset, final, sha256?, verified?}
Loop: advance to the returned nextOffset each call and set final:true on the last chunk.
Pass the source sha256 on that final chunk and ab hashes the reassembly and verifies it for you.
Chunk size depends on your transport, because base64 inflates ~33%. Measured, not assumed:
| caller | chunk of RAW bytes | why |
|---|---|---|
| a web page / the direct API (HTTP) | ~1 MB | body cap ~2.5 MB |
the adom-bridge CLI on Windows |
16 KB or less | the payload rides in ARGV, and Windows caps a command line at 32,767 chars |
| a container shipping a big file | use send_files |
streams over the relay, no such limit |
The CLI ceiling is sharp and it fails before ab is reached: 16 KB raw (~22,000 argv chars)
succeeds and verifies; 24 KB (~32,987 chars) does not spawn at all, returning WinError 206.
Credit to the fusion-bridge thread for the general warning - they hit the same class from the
container side with a 245 KB STEP failing errno 7 (E2BIG) in argv, which is what prompted
measuring this properly.
Three properties worth relying on:
- Stateless. No handle table, no session id, nothing to leak if a caller vanishes mid-transfer. Each call opens, seeks, writes, closes - which is also why a chunk is safely retryable: if you never saw the response, send the same offset again.
- A gap is refused, never zero-filled.
offset > sizereturnserrorCode: "chunk_gap"with anexpectedOffsetto resume from. Zero-filling a hole would produce a file of exactly the right length that is silently corrupt - the worst outcome for a model someone opens days later. offset: 0truncates, so a restarted transfer never appends onto the remains of a failed one.
Measured against the real 3.9 MB s10b-ph-sm4-tb.step:
single-shot write_file -> HTTP 413 "length limit exceeded"
write_file_chunk -> 4 chunks, 0.9s, 3,921,735 bytes, sha256 verified
Private wiki pages: wiki_fetch (ab 2.0.74+)
A web page can already read anything public on the wiki - it just fetches it. What it
cannot do is read a page that is private to the user, because the browser holds no Adom
session for wiki.adom.inc. ab does. wiki_fetch exposes that one capability:
wiki_fetch {url}
-> {httpStatus, url, contentType, size, base64}
This is not hypothetical. Three of the ten parts in the demo's picker were private, so the browser got a 404 and the card reported a mystery "blocked" - the page had no way to tell "this file does not exist" from "this file is not yours to read anonymously".
Fetch anonymously first, and fall back to this only when that fails. The demo does exactly that. Anonymous is the default so the user's credential is spent on the reads that genuinely need it, rather than on every read by habit.
The origin lock is the entire security story. Without it this verb is a general credentialed
proxy: any approved page could have ab attach the user's bearer token to a request at an
attacker's host. So the URL is parsed, and the host must be exactly wiki.adom.inc over
https.
Parsing rather than prefix-matching is deliberate and load-bearing:
https://[email protected]/steal
That string begins with https://wiki.adom.inc, yet its host is evil.com. A startsWith
guard hands the user's token straight to the attacker; Url::parse resolves the userinfo
correctly and refuses it. wiki_fetch_url_tests covers that case along with subdomain
lookalikes (wiki.adom.inc.evil.com), plain http, and file://.
The token itself never leaves ab - the caller receives bytes, never the credential. Web callers reach this through the ordinary consent gate, so a human approves it with the URL named in the prompt.
The wiki render surface
The wiki renders a page's readme as HTML inside a sandboxed iframe served from
https://wiki.adom.inc/readme/<owner>/app/<slug> (verified live). Scripts run
there, so a harness like the one below works from a readme - the POST triggers
the Adom Bridge consent prompt, and on Allow the verb runs. (Detection buttons
work regardless; the write button is what pops the prompt.)
The harness
Runnable standalone (save as .html and open it), and the shape we embed in a
readme once the page opts into HTML rendering. bridge_list is a harmless verb
that proves the consent-to-verb loop; swap it for kicad_open_board etc.
<div id="ab-demo" style="font-family:system-ui,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;max-width:760px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span id="ab-dot" style="width:12px;height:12px;border-radius:50%;background:#484f58;display:inline-block"></span>
<strong>Adom Bridge web control demo</strong>
</div>
<div style="font-size:12px;color:#8b949e;margin-bottom:14px">This frame's origin: <code id="ab-origin">checking...</code> · API: <code>http://127.0.0.1:24680</code></div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">
<button id="ab-detect">Detect Adom Bridge</button>
<button id="ab-status">Read status</button>
<button id="ab-run">Run a verb (asks your permission)</button>
</div>
<pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0">Ready. Click Detect Adom Bridge to begin.</pre>
<script>
(function () {
var AB = "http://127.0.0.1:24680", log = document.getElementById("ab-log"), dot = document.getElementById("ab-dot");
document.getElementById("ab-origin").textContent = location.origin || "(null: sandboxed frame)";
function line(m){ log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }
document.getElementById("ab-detect").onclick = function () {
log.textContent = "GET " + AB + "/health ...";
fetch(AB + "/health").then(function(r){return r.ok?r.json():Promise.reject("HTTP "+r.status);})
.then(function(j){ dot.style.background = j.service==="adom-bridge" ? "#00b8b0" : "#e6c86c"; line("RUNNING: " + JSON.stringify(j)); })
.catch(function(e){ dot.style.background = "#484f58"; line("Not reachable (" + e + ")."); });
};
document.getElementById("ab-status").onclick = function () {
log.textContent = "GET " + AB + "/status ...";
fetch(AB + "/status").then(function(r){return r.json();}).then(function(j){ line(JSON.stringify(j, null, 2)); }).catch(function(e){ line("failed: " + e); });
};
document.getElementById("ab-run").onclick = function () {
log.textContent = "POST " + AB + "/command {bridge_list} - Adom Bridge will ask YOU to approve ...";
fetch(AB + "/command", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({command:"bridge_list"}) })
.then(function(r){ return r.json().then(function(j){ return {status:r.status, body:j}; }); })
.then(function(res){ line(res.status===200 ? "APPROVED and ran: " + JSON.stringify(res.body).slice(0,300) : "Not approved (HTTP " + res.status + "): " + JSON.stringify(res.body)); })
.catch(function(e){ line("blocked before ab answered: " + e); });
};
})();
</script>
The gate, in code
src-tauri/src/direct_api.rs: web_gate (middleware, CORS + read-allow + loopback
Host check) and web_consent_gate (the per-request human prompt + per-origin
grant). resolve_web_approval (a GUI-only Tauri command) is what the Allow/Deny
buttons call. Full ab-side reference: the adom-bridge-web-integration skill.
The full reference implementation
The harness above is the minimal shape. ab also ships a complete demo page that exercises the whole surface - every verb group, live progress rendering, evidence thumbnails, a part picker, and the chunked upload above:
http://127.0.0.1:24681
Its address is reported at /status.webControl.demoUrl. Source: web-control-demo.html on
this page. Pushing that file to this page's /files is the deploy - every ab picks it up
on its next refresh, with no ab release.
# Talking to Adom Bridge from a web page
A page served in a browser can reach the copy of Adom Bridge running on the same
machine, over its loopback HTTP API at `http://127.0.0.1:24680`. Detection is open;
running a verb asks the human for permission in Adom Bridge's own window.
## One verb surface, not two
**Web control is not a separate API with its own verbs.** It is the *same* verbs, over a
different transport, with a different consent rule. There is no "web-control verb list" to
keep in sync with a "main verb list", because there is only one list.
`src-tauri/src/direct_api.rs` states the commitment in its own header:
> …dispatches through the same `commands::handle_command` function the WS path uses…
> **Every verb that works over WS works over the direct API too - no per-verb plumbing.**
All three callers converge on one dispatcher:
```
the AI, via the adom-bridge CLI --+
Hydrogen / a bridge, on loopback --+--> commands::handle_command --> the verb
a web page, via fetch() --+
```
Concretely: **170 verbs** are documented in ab's own explorer (`GET /verbs`), and a web page
can call **166** of them. The four it cannot are listed below, and the reason is transport,
not policy.
**So adding a verb "for web control" is almost always a mistake.** Write the verb once and it
is automatically available to the CLI, to Hydrogen, to bridges, and to web pages. If you catch
yourself writing `web_*` anything, stop.
## The model (v2.0.31)
- **Detection is open.** `GET /health` and `/status` answer any web origin
(liveness, version, which bridges are installed). This is non-sensitive, and it
has to be open because the wiki renders its readme inside a **sandboxed iframe**
(`allow-scripts` WITHOUT `allow-same-origin`), so the iframe's `fetch()` carries
`Origin: null`, not `https://wiki.adom.inc`. An origin allowlist can't gate that.
- **Running a verb asks for consent.** A `POST /command` from a browser is not
refused and is not silent. Adom Bridge pops its **own** prompt in its **own**
window naming the page and the verb, with **Allow once / Allow 1 hr / Deny**
(no Bypass or 24 hr for a web caller). The human clicking is the authorization.
- **Consent is the gate, not origin.** Because a human approves each web action in
a window no web page can script, the origin does not need to be trusted. That is
why this works for an `Origin: null` iframe, and why the blast radius of even a
compromised page is "a prompt the user can Deny," never a takeover.
- **Anti-spam.** A Deny mutes re-prompts from that page for 30s; the write path is
over loopback only (anti-DNS-rebind on the `Host` header).
`Allow 1 hr` grants that page an hour of verbs without re-prompting; `Allow once`
runs just the one. The port `24680` is fixed and stable; browsers treat
`http://127.0.0.1` as a trustworthy secure context, so an HTTPS page reaches it
with no mixed-content error.
## The four real differences
### 1. Transport: JSON over HTTP, not the CLI's WebSocket
A web page has `fetch()` and nothing else - no SDK, no library, no binary channel. The CLI
holds a WebSocket to the relay and can send **binary frames**. That single capability
difference is the origin of every remaining item on this list.
### 2. Four verbs need the CLI (`cliRequired`)
```
pull_file streaming binary frames
send_files the binary streaming variant lives only on the relay
hd_send_files relayed to Hydrogen's /files/inject - relay-only transfer
shell_execute multi-minute approval polling, not one synchronous request
```
These are refused up front with `errorCode: "cli_required"` and a pointer to the CLI, rather
than failing obscurely. The live list is always at `GET /status.directApi.cliRequired` - read
it there rather than trusting this page.
Note what is **not** on that list: `run_script`, `write_file`, `registry_set`, `process_kill`,
every `desktop_*` verb, every bridge verb. Web control is not a reduced "safe subset" - it is
the full surface minus four things that physically need a different transport.
### 3. Consent replaces the relay gate
Both paths are gated; they are gated *differently*, because the trust question differs.
| caller | gate |
|---|---|
| cloud AI over the relay | **Tier-2 gate** (`via_relay`): shell/write/registry/process verbs need a standing human grant |
| local CLI, Hydrogen, a bridge | **trusted by transport** - loopback plus install consent, no gate |
| **a web page** | **per-origin consent**: the first state-changing call pops Allow once / Allow 1 hr / Deny |
A web page is on loopback, so it is *not* `via_relay` and does not hit the Tier-2 gate - the
consent gate stands in for it. Grants are keyed per origin and visible at
`/status.webControl.grants`, in ab's window, and via `web_control_grants` / `web_control_revoke`.
The built-in demo page is deliberately served on **port 24681** while the command API is on
**24680**, so the demo is cross-origin to ab and gets **no special privilege** - it faces the
identical consent prompt any other site would. Serving it same-origin would let its calls skip
the gate, which is exactly the bypass to avoid.
### 4. A cross-origin `GET /status` is projected down
Any page may detect ab - that is the point of an open `/health` and `/status` - but a
cross-origin `/status` returns only `service`, `ok`, `version`, `schema`, and a small
`webControl` block. The full document (machine name, install path containing the username,
permission state, the list of every origin the user has approved) is **local callers only**.
Fixed in 2.0.65 (ab#89); before that, any site could read all of it with no consent step.
## So is anything redundant?
One thing, and it is worth naming rather than hiding:
| verb | transport | reachable from a web page |
|---|---|---|
| `send_files` | relay WebSocket, 1 MiB binary frames | no (`cliRequired`) |
| `write_file_chunk` | base64 chunks over `/command` JSON | **yes** |
Both move a large file onto the PC. That is one *capability* with two *transports*, and the
duplication is forced by the browser: `send_files` streams binary frames over the relay WS,
and a page posting JSON to loopback cannot speak that. Exposing `send_files` over HTTP would
have meant reimplementing its transport anyway - the same work, wearing a name that promised
something it could not deliver.
`write_file_chunk` is **not a web-control verb**. It is on the one shared surface, and the CLI,
Hydrogen and bridges can all call it. It exists because the ~2.5 MB `/command` body cap is a
property of HTTP, which the CLI also hits when it uses the direct API.
Everything else has exactly one implementation.
## Large files: `write_file_chunk` (ab 2.0.72+, ab#90)
`write_file` carries the whole payload as base64 inside the JSON body. Base64 inflates ~33%,
the body cap is ~2.5 MB, and a real `.step` 3D model is routinely 1-10 MB - so staging one
returned `413 Payload Too Large` and the 3D body silently never arrived.
```
write_file_chunk {path, dataB64, offset, final?, sha256?, createDirs?}
-> {path, offset, bytesWritten, size, nextOffset, final, sha256?, verified?}
```
Loop: advance to the returned `nextOffset` each call and set `final:true` on the last chunk.
Pass the source `sha256` on that final chunk and ab hashes the reassembly and verifies it for you.
**Chunk size depends on your transport**, because base64 inflates ~33%. Measured, not assumed:
| caller | chunk of RAW bytes | why |
|---|---|---|
| a web page / the direct API (HTTP) | **~1 MB** | body cap ~2.5 MB |
| the `adom-bridge` CLI on Windows | **16 KB or less** | the payload rides in ARGV, and Windows caps a command line at 32,767 chars |
| a container shipping a big file | use `send_files` | streams over the relay, no such limit |
The CLI ceiling is sharp and it fails *before ab is reached*: 16 KB raw (~22,000 argv chars)
succeeds and verifies; 24 KB (~32,987 chars) does not spawn at all, returning `WinError 206`.
Credit to the fusion-bridge thread for the general warning - they hit the same class from the
container side with a 245 KB STEP failing `errno 7` (E2BIG) in argv, which is what prompted
measuring this properly.
Three properties worth relying on:
- **Stateless.** No handle table, no session id, nothing to leak if a caller vanishes
mid-transfer. Each call opens, seeks, writes, closes - which is also why a chunk is safely
**retryable**: if you never saw the response, send the same offset again.
- **A gap is refused, never zero-filled.** `offset > size` returns `errorCode: "chunk_gap"`
with an `expectedOffset` to resume from. Zero-filling a hole would produce a file of exactly
the right length that is silently corrupt - the worst outcome for a model someone opens days
later.
- **`offset: 0` truncates**, so a restarted transfer never appends onto the remains of a failed
one.
Measured against the real 3.9 MB `s10b-ph-sm4-tb.step`:
```
single-shot write_file -> HTTP 413 "length limit exceeded"
write_file_chunk -> 4 chunks, 0.9s, 3,921,735 bytes, sha256 verified
```
## Private wiki pages: `wiki_fetch` (ab 2.0.74+)
A web page can already read anything **public** on the wiki - it just fetches it. What it
cannot do is read a page that is **private to the user**, because the browser holds no Adom
session for `wiki.adom.inc`. ab does. `wiki_fetch` exposes that one capability:
```
wiki_fetch {url}
-> {httpStatus, url, contentType, size, base64}
```
This is not hypothetical. Three of the ten parts in the demo's picker were private, so the
browser got a 404 and the card reported a mystery "blocked" - the page had no way to tell
"this file does not exist" from "this file is not yours to read anonymously".
**Fetch anonymously first, and fall back to this only when that fails.** The demo does exactly
that. Anonymous is the default so the user's credential is spent on the reads that genuinely
need it, rather than on every read by habit.
**The origin lock is the entire security story.** Without it this verb is a general credentialed
proxy: any approved page could have ab attach the user's bearer token to a request at an
attacker's host. So the URL is **parsed**, and the host must be exactly `wiki.adom.inc` over
https.
Parsing rather than prefix-matching is deliberate and load-bearing:
```
https://[email protected]/steal
```
That string *begins with* `https://wiki.adom.inc`, yet its host is `evil.com`. A `startsWith`
guard hands the user's token straight to the attacker; `Url::parse` resolves the userinfo
correctly and refuses it. `wiki_fetch_url_tests` covers that case along with subdomain
lookalikes (`wiki.adom.inc.evil.com`), plain http, and `file://`.
The token itself never leaves ab - the caller receives bytes, never the credential. Web callers
reach this through the ordinary consent gate, so a human approves it with the URL named in the
prompt.
## The wiki render surface
The wiki renders a page's readme as HTML inside a sandboxed iframe served from
`https://wiki.adom.inc/readme/<owner>/app/<slug>` (verified live). Scripts run
there, so a harness like the one below works from a readme - the `POST` triggers
the Adom Bridge consent prompt, and on Allow the verb runs. (Detection buttons
work regardless; the write button is what pops the prompt.)
## The harness
Runnable standalone (save as `.html` and open it), and the shape we embed in a
readme once the page opts into HTML rendering. `bridge_list` is a harmless verb
that proves the consent-to-verb loop; swap it for `kicad_open_board` etc.
```html
<div id="ab-demo" style="font-family:system-ui,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;max-width:760px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span id="ab-dot" style="width:12px;height:12px;border-radius:50%;background:#484f58;display:inline-block"></span>
<strong>Adom Bridge web control demo</strong>
</div>
<div style="font-size:12px;color:#8b949e;margin-bottom:14px">This frame's origin: <code id="ab-origin">checking...</code> · API: <code>http://127.0.0.1:24680</code></div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">
<button id="ab-detect">Detect Adom Bridge</button>
<button id="ab-status">Read status</button>
<button id="ab-run">Run a verb (asks your permission)</button>
</div>
<pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0">Ready. Click Detect Adom Bridge to begin.</pre>
<script>
(function () {
var AB = "http://127.0.0.1:24680", log = document.getElementById("ab-log"), dot = document.getElementById("ab-dot");
document.getElementById("ab-origin").textContent = location.origin || "(null: sandboxed frame)";
function line(m){ log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }
document.getElementById("ab-detect").onclick = function () {
log.textContent = "GET " + AB + "/health ...";
fetch(AB + "/health").then(function(r){return r.ok?r.json():Promise.reject("HTTP "+r.status);})
.then(function(j){ dot.style.background = j.service==="adom-bridge" ? "#00b8b0" : "#e6c86c"; line("RUNNING: " + JSON.stringify(j)); })
.catch(function(e){ dot.style.background = "#484f58"; line("Not reachable (" + e + ")."); });
};
document.getElementById("ab-status").onclick = function () {
log.textContent = "GET " + AB + "/status ...";
fetch(AB + "/status").then(function(r){return r.json();}).then(function(j){ line(JSON.stringify(j, null, 2)); }).catch(function(e){ line("failed: " + e); });
};
document.getElementById("ab-run").onclick = function () {
log.textContent = "POST " + AB + "/command {bridge_list} - Adom Bridge will ask YOU to approve ...";
fetch(AB + "/command", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({command:"bridge_list"}) })
.then(function(r){ return r.json().then(function(j){ return {status:r.status, body:j}; }); })
.then(function(res){ line(res.status===200 ? "APPROVED and ran: " + JSON.stringify(res.body).slice(0,300) : "Not approved (HTTP " + res.status + "): " + JSON.stringify(res.body)); })
.catch(function(e){ line("blocked before ab answered: " + e); });
};
})();
</script>
```
## The gate, in code
`src-tauri/src/direct_api.rs`: `web_gate` (middleware, CORS + read-allow + loopback
Host check) and `web_consent_gate` (the per-request human prompt + per-origin
grant). `resolve_web_approval` (a GUI-only Tauri command) is what the Allow/Deny
buttons call. Full ab-side reference: the `adom-bridge-web-integration` skill.
## The full reference implementation
The harness above is the minimal shape. ab also **ships** a complete demo page that exercises
the whole surface - every verb group, live progress rendering, evidence thumbnails, a part
picker, and the chunked upload above:
```
http://127.0.0.1:24681
```
Its address is reported at `/status.webControl.demoUrl`. Source: `web-control-demo.html` on
this page. Pushing that file to this page's `/files` **is** the deploy - every ab picks it up
on its next refresh, with no ab release.