source-command-debug-ui
SkillDev toolsDrive the Voxt UI from Codex — chrome-devtools MCP setup, debugUI helpers, fast multi-user sign-in
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the source-command-debug-ui skill
What this skill tells your AI
The instructions your AI receives, as published by actual-chat/actual-chat in .agents/skills/source-command-debug-ui/SKILL.md and read by ahel’s review.
Use this skill when the user asks to run the migrated source command debug-ui.
Command Template
/debug-ui
Use this when the user wants Codex to interact with the running app in a browser — log in as a test user, click around, take snapshots, send messages, verify a fix in the UI. Also covers the multi-Chrome setup for two-user scenarios.
For server lifecycle / rebuild concerns (stopping, restarting, reading
build errors), see /server-loop. They're meant to be used together: the
loop manages the .NET server; this skill manages the browser side.
TL;DR
- One Chrome on
:9222, one MCP atlocalhost:8765. Tools appear in Codex asmcp__chrome-devtools-1__*. Two browsers?ai chrome*2on the host gives anonymous profiles on:9222and:9223; the second MCP atlocalhost:8766exposesmcp__chrome-devtools-2__*. - Sign in instantly with
await debugUI.signIn('+1 555 555 5550')— the predefined phones (+1 555 555 5550..5555) and dev emails (test-*@actual.chat) all accept TOTP111111. - Get the current user's id with
await debugUI.getUserId()— also prints to console. - One PM URL is
/chat/p-<userIdA>-<userIdB>(sorted ordinally).
When the user says "click around"
This is the right skill. Plan: confirm the setup is up (compose service +
host Chrome), drive the flow via the chrome-devtools MCP, fall back to
running JS via evaluate_script when the a11y tree is awkward.
Prefer debugUI over UI clicks
If a step can be accomplished via a debugUI.* call, do it that way
— it's faster, deterministic, and dodges the modal/onboarding/bubble
soup. Use UI clicks only when:
- the user explicitly asked for the UI flow ("click Sign in", "go through the modal"), or
- the UI itself is what's under test (e.g. verifying the sign-in modal validates phone formatting, that the avatar uploader handles drag & drop, that a tutorial step renders correctly).
Concrete examples — defaults in italics, only switch to clicks if one of the conditions above applies:
- Logging in a test user →
await debugUI.signIn('+1 555 555 5550') - Logging out →
await debugUI.signOut() - Getting the current user's id →
await debugUI.getUserId() - Switching render mode →
await debugUI.setRenderMode('w') - Restarting the server →
debugUI.stopServer()(or curl) - Skipping onboarding/bubbles →
debugUI.resetOnboarding(false)/resetBubbles(false)+ reload - Navigating to a chat or settings page →
location.assign('/chat/...')(regular browser navigation;debugUI.navigateToexists but goes through the server-sideNavigationManagerand is rarely what you want — see the table below).
1. Setup overview
The browser-debug rig is three moving pieces:
host Codex container docker-compose
───────────────────── ───────────────────── ─────────────────────
Chrome :9222 (profile 1) ───CDP──┐ ┌─ chrome-devtools-mcp-1
│ │ HTTP :8765 → Chrome :9222
Chrome :9223 (profile 2) ───CDP──┼─ host.docker ─┤
│ .internal │
│ └─ chrome-devtools-mcp-2
│ HTTP :8766 → Chrome :9223
│
┌─────────────────────────┴┐
│ Codex MCP client │ → tools `mcp__chrome-devtools-1__*`
│ (`.mcp.json`) │ `mcp__chrome-devtools-2__*`
└──────────────────────────┘
The bridge is supergateway --stateful --outputTransport streamableHttp
wrapping chrome-devtools-mcp (which is stdio-only). Stateful is what
keeps take_snapshot → click(uid) working across calls.
The image is built from services/chrome-devtools-mcp/ with both packages
baked in (no npx at runtime). Its entrypoint runs a supervisor loop and
a Chrome watcher: when host Chrome becomes unreachable for ~10 s the
watcher kills supergateway, the loop waits for Chrome to come back, then
starts a fresh supergateway. Combined with restart: unless-stopped,
restarting host Chrome no longer requires recreating the compose service —
clients just need to re-issue take_snapshot after the recycle, since
the previous snapshot UIDs are tied to the dead Chrome session.
Bring it up (host)
ai chrome # 1 instance, default profile, port 9222
ai chrome:50000 # 1 instance, default profile, port 50000
ai chrome*2 # 2 instances, anonymous profiles, ports 9222 & 9223
ai chrome*2:50000 # 2 instances, anonymous profiles, ports 50000 & 50001
ai edge[:PORT][*N] # same shape for Microsoft Edge (default port 9322)
The *N suffix forces anonymous (per-port) profiles so cookies don't
bleed across the instances — that's how a single user agent can host two
Voxt logins side by side.
docker compose up -d --force-recreate chrome-devtools-mcp-1 chrome-devtools-mcp-2
To re-target an MCP at a different Chrome port, set CHROME_DEBUG_PORT_1
/ CHROME_DEBUG_PORT_2 in .env (defaults 9222 / 9223) and recreate
just that service.
Probe it
curl -s -o - -w "\n%{http_code}\n" http://localhost:8765/healthz # → ok / 200
curl -s -o - -w "\n%{http_code}\n" http://localhost:8766/healthz # → ok / 200
If the MCP tools don't appear in Codex after compose is up, the most common cause is that Codex was started before the MCP service was healthy — restart Codex.
Why two MCPs
Each chrome-devtools-mcp instance is hard-bound to one Chrome via
--browserUrl at startup. Cookies are per Chrome profile, not per MCP
session, so two independent Voxt logins → two Chromes → two MCPs. The
isolatedContext parameter on new_page does not isolate cookies
when MCP is connected to an existing Chrome — verified empirically.
Combine the MCP with Playwright when you need richer data
The chrome-devtools MCP is convenient — take_snapshot, click(uid),
evaluate_script, request lists — but its list_console_messages is
paginated, type-filtered, and (in practice) misses some entries. When
you need ALL of a page's output (every console.* level, every page
error, every unhandled rejection, network failures) it's totally OK
to attach Playwright via CDP in parallel, just for that.
Use Playwright alongside the MCP, not as a replacement:
- MCP: continues to drive the UI (snapshot/click/evaluate). It's ergonomic for one-off interactions and exploratory poking.
- Playwright: a tiny script connects via
chromium.connectOverCDP, attachespage.on('console' | 'pageerror' | 'crash'), and streams every event to a log file. Read the file as needed.
The reverse direction also works: Playwright is also great for long predictable UI sequences (e.g. "sign in, navigate to chat, start audio + video, wait 60s, stop, re-enter") that you want to re-run multiple times without retyping. MCP is poor for that — every step is a separate evaluate.
Sketch (tmp/console-tap.mjs):
import { chromium } from 'playwright';
const port = Number(process.argv[2] ?? 9222);
const browser = await chromium.connectOverCDP(`http://localhost:${port}`);
const ctx = browser.contexts()[0];
const page = ctx.pages().find(p => p.url().includes('local.voxt.ai')) ?? ctx.pages()[0];
page.on('console', m => process.stdout.write(`[${m.type()}] ${m.text()}\n`));
page.on('pageerror', e => process.stdout.write(`[PERR] ${e.stack ?? e.message}\n`));
page.on('crash', () => process.stdout.write(`[CRASH]\n`));
await new Promise(() => {}); // keep alive; SIGINT to stop
Run with node tmp/console-tap.mjs 9222 > tmp/console.log 2>&1 &
(or via the harness's run_in_background Bash flag), then drive the
page from the MCP as usual. Tail / grep the log file when something
mysterious happens.
Caveats:
- Playwright connects via CDP to the same Chrome — don't try to take
exclusive control (no
browser.close()until you're done with MCP too). Just attach listeners. - Same realm, same cookies. Playwright sees what the MCP sees; this isn't isolation, it's an extra observer.
connectOverCDPworks becauseai chromealready starts Chrome with--remote-debugging-port=.... Same flag the MCP uses.
2. The debugUI helpers (browser console)
window.debugUI is the single entry point — exposed in every page after
load. Most methods are async. Source lives in
src/dotnet/UI.Blazor/Services/DebugUI/ (debug-ui.ts for the JS
surface, DebugUI*.cs partials for the server side).
Auth (DebugUI.Auth.cs / debug-ui.ts)
| Method | What it does |
|---|---|
await debugUI.signIn(phoneOrEmail, opts?) | Local-dev-only. Scripts the full sign-in: send TOTP, validate, confirm pending registration, wait for AccountUI.OwnAccount to flip to non-guest (5s timeout, throws on miss), then optionally clears onboarding/bubbles. opts = { register?: true, skipOnboarding?: true, skipBubbles?: true }. |
await debugUI.signOut() | Goes through AccountUI.SignOut (deregister notifications, full client teardown, page reload). |
await debugUI.getUserId() | Returns the signed-in user's UserId; also console.logs it. Works for guests too — returns the guest id (prefixed with ~). |
TOTP shortcut: signIn always sends 111111. That's the dev-bypass
code for test-*@actual.chat emails and the predefined phones the
loop wires up (+1 555 555 5550..5555). Other inputs will fail TOTP
validation — feature, not bug.
Settings / state (DebugUI.Settings.cs)
| Method | What it does |
|---|---|
await debugUI.setRenderMode('a' | 's' | 'w') | Switches Auto / Server / WASM. Mirrors RenderModeSelector.ChangeMode and triggers a page reload. |
debugUI.getCurrentRenderMode() | Returns 's' or 'w' — pure client-side, derived from whether the WASM runtime is loaded. Note: 'a' (Auto) starts as 's' and upgrades to 'w'. |
debugUI.resetOnboarding(enable) | false skips all onboarding steps; true resets them. |
debugUI.resetBubbles(enable) | Same shape for feature-tip bubbles. |
debugUI.enableAudioSync(enable) | Toggles the IDebugAudio flag. |
debugUI.forceRecordingStatus(status?) | Forces what every recording control reports. '<Kind>' or '<Kind>:<code>', e.g. 'NoMicrophonePermission', 'StartFailed:NotReadableError'; omit to clear. Kinds: Off, Starting, Recording, Reconnecting, Disconnected, NoMicrophonePermission, NoMicrophone, MicrophoneBusy, StartFailed. |
await debugUI.getThreadPoolSettings() | Inspect current thread-pool min/max/available. |
debugUI.changeThreadPoolSettings(min, minIO, max, maxIO) | Runtime tune. Local-dev only. |
Lifecycle / RPC (DebugUI.cs)
| Method | What it does |
|---|---|
debugUI.stopServer() | Local-dev only. Same path as /health/stop and s keypress in the loop terminal. |
debugUI.disconnectBlazorRpc() | Drops the Blazor circuit (server mode) or the WASM RPC peer. |
debugUI.disconnectJSRpc(workerKind?) | Disconnects the JS-side RPC peer for one or all workers. |
debugUI.navigateTo(url) | Server-side NavigationManager.NavigateTo. Usually avoid — prefer location.assign(url) or location.href = url, which is both faster and more predictable. Reach for this only when you specifically want the server's NavigationManager to drive the routing. |
Diagnostics / monitors (DebugUI.Monitors.cs)
| Method | What it does |
|---|---|
debugUI.startFusionMonitor() | Opens the Fusion compute graph monitor (WASM/MAUI only). |
debugUI.startTaskMonitor() | Starts TaskMonitor + TaskEventListener. |
debugUI.startDOMEventSniffer() | Records the last 50 Blazor + DOM events, dumps on NullReferenceException from a stale handler. Useful when you see the "Blazor event dispatch failed" red error. |
Other handy ones
| Method | What it does |
|---|---|
debugUI.fakeSleep(seconds) | Simulates the OnDeviceAwake go-to-sleep / wake transition. |
debugUI.showSafeAreas(true | false | undefined) | Toggles the safe-area visualization classes on <body>. |
debugUI.clearSvgCache() | Resets the SVG icon cache. |
debugUI.setAudioRecorderOffset(ms) | Inject audio drift for catch-up policy testing. 0 clears. |
debugUI.testVideoRecordingQualityChange(periodSeconds=30) / testVideoPlaybackQualityChange | Drive the quality controller through a synthetic -1/0/+1 sweep. |
3. Recipes
Sign in two users in one go
// Chrome 1 (mcp__chrome-devtools-1)
await debugUI.signIn('+1 555 555 5550'); // user A
const a = await debugUI.getUserId();
// Chrome 2 (mcp__chrome-devtools-2)
await debugUI.signIn('+1 555 555 5551'); // user B
const b = await debugUI.getUserId();
// Build the PM URL — peer chat IDs sort their two UserIds ordinally
const pm = '/chat/p-' + [a, b].sort().join('-');
location.assign(pm);
Skipping the modal/captcha/onboarding/bubbles takes ~1s per user — well
inside the 5s When(!IsGuest) window built into signIn.
Switch render mode mid-session
debugUI.getCurrentRenderMode(); // 's' or 'w'
await debugUI.setRenderMode('w'); // page reloads in WASM
// after reload:
debugUI.getCurrentRenderMode(); // 'w'
Auto first prerenders server, then upgrades to WASM — that's why
getCurrentRenderMode may return 's' on the very first paint of an
'a' selection and 'w' once the runtime is up.
Hard-reload after WASM-affecting changes
After a server restart, a plain reload is enough when render mode is
's' (Server). When the page is in 'w' (WASM) or 'a' (Auto,
which generally upgrades to WASM), the SW keeps serving the stale
hashed bundle.<hash>.js and the cached _framework payload — your
fix won't take. You need a hard reload with caches cleared whenever:
- you edited any
.ts/.css(the bundle filename gets a new fingerprint), OR - you edited any
.csthat runs in WASM (UI.Blazor.App,UI.Blazor,Core, etc., as opposed to server-only assemblies).
One-liner:
const regs = await navigator.serviceWorker.getRegistrations();
for (const r of regs) await r.unregister();
const cks = await caches.keys();
for (const k of cks) await caches.delete(k);
location.reload();
From the chrome-devtools MCP, navigate_page with type:"reload" and
ignoreCache:true does the same, but the SW unregister is what makes
the reload actually pick up the new hashed bundle.
Recommended workflow for an edit-and-test loop:
await debugUI.setRenderMode('s')once at the start of the session. Server-render reloads are noticeably cheaper — no WASM runtime to re-download, no service-worker dance — so iteration is tighter.- Run all your scenarios. If any fail, fix and retry under
's'. - When everything passes under
's', switch withawait debugUI.setRenderMode('w')and re-run the same scenarios end to end. This is the final confirmation that the WASM build behaves the same.
Mixing in 'a' (Auto) muddies the picture — the same session ends up
running partly server-rendered, partly WASM, and "is this a
server-only bug or a WASM bug?" gets harder to localize.
Stop the server from the browser
debugUI.stopServer();
If server-loop is running on the host, it'll rebuild and relaunch
automatically. See /server-loop for the full restart story.
Find the test-user's UserId without going to Settings
await debugUI.getUserId(); // logs to console + returns
Pre-getUserId workflow (Settings → Share → Copy link, then read the
/u/<id> URL) still works as a fallback for older builds.
4. Peer chat URLs
Direct messages between two users live in a peer chat. Its id is
formed from both UserIds, sorted ordinally (string comparison —
StringComparer.Ordinal), prefixed with p-, joined with a hyphen:
ChatId = "p-" + min(userIdA, userIdB) + "-" + max(userIdA, userIdB)
URL = /chat/<ChatId>
UserIds in this codebase are 6-character base-32-ish strings (e.g.
4An15V, 7GI0pt). The corresponding peer chat URL is therefore:
/chat/p-4An15V-7GI0pt # since '4' < '7' (0x34 < 0x37)
The order is fixed by the server (PeerChatId.New sorts internally), so
you must sort before constructing the URL — feeding it in the wrong
order yields a "Chat not found" page even though both ids are valid.
Building it from JS
// In either Chrome (already signed in)
const me = await debugUI.getUserId(); // e.g. '4An15V'
const peer = '7GI0pt'; // user B's id, however you got it
const chatId = 'p-' + [me, peer].sort().join('-');
location.assign('/chat/' + chatId);
Array.prototype.sort defaults to lexicographic comparison, which
matches the server's StringComparer.Ordinal for ASCII-only ids.
Other chat-id shapes you'll see
- Notes (self-chat) — a 10-character random ChatId, e.g.
/chat/YSLweHvRYa. Not ap-...form. - Group chats / places — also 10-character random ChatIds.
- Voxt Announcements — a stable alias
/chat/announcements.
Privacy gate (relevant to PM tasks)
A user can post up to 2 messages to a peer they're not in contacts
with before the third send is server-rejected with
InvalidOperationException: You can send up to 2 messages until this user adds you to their contacts or replies. The recipient sending one
message back, or adding the sender to contacts, opens the gate
permanently.
The client currently silently retries the rejected send (the message sits with a clock icon), so a UI test that doesn't account for this will look like the message simply never arrived. When testing PM flows, either stay under 2 unanswered messages or arrange a reply first.
5. Available pages (@page routes)
All Razor @page routes in the codebase, by category. Use these with
location.assign('/...') (preferred) or debugUI.navigateTo('/...').
For an up-to-date list, run Grep for ^@page over **/*.razor.
Core app
| Route | Component | Notes |
|---|---|---|
/ | HomePage | Landing — sign-in entry point. |
/chat | ChatPage | Default chat ("Notes"). |
/chat/{*Route} | ChatPage | Specific chat. Route is the ChatId, e.g. p-<a>-<b>, YSLweHvRYa (random), announcements. |
/u / /u/{PageRoute} | UserPage | Resolves a UserId or @alias and redirects to the peer chat. |
/place/{PlaceSid} | PlaceInfoPage | A "Place" (chat hub) by short id. |
/join/{InviteId} | ChatInvitePage | Accept a chat invite. |
/settings / /settings/{SettingsOption} | SettingsPage | Settings modal — option = tab key (account, ui, etc.). |
Public / landing
| Route | Component |
|---|---|
/docs / /docs/privacy | DocsPrivacyPage |
/docs/terms | DocsTermsPage |
/docs/faq | DocsFaqPage |
/docs/cookies | DocsCookiesPage |
System / error
| Route | Component | Notes |
|---|---|---|
/Error | ErrorServerPage | Server-rendered error page. |
/unavailable / /unavailable/{What} | UnavailablePage | Maintenance / unsupported-browser etc. |
/fusion/close | FusionCloseServerPage | Sign-in / close-flow callback. |
Test pages
Everything under /test/* is a developer-only page — wired up to
exercise a specific component, integration, or system probe. Most
require sign-in. Use them when you need a focused stage for the thing
they test.
| Route | What it exercises |
|---|---|
/test/auth | Sign-in / claims plumbing. |
/test/blazor | Blazor render / RPC sandbox. |
/test/copy-chat2place | Admin-style chat→place migration. |
/test/digest | Email digest preview. |
/test/discover | Discover feed component. |
/test/dive-in-modal | Dive-in modal animation. |
/test/email-templates | All email templates with sample data. |
/test/embedded | Embedded-host (iframe-like) shell. |
/test/emojis | Emoji picker / rendering. |
/test/error-barrier | Error boundary behaviour. |
/test/external-contacts | Phone-book sync stub. |
/test/features | Feature flag inspector. |
/test/incoming-share-modal-test | Inbound share → chat-picker modal. |
/test/js | Misc JS interop probes. |
/test/landing-background | Landing-page background animation. |
/test/markup-editor | Rich-text composer. |
/test/maui | MAUI-only probes (won't render in web). |
/test/mesh | Mesh diagnostics. |
/test/mic-permission-guides | Per-browser mic permission guides. |
/test/photo-permission | Photo / camera permission flow. |
/test/place-info | Place info card. |
/test/reconnect-overlay | Connectivity reconnect overlay. |
/test/render-slot | RenderSlot slot mechanics. |
/test/requirements | UI requirement banners. |
/test/skeletons | Skeleton-loader components. |
/test/svg-cats | SVG cat illustrations gallery. |
/test/system | System-level probes (clock, env, runtime). |
/test/toasts | Toast / banner styles. |
/test/totp | TOTP input component. |
/test/ui-colors | Theme color palette. |
/test/video-input | Video capture device picker. |
/test/video-transcoding | Video upload + transcoder. |
/test/virtual-list | Virtual list scroll/bench. |
/test/web-splash | Web splash screen. |
6. Audio recording, transcription, video, screencast
The chat footer next to "Write a message" is where the call controls live. Clockwise from the message field on a single-user view:
[+] [Write a message ...] [bell] [video] [BIG record button] [sliders]
After recording starts, two extra in-call controls become reachable on the caller's side: a screencast (screen-share) toggle and a video toggle for the second/third participant once they've joined.
The order matters: audio first, then video
You can't start video without audio (recording must be on). The expected ordering for the initiator is:
- Click the big record button — recording (mic + transcription) starts. You see a transcription stream appear in the chat as your audio is recognised.
- Click the video toggle (camera icon next to the record button) — a "Video preview" modal appears. Click Start video.
- Your own video tile shows up labelled "You".
For the joiners: when someone else is recording in the chat, the chat header shows Join muted. After clicking it they're in the call as listeners. They can then enable their own video — and crucially, without enabling their own recording. So a typical N-way video session has only the initiator paying the transcription cost.
Stop recording the moment the test isn't about transcription
Treat audio recording as resource consumption — not a default-on state. Every second of recording bills against the speech-recognition provider. The rule:
- Don't turn it on unless the test actually needs it.
- The instant you don't need it any longer, turn it off — even if you're "about to use it again in a minute". You can always re-enable later with the same record button. Cost during off-time is zero.
- Before any longer edit / read / investigation cycle, stop recording first. A long edit pass with a mic still hot is the most expensive shape of "Codex is thinking" — and it's pure waste, since transcription continues regardless of whether the assistant is actively driving the UI.
To stop recording: click the big record button again. It's now glowing red/pink; clicking it returns it to its idle state. The active video tiles keep streaming — stopping audio doesn't drop video calls, so there's no penalty for being aggressive about it.
You can also stop recording chat-by-chat from the Active Chats strip that appeared in the bottom-left when recording started — each entry has a mic icon you can click to mute that chat specifically.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 55
- Forks
- 10
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
source-command-debug-ui- Source
- github.com/actual-chat/actual-chat