Pup - Puppeteer Bridge
Public Made by Adomby adom
pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.
name: pup user-invocable: true description: "Drive a browser on the user's desktop from the cloud — the Puppeteer 'pup' bridge (pup_* verbs via the adom-bridge-cli CLI). pup drives Chrome for Testing (the automation build: no automation banner, deterministic version, toolbar extension rides the launch — see docs/WHY-CFT.md; installed Chrome/Edge are fallbacks) in a pup-managed DURABLE profile (logins/localStorage persist and are shared across pup windows; {isolated:true} for a throwaway), driven over CDP, in the BACKGROUND so it never disturbs the user; NOT the user's real signed-in profile (that's the Adom extension's nbrowser_* verbs), NOT headless (a real rendered window, so screenshots/recording are true-to-life). Chrome for Testing only as a last resort. Open/close windows + tabs, navigate, screenshot, eval JS, record. This is the START-HERE skill: the mental model + when-to-use + the skill map; deeper topics route to the sub-skills. For 'is it ready' use pup_readiness (not pup_status). Trigger words: pup, puppeteer, browser window, open in pup, open my app in pup, browser screenshot, browser eval, browser reload, visual debug, headful chrome, pup_open_window, pup_screenshot, pup_readiness, pup_prewarm, chrome for testing, open in browser, open my web app, show my app in the browser, preview my app, open the app I just built, view my app, open localhost, show my dev server, pup_login, log into a site in pup, autofill password, saved login, durable profile, isolated window."
Pup — browser control on the user's desktop (start here)
Drive a browser on the user's desktop from a cloud container via the adom-bridge-cli CLI. Every verb is a
Bash command (NOT an MCP tool):
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"https://example.com"}'
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{"sessionId":"mytask-web"}'
THE CORE GOAL (John's words, so everyone truly understands it)
"Open my web app in the browser" — the #1 use case, and its one trap
When the user asks to see an app they just built, pup IS the right surface (a real browser window on
their desktop that you can keep driving: reload after fixes, screenshot, eval). The ONE trap: an app
running in a CLOUD container is NOT reachable from the desktop as localhost:<port> — pup's Chrome
runs on the DESKTOP. Use the container's public proxy URL instead:
https://{container-slug}.adom.cloud/proxy/{port}/
Open it background-first (foreground:false + the taskbar flash signals the user), give the window
your thread's sessionId, and re-use the SAME window on every subsequent "check it now" (navigate /
reload, don't spawn new windows). Under Hydrogen (local container) http://localhost:{port} IS
correct — the trap is cloud-only. pup lints obvious container-local URLs and will warn you.
Vocabulary + routing (teach the user the words): call this surface pup in conversation ("I opened it in pup") so users learn the name for the AI-driven browser. When the user wants the page in THEIR OWN browser — their logins, their tabs, a hand-off with no AI driving — that is nb (native browser), a different surface: say so and use nb instead. Displaying inside a Hydrogen panel next to the editor is wv. When in doubt: driving/testing → pup, handing to the human → nb, docked preview → wv.
Platform qualifier: pup requires Bridge (ab). Under Hydrogen (h) ab is bundled, so pup is
always available. On Hydrogen Cloud (hc) ab may NOT be connected — check adom-bridge-cli ping
first; if there's no desktop, show the app in a wv panel (the proxy URL works there too) and
offer the Bridge install for real-browser windows.
pup exists to be an AI-driven browser that can do everything it needs to for you: the best assistant in the world. The engine of that goal is the shared durable adom-you profile:
- Surf as adom-you by default. Every pup window on adom-you contributes every login it makes to the shared credential store. The more windows surf as adom-you, the better the username/password database gets, and over time pup can automatically sign in to every website it has ever seen.
- Isolation is the exception, not a style choice. Pass
isolated:true(or a separate profile) ONLY when the task genuinely needs a clean jar: testing logged-out views, acting as a different user, untrusted pages. An isolated window's logins are thrown away: they build nothing. - The dashboard shows a "surfing as adom-you" chip per window and an adom-you count in the header: if your window says "NOT adom-you", have a reason.
ALWAYS say SAVE (John's rule)
When the browser offers to save something during your driving, the answer is always Save: Chrome's "Save address?", "Save password?", "Save card / payment info?", "Update password?", any "Save this info?" bubble. Every save enriches the adom-you profile so pup can auto-fill it on every future site, which IS the core goal above. Never click "No thanks" / "Never" / dismiss, unless the USER explicitly told you not to save that particular thing.
Usernames and passwords especially (John's emphasis). Whenever you sign a window into any site,
SAVE the credentials to the adom-you profile: accept every "Save password?" and "Update password?"
bubble, so the shared username/password store grows into the best adom-you profile it can be. A
signed-in session whose password you did NOT save is a missed brick in that database. If you filled
a login via pup_login, pass save:true (or accept the resulting Chrome save bubble) so the
credential is persisted, not just used once.
- These are Chrome's own bubbles (browser UI, not page DOM), so click them by accessible name in the
BACKGROUND:
desktop_ui_click {name:"Save"}(fall back to "Save address"/"Update password" as the bubble labels them). No foreground, no focus steal. - If a save bubble appears and you don't act, Chrome dismisses it on the next navigation and the data is lost forever. Treat a visible save prompt as a must-do before you navigate away.
- The ONE exception: an isolated/throwaway window (
isolated:true) builds nothing, so saving there is pointless, skip it.
The mental model — what pup actually is (read this once)
pup is a small Node bridge that Bridge spawns on the user's machine. When you open a window it does NOT attach to the user's running browser and does NOT run headless. It:
- Launches a brand-new process of the browser ALREADY installed on the machine — installed Chrome
first, else Microsoft Edge. On Windows every PC ships Edge, so there's almost always a candidate
and no download. On macOS nothing is guaranteed: Safari is not CDP-drivable, and Edge is not
preinstalled — so on a Mac with no Chrome, pup falls through to downloading Chrome (or cached Chrome
for Testing). Don't promise the user "no download" off Windows; check
pup_readinessinstead. It uses their browser binary, never their profile. - …with a pup-managed
--user-data-dir+ a CDP debugging port (--remote-debugging-port), driven over the Chrome DevTools Protocol. This is a profile pup owns and persists, never the user's own signed-in OS Chrome/Edge profile. By default every window shares ONE durable profile (adom-you), so logins, cookies and localStorage accumulate and are shared across all pup windows — sign into a site once and every pup window is signed in; app state (localStorage) survives between windows. Sharing a profile does NOT merge windows: each window is still its own OS window with its own taskbar identity. For a private, throwaway window (a fresh-login test, or acting as a different user) pass{isolated:true}, or pass an explicit{profile:"<name>"}for a separate durable jar. (Need the user's REAL signed-in profile/SSO? That's the Adom extensionnbrowser_*— see below.) - Renders a REAL window, but keeps it in the BACKGROUND. It is not headless — a real GPU-composited
window, so
pup_screenshot/ recording are true-to-life and canvas/WebGL/media render correctly. But pup immediately z-orders it to the bottom (Windows: Win32SetWindowPoswithSWP_NOACTIVATE; macOS: the browser process is hidden, the Cmd+H state — same contract), so it renders without stealing focus and without covering the user's active window. You drive and screenshot it fully while it stays hidden.foreground:trueis the ONLY thing that shows it. - Spawn-verifies + falls through. The pick order is Chrome → Edge → cached Chrome for Testing; pup actually launches each and, if one won't start (corrupt install), falls to the next — so an open never hard-fails on a broken browser. The one that launches is cached as the default. Only a box with no Chromium at all fetches Chrome for Testing (~150 MB, last resort).
- Is driven by
sessionId, and one session = one window that holds many tabs.
Keep this model in mind and every verb makes sense: it's a pup-managed, persistent, backgrounded browser (the machine's own Chrome/Edge binary, a profile pup owns) driven by CDP — durable and shared by default, isolated only when you ask.
Power & rendering — throttling is DEFAULT and correct; highFps is the leased override
Background pup windows are THROTTLED on purpose, to protect the user's CPU + battery. Chrome pauses/slows rendering on occluded/unfocused windows, and pup relies on that. This is deliberate — issue #14 (James): three idle pup windows with the anti-throttle flags on held his laptop out of deep idle overnight and FLATTENED his battery. So the default is: a background window renders slowly or not at all until it's focused.
Consequence you MUST know: a healthy background window can LOOK frozen — Google Slides stops
animating, a video pauses, the cursor doesn't blink. That is THROTTLING, not a hang. It wakes the
instant the window is focused (visibilityState flips to visible). Before ever calling a window
"frozen", check: is it just backgrounded? (on-screen + CDP-responsive + readyState:complete = it's
throttled, not broken.)
When you genuinely need a background window to keep rendering — recording it, or a live view the
user watches WITHOUT focusing it (a dashboard, a running demo) — upgrade that ONE window:
pup_open_window {highFps:true} (or the recorder path sets it). This applies the anti-throttle
flags so the occluded window renders full-rate. It is LEASED: 20 min by default, 120 max, and it
auto-downgrades when idle / when the lease lapses, so it can NEVER drain the battery the way
issue #14 did. Renew a still-needed lease with pup_highfps_extend {sessionId, minutes}.
The balance, in one line: throttled by default (battery), highFps only for a window that must
keep rendering while unfocused, and the lease makes that self-limiting. Do NOT reach for highFps to
"fix" a window that merely LOOKS frozen — just focus it (or tell the user to click it).
Under heavy load or a crash, pup self-heals — do NOT panic-kill or declare it dead
pup is built to stay usable when the shared Chrome is under heavy load, and to recover on its own if Chrome ever crashes. Read these signals correctly instead of assuming the worst:
_degradedonpup_list_windows/pup_status. These verbs ALWAYS return the full window list, fast, even when a window is too busy (Chrome under heavy load on a heavy SPA) to answer a live read. A busy window comes back markeddegradedwith its LAST-KNOWN url/title._degradedmeans "that window is busy, NOT crashed — retry shortly for live values." It does NOT mean pup is broken. (Before v1.9.172 these verbs could hang and return null under load, which read as "pup is frozen" — that can no longer happen.)- A window vanished / a
_cleanupReportsays a window crashed. If the shared Chrome crashes, pup's health loop (15s) detects it and RELAUNCHES every affected window on its own — signed in, in the background, at its last URL — then reports it via_cleanupReport("pup auto-recovered your … window after a Chrome crash"). You do NOT need to kill Chrome, clean the profile, or reopen windows by hand. Wait ~15-30s and re-list; the windows come back. Only if a page crashes Chrome REPEATEDLY does pup stop retrying it (after 2 attempts) and tell the user the page itself looks unstable. - One window times out but others work = a per-window jam, not a dead bridge. Verbs are
concurrent; a slow/jammed window does not block operations on OTHER windows, and never blocks
list/status. If
pup_evalon window A times out, window B still works and the bridge is fine. - Genuinely frozen vs throttled vs busy: a background window that looks static is almost always
just THROTTLED (see Power & rendering above) — focus it and it wakes. A window that is BUSY shows up
as
degraded. A window that CRASHED gets auto-relaunched. None of these is "pup is dead."
⛔ HARD RULE: NEVER send NATIVE OS input without warning the user FIRST (John, 2026-07-29)
Native OS input (desktop_click, desktop_type, desktop_press_key, desktop_hover) does NOT go
to a window. It goes to WHATEVER WINDOW HAS OS FOCUS AT THAT INSTANT. If the user has alt-tabbed
away — and this user runs many AI threads at once and alt-tabs away from a foregrounded window almost
immediately, every time — your keystrokes land in THEIR document, THEIR terminal, THEIR editor.
This happened: an agent foregrounded a pup window, typed a username with desktop_type without ever
telling the user, the user had already alt-tabbed back to their own work, and the text went into their
window. The agent then compounded it by concluding "Windows failed to foreground the window" — blaming
the OS for what was really "the human took their focus back, because nobody asked them not to."
NEVER do that again. Before ANY native input, in this order:
notify_userTOAST — tell them what you are about to do and that you need the window.desktop_caption— on-screen, so it is visible while they look at the window.- Say explicitly: "please don't type or click until I say done." They cannot know otherwise.
- WAIT for them to be ready —
waitForUserReady(pollosGetForegroundWindow()vs the session hwnd until it is stable). NEVER dispatch while the foreground is not your window. - Re-verify foreground IMMEDIATELY before EACH native event, not once at the start. Focus can change between two events, and a half-typed password in the wrong window is a real harm.
- Caption each step as you go, and caption when you are DONE so they know they can resume.
pup_assisted_click already implements all six. Use it instead of hand-rolling
raise-then-click. If you need native typing, follow the same protocol around it.
Corollaries that cost this project real time:
- CDP input (
pup_click,pup_type) needs NO foreground and disturbs nobody — always prefer it. Reach for native input ONLY when the target genuinely cannot be driven any other way (a cross-origin FedCM chooser, native OS chrome, a site that rejects synthetic events). - Native input returning
ok:truemeans "the event was dispatched to the OS", NOT "it reached your window". Never treat that ok as proof. Verify the effect in the page. - If native input seems not to land, the FIRST hypothesis is "it went to another window because focus moved", never "the OS failed to foreground my window".
Standing rule: TOAST THE USER before foregrounding (John, repeatedly)
NEVER put a window on the user's screen silently. Any time you FOREGROUND a pup window — a demo, a
"show me", a reveal, a real-cursor click that needs the window up — send an AD toast FIRST (or let
pup's auto-toast do it, v1.9.166+) so the user's attention is drawn to what just appeared. A window
that pops up with no heads-up reads as the agent hijacking the screen. pup now auto-fires
notify_user on every granted foreground, but you should still narrate WHY in the reason. For a
background nudge (not a full foreground), use pup_alert_window (taskbar flash) instead.
If you foreground in order to CLICK something pup's CDP click can't drive (a native OS dialog, or
a cross-origin FedCM "Sign in with Google" account chooser that rejects in-page clicks), use
pup_assisted_click — never raise-then-click by hand. It captions the reason + toasts first,
foregrounds, then WAITS and detects if the user alt-tabbed away (holding, captioning "Adom is waiting"
until they're back — it clicks NOTHING while they're not ready), then does the tight real-cursor
hover→click and narrates each step. This is the protocol so pup never foregrounds-and-acts while the
user is not ready.
When pup — vs the Adom extension (nbrowser_*)
| The task | Tool |
|---|---|
| Screenshot / scrape / test / demo a page; drive an app you're building; anything that does NOT need the user's logins | pup (this) |
| Log into a site, use saved passwords, fill a form on a signed-in page, download behind a login, sail past captchas | Adom browser extension (nbrowser_*, the adom-browser-extension skill) — drives their REAL signed-in Chrome/Edge |
pup drives the machine's browser binary in a profile pup manages — durable and shared across pup windows, but never the user's own signed-in OS Chrome/Edge profile, so it can't borrow logins the user already has there. pup accumulates its OWN logins over time (sign in once inside pup, it sticks). The extension is the counterpart when you need the user's REAL profile/SSO directly.
Logins & credentials (the durable profile pays off here)
- Sign into a site ONCE in any pup window and the durable profile keeps it — every later window (any thread) is already signed in. Google/wiki proven; works for any cookie-session site.
pup_login {sessionId, username?, password?, submit?, save?}fills an HTML login form in the BACKGROUND (no Chrome dropdown, no foregrounding, React/Vue-safe). Withsave:truethe cred is stored (OS keychain via credential_set) and next time a barepup_login {sessionId, submit:true}signs in from the vault.- Vault-matched login pages AUTO-FILL on page load (fill only, never auto-submit) — response
carries
_autoLogin. Off switch:pup_configure {autoLogin:"off"}. - Chrome's own "Save password?" bubble works in pup windows (the durable profile enables the password manager), so manual logins accumulate too.
- Need a window that is NOT signed in (fresh-login test, act-as-another-user)?
{isolated:true}on open. Need cross-origin eval for dev?{webSecurity:false}(forces isolation, keeps the credentialed profile safe).
Skill map — this is a pack
| Skill | Read it for |
|---|---|
pup (this file) |
The mental model, when-to-use, quick start, the essentials |
pup-windows-sessions-tabs |
sessionIds + naming, window ownership (don't steal another AI thread's window), background vs foreground rules, many tabs in one window, list/switch/close, verifying what you opened |
pup-screenshots-recording |
Screenshots (full-page, full-res), reading the shot, recording a window vs the whole desktop, framing a window for a clean shot without foregrounding |
pup-browsers-and-chrome |
The browser detection/CDP-launch details, pup_use to pin a browser, installing Chrome / Chrome-for-Testing (incl. the UAC-notify flow), readiness, the cold-start error table, Node provisioning, the shell-approval gate |
pup-adom-wiki |
Driving + verifying wiki.adom.inc pages in pup (show/screenshot/verify a published page; the login situation — pup views the wiki logged out for now) |
(Maintaining the bridge itself? The pup-bridge-dev + pup-bridge-publish skills are source-only in the
repo — not shipped here.)
Quick start — the 90% path
# Open in the BACKGROUND (automatic — never disturbs the user), then drive it invisibly.
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"http://localhost:3000"}'
adom-bridge-cli --ai-thread "<your thread>" pup_eval '{"sessionId":"mytask-web","expr":"document.title"}' # verify it really loaded
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{"sessionId":"mytask-web"}' # true-to-life PNG
- Your
--ai-threadname IS the owner — pup owns every window by the ai-thread that opened it, so just always pass--ai-thread "<your conversation name>"(you already must — see below) and the window is yours; no separateownerarg needed (it's a legacy alias). Apup_open_windowwith no ai-thread (and noowner) is refused withcaller_identity_required. Task-prefix yoursessionId(it's the window's KEY, never its identity) so two of your own windows don't collide. Seepup-windows-sessions-tabs. ok:truemeans navigation started, not that content rendered — sanity-check withpup_evalondocument.title(404s / login walls change it) or a screenshot.- On a fresh PC it's usually a non-event — pup drives the installed Chrome/Edge with no download. The
rare "no browser at all" / Node-provisioning / disk cases are all in
pup-browsers-and-chrome.
macOS differences (v1.9.74+) — same verbs, mac-native mechanics
Every pup_* verb works on macOS; only the OS-presentation layer differs:
- Background = hidden app. A background open launches the session's browser process hidden
(the Cmd+H state) — truly invisible, still fully drivable/screenshottable/recordable.
pup_lower_os_windowre-hides;pup_raise_os_window(withforegroundReason) unhides + activates. One session = one browser process, so a shared-profile window group hides/shows together. pup_alert_window= un-hide (not focused) + a native toast whose click focuses the window — macOS has no taskbar to flash.{stop:true}dismisses the toast.- No taskbar identity on mac — the per-window icons/badges/jump-lists below are
Windows-only; on mac the window title's
(session: <id>)suffix is the identity. - Recording needs ffmpeg (
brew install ffmpeg— the error hint says so; re-detects without a restart).
Taskbar preference — the USER can ask for "combined mode"
WINDOWS ONLY. macOS has no taskbar;
pup_configurestores the preference and returnstaskbarSupported:false, but nothing changes on screen. Don't propose it to a Mac user.
pup windows each get their own taskbar button by default ("split": one button per window, badged
with its tab count). If the user says anything like "combine my pup windows", "group the pup
icons", "one taskbar button for pup", "my taskbar is cluttered with pup windows" — that's the
GROUPED preference: pup_configure {"taskbarGrouping":"grouped"} stacks every pup window
under one "Adom Pup" button whose badge shows windows + total tabs. "Split them back" / "separate
icons" → {"taskbarGrouping":"split"}. The setting persists across updates. Change it ONLY when
the user asks — it's their taskbar. Details: pup-windows-sessions-tabs.
Window-type taskbar icons (split mode) — what the user sees per window
WINDOWS ONLY. Everything in this section is a Windows taskbar behaviour. macOS has no taskbar, no per-window tile, and no jump list, so on a Mac none of these icons, overlays or right-click affordances exist. The category signal that DOES survive cross-platform is the tab title (solid dot = signed in, hollow dot = public). On macOS, use
pup_list_windows/pup_list_tabsto tell windows apart — do not refer the user to an icon they cannot see, and do not skip a check on the assumption an icon already answered it.
In split mode every pup window's taskbar icon states its CONTENT KIND. All five share the teal Adom tile with a solid browser-window drawing; the glyph inside the window is the category:
| Glyph inside the window | Category |
|---|---|
| open book | Adom wiki, PUBLIC view (logged out) |
| book + person | Adom wiki, SIGNED IN (the user's logged-in view) |
| the Adom mark | an Adom APP (localhost / cloud-slug proxy URL) |
| globe | the web (any other site) |
| two panes | MIXED (tabs span more than one category) |
The category derives from ALL tabs and updates live (about a second). The corner overlay stays the
ACTIVE TAB'S FAVICON (or window/tab counters in grouped mode) — never repurpose it. Adom wiki tab
titles also lead with a text glyph: solid dot = signed in, hollow dot = public. You never manage
any of this; pup does it automatically. It is worth KNOWING because on Windows the user can see
login state and window kind at a glance — there, do not open a second window to "check" what a
taskbar icon already answers, and when the user says "the window with the book/globe icon" you know
which one they mean. On macOS neither shortcut applies: there is no icon to read, so check with
pup_list_tabs rather than assuming, and expect the user to describe a window by its title or
URL rather than by an icon.
A WIKI window's taskbar right-click jump list also carries a view toggle ("Adom wiki: switch to
logged-in view" / "...to public view") — a USER affordance that relaunches that window under the
other cookie jar with on-screen captions. Its callback verb pup_wiki_set_view is not for AI
threads; pass wikiView:"authed"|"public" on pup_open_window instead. Details: pup-adom-wiki.
Key rules (the essentials — details in the sub-skills)
- Always pass
sessionIdon session-scoped verbs —'{}'targets the active session, which may not be the one you mean. There is no top-levelpupsubcommand; it'sadom-bridge-cli pup_<cmd> <json>. ONE DANGEROUS EXCEPTION:pup_close. There'{}'does NOT mean "the active session" — it means ALL sessions, every window, across every AI thread on the machine, immediately and without confirmation. If you want to close one window it ispup_close_window {"sessionId":"…"}, neverpup_close '{}'. The rule above would otherwise lead you to expect the opposite, which is exactly how a tidy-up call becomes a data-loss event on someone's long-running dashboards. - Background is guaranteed;
foreground:trueis the only way to show a window — not sizing, not navigating, not screenshotting. - Own your window — always pass
--ai-thread "<your conversation name>"; that name is the window's owner (theowner:arg is a legacy alias). Task-prefix yoursessionId(the window key, not an identity). Never navigate/close a window you don't own. - One window, many tabs — use
pup_open_tab, not a secondpup_open_window, for multi-URL work. pup_readiness(notpup_status) answers "is it ready / which browser?".- No semicolons in
pup_eval— use the comma operator or an IIFE. - Complex page fighting you? (a
querySelectorAllcomes back empty, or a screenshot overshoots the target) — it's usually shadow DOM or a nested scroll container. Verify by DOM, scroll the element not the window, count before you eyeball. Seepup-screenshots-recording→ "Driving & verifying tricky pages." - Never broadly kill Chrome —
pup_close_windowfor a specific session. - Surface a verb's
_hintverbatim whenever it reports not-ready or failure. - If pup's verbs TIME OUT (or the AD LED is dim /
browser.bridgeRunning:false) the bridge PROCESS is down — usually right after arestart_bridgeor a crash. To wake it, fire onepup_open_window(a status probe likepup_readinessdoes NOT respawn a down bridge — only a window/tab verb does), then pollpup_list_windowswith backoff until it answers. Do NOT spam opens while AD saysbridge_starting— one is enough; it's already starting. (bridge_liststatus:"running"is not liveness; the LED +browser.bridgeRunning+ an actual verb reply are.) - Full machine-readable catalog:
adom-bridge-cli --ai-thread "<your thread>" pup_describe '{}'.
Verify what you rendered — pup checks it FOR you (v1.9.106)
ok:true means the navigation worked. It says NOTHING about whether the page rendered. A thread
recently opened its own app, got ok:true, and told the user it worked. The page was serving
404 page not found.
So pup now looks. Every pup_open_window / pup_open_tab / pup_navigate /
pup_reload response can carry two fields:
renderCheck— a verdict:{ok, httpStatus, signals[], excerpt, bodyChars, elements}. Signals includehttp_404,page_says_not_found,dev_server_cannot_get,server_error_text,stack_trace_visible,effectively_blank,no_stylesheets_loaded,broken_images_N._verifyRender— what to do. WhenrenderCheck.okis false it leads with "THIS PAGE DID NOT RENDER CORRECTLY — pup checked it, so do NOT report success".
The rule: if renderCheck.ok === false, do not tell the user it works. Screenshot it
(pup_screenshot {sessionId}) and READ the image, fix the cause, pup_reload, and re-check
until the response comes back clean. A 404 or "Cannot GET" usually means the dev server is not running,
is on a different port, or the path is wrong; unstyled HTML means the CSS 404'd; a stack trace means the
app threw.
renderCheck is a cheap DOM probe, not a screenshot, so it can miss things a human eye catches (wrong
layout, wrong content, an element off-screen). A clean renderCheck is not proof the page is right —
when you generated or edited the content, screenshot it anyway.
Name your thread so the user can tell your window apart
Pass --ai-thread "<what this conversation is>" on every adom-bridge-cli call (a global flag, like
--target). A typical user runs ~20 AI threads against one desktop, so pup puts your thread name at the
FRONT of the window title (visible in the taskbar hover-preview header, which truncates ~25-30 chars)
and in the hover tooltip alongside the container, browser and session. That is how the user knows which
of their conversations opened a window. Do NOT export ADOM_AI_THREAD — threads share one container, so
it names the shell rather than you.
Found a pup bug? Please report it
Responses carry _reportIssues on failures and on your first substantive verb. Reports from real
machines are the only way this bridge gets correct:
adom-wiki issue list adom/pup-bridge to check for a duplicate, then
adom-wiki issue create adom/pup-bridge --title "<symptom>" --category bug-report --body "<what you ran, expected, got, the VERBATIM response JSON, bridge version, OS>".
An AD-core problem (relay, bridge lifecycle, caller identity) goes to adom/adom-bridge instead.
Every adom-bridge-cli call needs --ai-thread
AD refuses any command that does not say which AI conversation is asking
(errorCode: caller_identity_required). It is a global flag, like --target, and it goes before
the verb:
adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"cf-web","url":"..."}'
Pick a name the USER would recognise for this conversation, never a UUID. It is attribution, not permission — self-reported, unverifiable, and nothing is gated on it. It exists so a user running ~20 threads can see which tab is driving their machine, and so a pup window can be labelled with the thread that owns it.
Do NOT export ADOM_AI_THREAD — threads share one container and one $HOME, so it names the shell
rather than you: every thread reports the same name, which is worse than none because it is confident
and wrong. Pass it per call (or {"caller":{"aiThread":"..."}} in the args).
AUDIT YOURSELF — the standard procedure when John says "audit your windows" (2026-07-30)
"Audit your windows" ALWAYS includes the ICONS. John had to tell me this twice: I reported sessions, health, geometry and logs and left the taskbar out, and the icon state was where the real regressions were hiding (a window that silently lost its badge after a restart; 8 stale AUMID keys for 3 windows). An audit without the icon section is not an audit — and it is not finished until you have looked at the taskbar ON GLASS, because the API reports a badge as applied while it is invisible.
Run ALL SEVEN sections, in this order, and report them as a table:
- Sessions (bridge view) —
pup_list_windows: count, sessionId, owner, tabCount, profile, ageMinutes,_degraded. Flag: owner:null (ownership protection is off), duplicate sessions pointing at one window, a tabCount that does not match what was opened. - ICONS / TASKBAR (never skip)
- per window: the
overlayfield —favicon+adom:<host>(correct),count:N, ornoneplus its reason. Anoneon a favicon-serving page is a regression; anoneon local PDFs is legitimate (no favicon exists). - AUMID keys:
reg query HKCU\Software\Classes\AppUserModelIdfiltered toAdom.Pup*. Expect exactly 1 (Adom.Pup) under minimalTouch. More = stale keys from closed windows, or per-window identity churn that destroys/recreates taskbar buttons. - ON GLASS:
desktop_screenshot_screen, crop the taskbar strip, and LOOK. Confirm each pup button wears the composite badge (page favicon + Adom mark inset flush in the lower-right). Report how many of N windows are actually badged.
- per window: the
- OS ground truth —
desktop_find_window {titleContains:"(session:"}: one real window per session (no ghosts, no untracked strays), and each rect at the standard inset frame (15,15 2531x1499 on this laptop). A maximized window reads ~-11,-11 2582x1550 — that is maximized, not stranded.-32000,-32000= minimized, confirm with IsIconic before calling it lost. - Health —
pup_evalper window:readyState,visibilityState, a1+1drivability check.visibilityState:hiddenis NORMAL for a parked/minimized window; only frontmost-yet-hidden is the broken state. Note any crashed tabs. - Log —
bridge_log_read, count[crash] [heal] [health] [unstick] [disconnect] [reclaim] [tabs] park-failed Handler error. Explicitly report park-failed and [reclaim] adopted lines: a park-failed window is sitting wherever Chrome left it, and an adoption means pup LOST a window and re-bound it (which produces duplicate sessions for one window). - Chrome footprint — procs + total MB, and pup's share vs the user's own Chrome. Never report a big total as if it were all pup's.
- Cleanup state — leftover test windows, phantom sessions, stale session files.
Report rules: state what is WRONG first, name which findings are your own regressions, and never present an API-reported success (badge applied, verb ok:true) as proof — prove it on glass or in the page.
⏱ Captions: NEVER longer than 2 seconds (John, standing rule)
A caption is a glance, not a dialog. desktop_caption parks text over the user's screen; anything
past ~2s outstays its welcome and covers what they are actually doing. John caught a 15-second one
live and called it out.
- Hard cap is enforced in code (
PUP_CAPTION_MAX_MS = 2000inpupCaption), so a caller passing 5000/8000/15000 is silently clamped. Do not try to route around it. - If something genuinely needs longer attention, it is not a caption. Use
notify_user(a toast the user dismisses on their own terms), or write it into the verb response for the calling AI. - The one thing captions are great for: narrating a foreground/click the instant it happens, e.g. the granted-foreground reason flash. Short, factual, gone.
FOREGROUNDING: always caption the REASON, for 2 seconds (John, standing rule)
Foregrounding a window is a rude operation. It takes over the user's screen while they are working,
and this user runs many AI threads at once and alt-tabs straight back to their own work. That is why
pup FORCES the caller to supply a foregroundReason before it will grant a raise.
The reason must be SHOWN, not just recorded. An audit trail nobody sees is not accountability.
Every granted foreground MUST:
- Flash an on-screen caption naming the session and quoting the caller's reason, so the user can see WHY their screen was taken over, as it happens.
- Show it for 2 seconds maximum (see the caption rule: a caption is a glance, not a dialog).
PUP_CAPTION_MAX_MS = 2000enforces the ceiling at the choke point. - Never be skipped because a mode is quiet. This is a user-protection signal, NOT cosmetic
machinery, so it is deliberately NOT gated behind
minimalTouch.
Implemented by captionForegroundReason(sessionId, reason, verb), called from the granted-raise path.
If you add a NEW path that foregrounds a window, it must call that function. A foreground that appears with no explanation is the exact behaviour this rule exists to prevent, and it has happened: an agent foregrounded a window silently, the user was mid-task, and the disruption read as pup misbehaving rather than as an agent choosing to interrupt.
Related rules that travel with this one:
- Native OS input (
desktop_click/desktop_type) needs the full warn-and-wait protocol, not just a caption, because it goes to whatever window has focus. pup_assisted_clickalready implements toast + caption + wait-for-ready + re-verify. Use it instead of hand-rolling raise-then-click.
---
name: pup
user-invocable: true
description: "Drive a browser on the user's desktop from the cloud — the Puppeteer 'pup' bridge (pup_* verbs via the adom-bridge-cli CLI). pup drives Chrome for Testing (the automation build: no automation banner, deterministic version, toolbar extension rides the launch — see docs/WHY-CFT.md; installed Chrome/Edge are fallbacks) in a pup-managed DURABLE profile (logins/localStorage persist and are shared across pup windows; {isolated:true} for a throwaway), driven over CDP, in the BACKGROUND so it never disturbs the user; NOT the user's real signed-in profile (that's the Adom extension's nbrowser_* verbs), NOT headless (a real rendered window, so screenshots/recording are true-to-life). Chrome for Testing only as a last resort. Open/close windows + tabs, navigate, screenshot, eval JS, record. This is the START-HERE skill: the mental model + when-to-use + the skill map; deeper topics route to the sub-skills. For 'is it ready' use pup_readiness (not pup_status). Trigger words: pup, puppeteer, browser window, open in pup, open my app in pup, browser screenshot, browser eval, browser reload, visual debug, headful chrome, pup_open_window, pup_screenshot, pup_readiness, pup_prewarm, chrome for testing, open in browser, open my web app, show my app in the browser, preview my app, open the app I just built, view my app, open localhost, show my dev server, pup_login, log into a site in pup, autofill password, saved login, durable profile, isolated window."
---
# Pup — browser control on the user's desktop (start here)
Drive a browser on the user's desktop from a cloud container via the `adom-bridge-cli` CLI. Every verb is a
**Bash command** (NOT an MCP tool):
```bash
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"https://example.com"}'
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{"sessionId":"mytask-web"}'
```
## THE CORE GOAL (John's words, so everyone truly understands it)
## "Open my web app in the browser" — the #1 use case, and its one trap
When the user asks to see an app they just built, pup IS the right surface (a real browser window on
their desktop that you can keep driving: reload after fixes, screenshot, eval). The ONE trap: an app
running in a CLOUD container is NOT reachable from the desktop as `localhost:<port>` — pup's Chrome
runs on the DESKTOP. Use the container's public proxy URL instead:
```
https://{container-slug}.adom.cloud/proxy/{port}/
```
Open it background-first (`foreground:false` + the taskbar flash signals the user), give the window
your thread's sessionId, and re-use the SAME window on every subsequent "check it now" (navigate /
reload, don't spawn new windows). Under Hydrogen (local container) `http://localhost:{port}` IS
correct — the trap is cloud-only. pup lints obvious container-local URLs and will warn you.
**Vocabulary + routing (teach the user the words):** call this surface **pup** in conversation
("I opened it in pup") so users learn the name for the AI-driven browser. When the user wants the
page in THEIR OWN browser — their logins, their tabs, a hand-off with no AI driving — that is
**nb** (native browser), a different surface: say so and use nb instead. Displaying inside a
Hydrogen panel next to the editor is **wv**. When in doubt: driving/testing → pup, handing to the
human → nb, docked preview → wv.
**Platform qualifier:** pup requires Bridge (ab). Under **Hydrogen (h)** ab is bundled, so pup is
always available. On **Hydrogen Cloud (hc)** ab may NOT be connected — check `adom-bridge-cli ping`
first; if there's no desktop, show the app in a **wv** panel (the proxy URL works there too) and
offer the Bridge install for real-browser windows.
pup exists to be **an AI-driven browser that can do everything it needs to for you: the best
assistant in the world**. The engine of that goal is the shared durable **adom-you** profile:
- **Surf as adom-you by default.** Every pup window on adom-you contributes every login it makes to
the shared credential store. The more windows surf as adom-you, the better the username/password
database gets, and over time pup can automatically sign in to every website it has ever seen.
- **Isolation is the exception, not a style choice.** Pass `isolated:true` (or a separate profile)
ONLY when the task genuinely needs a clean jar: testing logged-out views, acting as a different
user, untrusted pages. An isolated window's logins are thrown away: they build nothing.
- The dashboard shows a "surfing as adom-you" chip per window and an adom-you count in the header:
if your window says "NOT adom-you", have a reason.
### ALWAYS say SAVE (John's rule)
When the browser offers to **save** something during your driving, the answer is **always Save**:
Chrome's "Save address?", "Save password?", "Save card / payment info?", "Update password?", any
"Save this info?" bubble. Every save enriches the adom-you profile so pup can auto-fill it on every
future site, which IS the core goal above. Never click "No thanks" / "Never" / dismiss, unless the
USER explicitly told you not to save that particular thing.
**Usernames and passwords especially (John's emphasis).** Whenever you sign a window into any site,
SAVE the credentials to the adom-you profile: accept every "Save password?" and "Update password?"
bubble, so the shared username/password store grows into the best adom-you profile it can be. A
signed-in session whose password you did NOT save is a missed brick in that database. If you filled
a login via `pup_login`, pass `save:true` (or accept the resulting Chrome save bubble) so the
credential is persisted, not just used once.
- These are Chrome's own bubbles (browser UI, not page DOM), so click them by accessible name in the
BACKGROUND: `desktop_ui_click {name:"Save"}` (fall back to "Save address"/"Update password" as the
bubble labels them). No foreground, no focus steal.
- If a save bubble appears and you don't act, Chrome dismisses it on the next navigation and the
data is lost forever. Treat a visible save prompt as a must-do before you navigate away.
- The ONE exception: an isolated/throwaway window (`isolated:true`) builds nothing, so saving there
is pointless, skip it.
## The mental model — what pup actually is (read this once)
pup is a small **Node bridge** that Bridge spawns on the user's machine. When you open a window it
does NOT attach to the user's running browser and does NOT run headless. It:
1. **Launches a brand-new process of the browser ALREADY installed** on the machine — **installed Chrome
first, else Microsoft Edge**. On **Windows** every PC ships Edge, so there's almost always a candidate
and **no download**. On **macOS** nothing is guaranteed: Safari is not CDP-drivable, and Edge is not
preinstalled — so on a Mac with no Chrome, pup falls through to downloading Chrome (or cached Chrome
for Testing). Don't promise the user "no download" off Windows; check `pup_readiness` instead. It
uses their *browser binary*, never their *profile*.
2. **…with a pup-managed `--user-data-dir` + a CDP debugging port** (`--remote-debugging-port`), driven over
the Chrome DevTools Protocol. This is a profile **pup owns and persists**, never the user's own signed-in
OS Chrome/Edge profile. By default every window shares ONE durable profile (`adom-you`), so logins,
cookies and localStorage **accumulate and are shared across all pup windows** — sign into a site once and
every pup window is signed in; app state (localStorage) survives between windows. Sharing a profile does
NOT merge windows: each window is still its own OS window with its own taskbar identity. For a private,
throwaway window (a fresh-login test, or acting as a different user) pass `{isolated:true}`, or pass an
explicit `{profile:"<name>"}` for a separate durable jar. (Need the user's REAL signed-in profile/SSO?
That's the **Adom extension** `nbrowser_*` — see below.)
3. **Renders a REAL window, but keeps it in the BACKGROUND.** It is *not* headless — a real GPU-composited
window, so `pup_screenshot` / recording are true-to-life and canvas/WebGL/media render correctly.
But pup immediately z-orders it to the **bottom** (Windows: Win32 `SetWindowPos` with
`SWP_NOACTIVATE`; macOS: the browser process is **hidden**, the Cmd+H state — same contract), so it
renders **without stealing focus and without covering the user's active window**. You drive and
screenshot it fully while it stays hidden. `foreground:true` is the ONLY thing that shows it.
4. **Spawn-verifies + falls through.** The pick order is **Chrome → Edge → cached Chrome for Testing**; pup
actually launches each and, if one won't start (corrupt install), falls to the next — so an open never
hard-fails on a broken browser. The one that launches is **cached as the default**. Only a box with *no*
Chromium at all fetches **Chrome for Testing** (~150 MB, last resort).
5. **Is driven by `sessionId`**, and **one session = one window that holds many tabs**.
Keep this model in mind and every verb makes sense: it's a pup-managed, persistent, backgrounded browser
(the machine's own Chrome/Edge binary, a profile pup owns) driven by CDP — durable and shared by default,
isolated only when you ask.
## Power & rendering — throttling is DEFAULT and correct; `highFps` is the leased override
**Background pup windows are THROTTLED on purpose, to protect the user's CPU + battery.** Chrome
pauses/slows rendering on occluded/unfocused windows, and pup relies on that. This is deliberate —
issue #14 (James): three idle pup windows with the anti-throttle flags on held his laptop out of
deep idle overnight and FLATTENED his battery. So the default is: a background window renders slowly
or not at all until it's focused.
**Consequence you MUST know:** a healthy background window can LOOK frozen — Google Slides stops
animating, a video pauses, the cursor doesn't blink. That is THROTTLING, not a hang. It wakes the
instant the window is focused (`visibilityState` flips to `visible`). Before ever calling a window
"frozen", check: is it just backgrounded? (on-screen + CDP-responsive + `readyState:complete` = it's
throttled, not broken.)
**When you genuinely need a background window to keep rendering** — recording it, or a live view the
user watches WITHOUT focusing it (a dashboard, a running demo) — upgrade that ONE window:
`pup_open_window {highFps:true}` (or the recorder path sets it). This applies the anti-throttle
flags so the occluded window renders full-rate. It is **LEASED**: 20 min by default, 120 max, and it
**auto-downgrades when idle / when the lease lapses**, so it can NEVER drain the battery the way
issue #14 did. Renew a still-needed lease with `pup_highfps_extend {sessionId, minutes}`.
**The balance, in one line:** throttled by default (battery), `highFps` only for a window that must
keep rendering while unfocused, and the lease makes that self-limiting. Do NOT reach for `highFps` to
"fix" a window that merely LOOKS frozen — just focus it (or tell the user to click it).
## Under heavy load or a crash, pup self-heals — do NOT panic-kill or declare it dead
pup is built to stay usable when the shared Chrome is under heavy load, and to recover on its own if
Chrome ever crashes. Read these signals correctly instead of assuming the worst:
- **`_degraded` on `pup_list_windows` / `pup_status`.** These verbs ALWAYS return the full
window list, fast, even when a window is too busy (Chrome under heavy load on a heavy SPA) to answer
a live read. A busy window comes back marked `degraded` with its LAST-KNOWN url/title. `_degraded`
means "that window is busy, NOT crashed — retry shortly for live values." It does NOT mean pup is
broken. (Before v1.9.172 these verbs could hang and return null under load, which read as "pup is
frozen" — that can no longer happen.)
- **A window vanished / a `_cleanupReport` says a window crashed.** If the shared Chrome crashes, pup's
health loop (15s) detects it and RELAUNCHES every affected window on its own — signed in, in the
background, at its last URL — then reports it via `_cleanupReport` ("pup auto-recovered your …
window after a Chrome crash"). You do NOT need to kill Chrome, clean the profile, or reopen windows
by hand. Wait ~15-30s and re-list; the windows come back. Only if a page crashes Chrome REPEATEDLY
does pup stop retrying it (after 2 attempts) and tell the user the page itself looks unstable.
- **One window times out but others work = a per-window jam, not a dead bridge.** Verbs are
concurrent; a slow/jammed window does not block operations on OTHER windows, and never blocks
list/status. If `pup_eval` on window A times out, window B still works and the bridge is fine.
- **Genuinely frozen vs throttled vs busy:** a background window that looks static is almost always
just THROTTLED (see Power & rendering above) — focus it and it wakes. A window that is BUSY shows up
as `degraded`. A window that CRASHED gets auto-relaunched. None of these is "pup is dead."
## ⛔ HARD RULE: NEVER send NATIVE OS input without warning the user FIRST (John, 2026-07-29)
**Native OS input (`desktop_click`, `desktop_type`, `desktop_press_key`, `desktop_hover`) does NOT go
to a window. It goes to WHATEVER WINDOW HAS OS FOCUS AT THAT INSTANT.** If the user has alt-tabbed
away — and this user runs many AI threads at once and alt-tabs away from a foregrounded window almost
immediately, every time — your keystrokes land in THEIR document, THEIR terminal, THEIR editor.
This happened: an agent foregrounded a pup window, typed a username with `desktop_type` without ever
telling the user, the user had already alt-tabbed back to their own work, and the text went into their
window. The agent then compounded it by concluding "Windows failed to foreground the window" — blaming
the OS for what was really "the human took their focus back, because nobody asked them not to."
**NEVER do that again. Before ANY native input, in this order:**
1. **`notify_user` TOAST** — tell them what you are about to do and that you need the window.
2. **`desktop_caption`** — on-screen, so it is visible while they look at the window.
3. **Say explicitly: "please don't type or click until I say done."** They cannot know otherwise.
4. **WAIT for them to be ready** — `waitForUserReady` (poll `osGetForegroundWindow()` vs the session
hwnd until it is stable). NEVER dispatch while the foreground is not your window.
5. **Re-verify foreground IMMEDIATELY before EACH native event**, not once at the start. Focus can
change between two events, and a half-typed password in the wrong window is a real harm.
6. **Caption each step** as you go, and caption when you are DONE so they know they can resume.
**`pup_assisted_click` already implements all six.** Use it instead of hand-rolling
raise-then-click. If you need native typing, follow the same protocol around it.
**Corollaries that cost this project real time:**
- CDP input (`pup_click`, `pup_type`) needs NO foreground and disturbs nobody — **always
prefer it**. Reach for native input ONLY when the target genuinely cannot be driven any other way
(a cross-origin FedCM chooser, native OS chrome, a site that rejects synthetic events).
- Native input returning `ok:true` means "the event was dispatched to the OS", **NOT** "it reached
your window". Never treat that ok as proof. Verify the effect in the page.
- If native input seems not to land, the FIRST hypothesis is "it went to another window because
focus moved", **never** "the OS failed to foreground my window".
## Standing rule: TOAST THE USER before foregrounding (John, repeatedly)
NEVER put a window on the user's screen silently. Any time you FOREGROUND a pup window — a demo, a
"show me", a reveal, a real-cursor click that needs the window up — send an AD toast FIRST (or let
pup's auto-toast do it, v1.9.166+) so the user's attention is drawn to what just appeared. A window
that pops up with no heads-up reads as the agent hijacking the screen. pup now auto-fires
`notify_user` on every granted foreground, but you should still narrate WHY in the reason. For a
background nudge (not a full foreground), use `pup_alert_window` (taskbar flash) instead.
**If you foreground in order to CLICK something pup's CDP click can't drive** (a native OS dialog, or
a cross-origin FedCM "Sign in with Google" account chooser that rejects in-page clicks), use
**`pup_assisted_click`** — never raise-then-click by hand. It captions the reason + toasts first,
foregrounds, then WAITS and detects if the user alt-tabbed away (holding, captioning "Adom is waiting"
until they're back — it clicks NOTHING while they're not ready), then does the tight real-cursor
hover→click and narrates each step. This is the protocol so pup never foregrounds-and-acts while the
user is not ready.
## When pup — vs the Adom extension (`nbrowser_*`)
| The task | Tool |
|---|---|
| Screenshot / scrape / test / demo a page; drive an app you're building; anything that does NOT need the user's logins | **pup (this)** |
| Log into a site, use saved passwords, fill a form on a signed-in page, download behind a login, sail past captchas | **Adom browser extension** (`nbrowser_*`, the `adom-browser-extension` skill) — drives their REAL signed-in Chrome/Edge |
pup drives the machine's browser binary in a profile **pup manages** — durable and shared across pup windows,
but never the user's own signed-in OS Chrome/Edge profile, so it can't borrow logins the user already has
there. pup accumulates its OWN logins over time (sign in once inside pup, it sticks). The extension is the
counterpart when you need the user's REAL profile/SSO directly.
## Logins & credentials (the durable profile pays off here)
- Sign into a site ONCE in any pup window and the durable profile keeps it — every later window
(any thread) is already signed in. Google/wiki proven; works for any cookie-session site.
- `pup_login {sessionId, username?, password?, submit?, save?}` fills an HTML login form in
the BACKGROUND (no Chrome dropdown, no foregrounding, React/Vue-safe). With `save:true` the
cred is stored (OS keychain via credential_set) and next time a bare
`pup_login {sessionId, submit:true}` signs in from the vault.
- Vault-matched login pages AUTO-FILL on page load (fill only, never auto-submit) — response
carries `_autoLogin`. Off switch: `pup_configure {autoLogin:"off"}`.
- Chrome's own "Save password?" bubble works in pup windows (the durable profile enables the
password manager), so manual logins accumulate too.
- Need a window that is NOT signed in (fresh-login test, act-as-another-user)? `{isolated:true}`
on open. Need cross-origin eval for dev? `{webSecurity:false}` (forces isolation, keeps the
credentialed profile safe).
## Skill map — this is a pack
| Skill | Read it for |
|---|---|
| **`pup`** (this file) | The mental model, when-to-use, quick start, the essentials |
| **`pup-windows-sessions-tabs`** | sessionIds + naming, **window ownership** (don't steal another AI thread's window), **background vs foreground** rules, **many tabs in one window**, list/switch/close, verifying what you opened |
| **`pup-screenshots-recording`** | Screenshots (full-page, full-res), reading the shot, **recording** a window vs the whole desktop, framing a window for a clean shot without foregrounding |
| **`pup-browsers-and-chrome`** | The browser detection/CDP-launch details, `pup_use` to pin a browser, **installing Chrome / Chrome-for-Testing** (incl. the UAC-notify flow), **readiness**, the cold-start error table, Node provisioning, the shell-approval gate |
| **`pup-adom-wiki`** | Driving + verifying **`wiki.adom.inc`** pages in pup (show/screenshot/verify a published page; the login situation — pup views the wiki logged out for now) |
*(Maintaining the bridge itself? The `pup-bridge-dev` + `pup-bridge-publish` skills are source-only in the
repo — not shipped here.)*
## Quick start — the 90% path
```bash
# Open in the BACKGROUND (automatic — never disturbs the user), then drive it invisibly.
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"http://localhost:3000"}'
adom-bridge-cli --ai-thread "<your thread>" pup_eval '{"sessionId":"mytask-web","expr":"document.title"}' # verify it really loaded
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{"sessionId":"mytask-web"}' # true-to-life PNG
```
- **Your `--ai-thread` name IS the owner** — pup owns every window by the ai-thread that opened it, so
just always pass `--ai-thread "<your conversation name>"` (you already must — see below) and the window
is yours; no separate `owner` arg needed (it's a legacy alias). A `pup_open_window` with no
ai-thread (and no `owner`) is **refused** with `caller_identity_required`. Task-prefix your `sessionId`
(it's the window's KEY, never its identity) so two of your own windows don't collide. See
`pup-windows-sessions-tabs`.
- **`ok:true` means navigation *started*, not that content rendered** — sanity-check with `pup_eval`
on `document.title` (404s / login walls change it) or a screenshot.
- **On a fresh PC it's usually a non-event** — pup drives the installed Chrome/Edge with no download. The
rare "no browser at all" / Node-provisioning / disk cases are all in `pup-browsers-and-chrome`.
## macOS differences (v1.9.74+) — same verbs, mac-native mechanics
Every `pup_*` verb works on macOS; only the OS-presentation layer differs:
- **Background = hidden app.** A background open launches the session's browser process hidden
(the Cmd+H state) — truly invisible, still fully drivable/screenshottable/recordable.
`pup_lower_os_window` re-hides; `pup_raise_os_window` (with `foregroundReason`)
unhides + activates. One session = one browser process, so a shared-profile window group
hides/shows together.
- **`pup_alert_window` = un-hide (not focused) + a native toast** whose click focuses the
window — macOS has no taskbar to flash. `{stop:true}` dismisses the toast.
- **No taskbar identity on mac** — the per-window icons/badges/jump-lists below are
Windows-only; on mac the window title's `(session: <id>)` suffix is the identity.
- **Recording** needs ffmpeg (`brew install ffmpeg` — the error hint says so; re-detects
without a restart).
## Taskbar preference — the USER can ask for "combined mode"
> **WINDOWS ONLY.** macOS has no taskbar; `pup_configure` stores the preference and returns
> `taskbarSupported:false`, but nothing changes on screen. Don't propose it to a Mac user.
pup windows each get their own taskbar button by default ("split": one button per window, badged
with its tab count). If the user says anything like "combine my pup windows", "group the pup
icons", "one taskbar button for pup", "my taskbar is cluttered with pup windows" — that's the
GROUPED preference: `pup_configure {"taskbarGrouping":"grouped"}` stacks every pup window
under one "Adom Pup" button whose badge shows windows + total tabs. "Split them back" / "separate
icons" → `{"taskbarGrouping":"split"}`. The setting persists across updates. Change it ONLY when
the user asks — it's their taskbar. Details: `pup-windows-sessions-tabs`.
## Window-type taskbar icons (split mode) — what the user sees per window
> **WINDOWS ONLY.** Everything in this section is a Windows taskbar behaviour. **macOS has no
> taskbar**, no per-window tile, and no jump list, so on a Mac none of these icons, overlays or
> right-click affordances exist. The category signal that DOES survive cross-platform is the **tab
> title** (solid dot = signed in, hollow dot = public). On macOS, use `pup_list_windows` /
> `pup_list_tabs` to tell windows apart — do not refer the user to an icon they cannot see,
> and do not skip a check on the assumption an icon already answered it.
In split mode every pup window's taskbar icon states its CONTENT KIND. All five share the teal
Adom tile with a solid browser-window drawing; the glyph inside the window is the category:
| Glyph inside the window | Category |
|---|---|
| open book | Adom wiki, PUBLIC view (logged out) |
| book + person | Adom wiki, SIGNED IN (the user's logged-in view) |
| the Adom mark | an Adom APP (localhost / cloud-slug proxy URL) |
| globe | the web (any other site) |
| two panes | MIXED (tabs span more than one category) |
The category derives from ALL tabs and updates live (about a second). The corner overlay stays the
ACTIVE TAB'S FAVICON (or window/tab counters in grouped mode) — never repurpose it. Adom wiki tab
titles also lead with a text glyph: solid dot = signed in, hollow dot = public. You never manage
any of this; pup does it automatically. It is worth KNOWING because **on Windows** the user can see
login state and window kind at a glance — there, do not open a second window to "check" what a
taskbar icon already answers, and when the user says "the window with the book/globe icon" you know
which one they mean. **On macOS neither shortcut applies**: there is no icon to read, so check with
`pup_list_tabs` rather than assuming, and expect the user to describe a window by its title or
URL rather than by an icon.
A WIKI window's taskbar right-click jump list also carries a view toggle ("Adom wiki: switch to
logged-in view" / "...to public view") — a USER affordance that relaunches that window under the
other cookie jar with on-screen captions. Its callback verb `pup_wiki_set_view` is not for AI
threads; pass `wikiView:"authed"|"public"` on pup_open_window instead. Details: `pup-adom-wiki`.
## Key rules (the essentials — details in the sub-skills)
- **Always pass `sessionId`** on session-scoped verbs — `'{}'` targets the *active* session, which may not
be the one you mean. There is no top-level `pup` subcommand; it's `adom-bridge-cli pup_<cmd> <json>`.
**ONE DANGEROUS EXCEPTION: `pup_close`.** There `'{}'` does NOT mean "the active session" — it means
**ALL sessions, every window, across every AI thread on the machine**, immediately and without
confirmation. If you want to close one window it is `pup_close_window {"sessionId":"…"}`, never
`pup_close '{}'`. The rule above would otherwise lead you to expect the opposite, which is exactly
how a tidy-up call becomes a data-loss event on someone's long-running dashboards.
- **Background is guaranteed; `foreground:true` is the only way to show a window** — not sizing, not
navigating, not screenshotting.
- **Own your window** — always pass `--ai-thread "<your conversation name>"`; that name is the window's
owner (the `owner:` arg is a legacy alias). Task-prefix your `sessionId` (the window key, not an
identity). Never navigate/close a window you don't own.
- **One window, many tabs** — use `pup_open_tab`, not a second `pup_open_window`, for multi-URL work.
- **`pup_readiness`** (not `pup_status`) answers "is it ready / which browser?".
- **No semicolons in `pup_eval`** — use the comma operator or an IIFE.
- **Complex page fighting you?** (a `querySelectorAll` comes back empty, or a screenshot overshoots the
target) — it's usually shadow DOM or a nested scroll container. Verify by DOM, scroll the *element* not
the window, count before you eyeball. See `pup-screenshots-recording` → "Driving & verifying tricky pages."
- **Never broadly kill Chrome** — `pup_close_window` for a specific session.
- **Surface a verb's `_hint` verbatim** whenever it reports not-ready or failure.
- **If pup's verbs TIME OUT (or the AD LED is dim / `browser.bridgeRunning:false`) the bridge PROCESS is
down** — usually right after a `restart_bridge` or a crash. To wake it, fire **one**
`pup_open_window` (a status probe like `pup_readiness` does NOT respawn a down bridge — only a
window/tab verb does), then poll `pup_list_windows` with backoff until it answers. **Do NOT spam
opens** while AD says `bridge_starting` — one is enough; it's already starting. (`bridge_list`
`status:"running"` is not liveness; the LED + `browser.bridgeRunning` + an actual verb reply are.)
- Full machine-readable catalog: `adom-bridge-cli --ai-thread "<your thread>" pup_describe '{}'`.
## Verify what you rendered — pup checks it FOR you (v1.9.106)
**`ok:true` means the navigation worked. It says NOTHING about whether the page rendered.** A thread
recently opened its own app, got `ok:true`, and told the user it worked. The page was serving
`404 page not found`.
So pup now looks. Every `pup_open_window` / `pup_open_tab` / `pup_navigate` /
`pup_reload` response can carry two fields:
- **`renderCheck`** — a verdict: `{ok, httpStatus, signals[], excerpt, bodyChars, elements}`. Signals
include `http_404`, `page_says_not_found`, `dev_server_cannot_get`, `server_error_text`,
`stack_trace_visible`, `effectively_blank`, `no_stylesheets_loaded`, `broken_images_N`.
- **`_verifyRender`** — what to do. When `renderCheck.ok` is false it leads with
*"THIS PAGE DID NOT RENDER CORRECTLY — pup checked it, so do NOT report success"*.
**The rule: if `renderCheck.ok === false`, do not tell the user it works.** Screenshot it
(`pup_screenshot {sessionId}`) and READ the image, fix the cause, `pup_reload`, and re-check
until the response comes back clean. A 404 or "Cannot GET" usually means the dev server is not running,
is on a different port, or the path is wrong; unstyled HTML means the CSS 404'd; a stack trace means the
app threw.
`renderCheck` is a cheap DOM probe, not a screenshot, so it can miss things a human eye catches (wrong
layout, wrong content, an element off-screen). A clean `renderCheck` is not proof the page is *right* —
when you generated or edited the content, screenshot it anyway.
## Name your thread so the user can tell your window apart
Pass `--ai-thread "<what this conversation is>"` on every `adom-bridge-cli` call (a global flag, like
`--target`). A typical user runs ~20 AI threads against one desktop, so pup puts your thread name at the
FRONT of the window title (visible in the taskbar hover-preview header, which truncates ~25-30 chars)
and in the hover tooltip alongside the container, browser and session. That is how the user knows which
of their conversations opened a window. Do NOT export `ADOM_AI_THREAD` — threads share one container, so
it names the shell rather than you.
## Found a pup bug? Please report it
Responses carry `_reportIssues` on failures and on your first substantive verb. Reports from real
machines are the only way this bridge gets correct:
`adom-wiki issue list adom/pup-bridge` to check for a duplicate, then
`adom-wiki issue create adom/pup-bridge --title "<symptom>" --category bug-report
--body "<what you ran, expected, got, the VERBATIM response JSON, bridge version, OS>"`.
An AD-core problem (relay, bridge lifecycle, caller identity) goes to `adom/adom-bridge` instead.
## Every adom-bridge-cli call needs `--ai-thread`
AD refuses any command that does not say which AI conversation is asking
(`errorCode: caller_identity_required`). It is a **global flag**, like `--target`, and it goes before
the verb:
```bash
adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"cf-web","url":"..."}'
```
Pick a name the USER would recognise for this conversation, never a UUID. It is **attribution, not
permission** — self-reported, unverifiable, and nothing is gated on it. It exists so a user running ~20
threads can see which tab is driving their machine, and so a pup window can be labelled with the thread
that owns it.
**Do NOT `export ADOM_AI_THREAD`** — threads share one container and one `$HOME`, so it names the shell
rather than you: every thread reports the same name, which is worse than none because it is confident
and wrong. Pass it per call (or `{"caller":{"aiThread":"..."}}` in the args).
## AUDIT YOURSELF — the standard procedure when John says "audit your windows" (2026-07-30)
**"Audit your windows" ALWAYS includes the ICONS.** John had to tell me this twice: I reported
sessions, health, geometry and logs and left the taskbar out, and the icon state was where the real
regressions were hiding (a window that silently lost its badge after a restart; 8 stale AUMID keys for
3 windows). An audit without the icon section is not an audit — and it is not finished until you have
looked at the taskbar ON GLASS, because the API reports a badge as applied while it is invisible.
Run ALL SEVEN sections, in this order, and report them as a table:
1. **Sessions (bridge view)** — `pup_list_windows`: count, sessionId, owner, tabCount, profile,
ageMinutes, `_degraded`. Flag: owner:null (ownership protection is off), duplicate sessions
pointing at one window, a tabCount that does not match what was opened.
2. **ICONS / TASKBAR (never skip)**
- per window: the `overlay` field — `favicon+adom:<host>` (correct), `count:N`, or `none` **plus
its reason**. A `none` on a favicon-serving page is a regression; a `none` on local PDFs is
legitimate (no favicon exists).
- AUMID keys: `reg query HKCU\Software\Classes\AppUserModelId` filtered to `Adom.Pup*`.
Expect **exactly 1** (`Adom.Pup`) under minimalTouch. More = stale keys from closed windows, or
per-window identity churn that destroys/recreates taskbar buttons.
- **ON GLASS:** `desktop_screenshot_screen`, crop the taskbar strip, and LOOK. Confirm each pup
button wears the composite badge (page favicon + Adom mark inset flush in the lower-right).
Report how many of N windows are actually badged.
3. **OS ground truth** — `desktop_find_window {titleContains:"(session:"}`: one real window per
session (no ghosts, no untracked strays), and each rect at the standard inset frame
(15,15 2531x1499 on this laptop). A maximized window reads ~-11,-11 2582x1550 — that is
maximized, not stranded. `-32000,-32000` = minimized, confirm with IsIconic before calling it lost.
4. **Health** — `pup_eval` per window: `readyState`, `visibilityState`, a `1+1` drivability check.
`visibilityState:hidden` is NORMAL for a parked/minimized window; only **frontmost-yet-hidden** is
the broken state. Note any crashed tabs.
5. **Log** — `bridge_log_read`, count `[crash] [heal] [health] [unstick] [disconnect] [reclaim]
[tabs] park-failed Handler error`. Explicitly report **park-failed** and **[reclaim] adopted**
lines: a park-failed window is sitting wherever Chrome left it, and an adoption means pup LOST a
window and re-bound it (which produces duplicate sessions for one window).
6. **Chrome footprint** — procs + total MB, and **pup's share vs the user's own Chrome**. Never
report a big total as if it were all pup's.
7. **Cleanup state** — leftover test windows, phantom sessions, stale session files.
**Report rules:** state what is WRONG first, name which findings are your own regressions, and never
present an API-reported success (badge applied, verb ok:true) as proof — prove it on glass or in the
page.
## ⏱ Captions: NEVER longer than 2 seconds (John, standing rule)
A caption is a **glance**, not a dialog. `desktop_caption` parks text over the user's screen; anything
past ~2s outstays its welcome and covers what they are actually doing. John caught a 15-second one
live and called it out.
- **Hard cap is enforced in code** (`PUP_CAPTION_MAX_MS = 2000` in `pupCaption`), so a caller passing
5000/8000/15000 is silently clamped. Do not try to route around it.
- **If something genuinely needs longer attention, it is not a caption.** Use `notify_user` (a toast
the user dismisses on their own terms), or write it into the verb response for the calling AI.
- The one thing captions are *great* for: narrating a foreground/click the instant it happens, e.g.
the granted-foreground reason flash. Short, factual, gone.
## FOREGROUNDING: always caption the REASON, for 2 seconds (John, standing rule)
Foregrounding a window is a **rude operation**. It takes over the user's screen while they are working,
and this user runs many AI threads at once and alt-tabs straight back to their own work. That is why
pup FORCES the caller to supply a `foregroundReason` before it will grant a raise.
**The reason must be SHOWN, not just recorded.** An audit trail nobody sees is not accountability.
Every granted foreground MUST:
1. Flash an on-screen caption naming the session and quoting the caller's reason, so the user can see
WHY their screen was taken over, as it happens.
2. Show it for **2 seconds maximum** (see the caption rule: a caption is a glance, not a dialog).
`PUP_CAPTION_MAX_MS = 2000` enforces the ceiling at the choke point.
3. Never be skipped because a mode is quiet. This is a user-protection signal, NOT cosmetic
machinery, so it is deliberately NOT gated behind `minimalTouch`.
Implemented by `captionForegroundReason(sessionId, reason, verb)`, called from the granted-raise path.
**If you add a NEW path that foregrounds a window, it must call that function.** A foreground that
appears with no explanation is the exact behaviour this rule exists to prevent, and it has happened:
an agent foregrounded a window silently, the user was mid-task, and the disruption read as pup
misbehaving rather than as an agent choosing to interrupt.
Related rules that travel with this one:
- Native OS input (`desktop_click` / `desktop_type`) needs the full warn-and-wait protocol, not just a
caption, because it goes to whatever window has focus.
- `pup_assisted_click` already implements toast + caption + wait-for-ready + re-verify. Use it
instead of hand-rolling raise-then-click.