Web in an owned Chrome
Commands use stim. If it is not installed globally, replace stim with
npx stim.
stim web opens the workspace's page in a Chrome that Stim owns. It captures
the page's console calls, uncaught errors and failed requests in stim logs,
and reports whether the page loaded, the same way stim ios and
stim android do for native apps.
Stim uses the Google Chrome or Chromium already installed on the machine, with
a profile it creates under STIM_HOME. It never installs a browser, and
stim doctor reports a missing Chrome for a project that renders on the web.
stim web never starts a web server, for any framework. It opens, reuses or
reloads the owned Chrome at the page URL. Every web project follows the same
two steps: start your dev server, then run stim web.
- Global
- npx
stim web
stim logs --errors
stim reload web
stim stop
npx stim web
npx stim logs --errors
npx stim reload web
npx stim stop
Chrome runs headless by default. --headed shows its window.
Expo web
An Expo app renders on the web through the same Metro server as iOS and
Android. With no web.url set, stim web opens http://localhost:<metroPort>/.
Its dev server is stim start. Install the web dependencies once, then start
Metro before stim web:
- Global
- npx
npx expo install react-dom react-native-web @expo/metro-runtime
stim start
stim web
npx expo install react-dom react-native-web @expo/metro-runtime
npx stim start
npx stim web
Vite, Next, and other web servers
Start the server with its own command on a named port and point web.url at
it. In web.url, {port:<label>} becomes the workspace's named port and
{port:metro} its Metro port.
- Global
- npx
pnpm exec vite --port "$(stim ports get web)" --strictPort
stim settings set web.url 'http://localhost:{port:web}/' --scope workspace
stim web
pnpm exec vite --port "$(npx stim ports get web)" --strictPort
npx stim settings set web.url 'http://localhost:{port:web}/' --scope workspace
npx stim web
In a monorepo where the web app is its own package, such as apps/web beside
apps/mobile, run the commands from the web package. When that package depends
on neither react-native nor expo and holds no named ports of its own,
stim ports, web, settings, logs, reload, stop and status resolve
to the one Stim app registered in the same Git worktree. Each names the app on
stderr, and status stars it.
The port, the browser, web.url and the logs all belong to that app's
workspace. Register the app first by running stim ports get web,
stim start, stim ios or stim android from the app directory.
| Setting | Effect |
|---|---|
web.url | The page to open; unset opens the Metro URL for Expo |
web.ignoreCertificateErrors | Accept a dev server's self-signed certificate, in the owned profile only |
web.viewport | desktop (1280×800, the default) or phone (390×844 at 3× with touch) |
HTTPS dev servers
A dev server with a self-signed certificate, such as Vite with
@vitejs/plugin-basic-ssl, fails with net::ERR_CERT_AUTHORITY_INVALID until
you accept the certificate in the owned profile:
- Global
- npx
stim settings set web.url 'https://localhost:{port:web}/' --scope workspace
stim settings set web.ignoreCertificateErrors true --scope workspace
stim web
npx stim settings set web.url 'https://localhost:{port:web}/' --scope workspace
npx stim settings set web.ignoreCertificateErrors true --scope workspace
npx stim web
An https:// URL on a plain HTTP server fails with net::ERR_SSL_PROTOCOL_ERROR,
and an http:// URL on an HTTPS server with net::ERR_EMPTY_RESPONSE. The
remedy line names the scheme to use.
Monorepo recipe: a Vite package with a base path and HTTPS
In this layout, apps/web runs Vite through its dev script, serves the app
under /apps/groups/, and uses @vitejs/plugin-basic-ssl. apps/mobile is
the Stim app. The flow is the same as for any web project: start the dev
server, then run stim web. Set the page once per repository. The repo
layer is shared by every worktree, so a new worktree only registers the app and
starts the server:
- Global
- npx
cd apps/mobile
stim ports get web
cd ../web
stim settings set web.url 'https://localhost:{port:web}/apps/groups/' --scope repo
stim settings set web.ignoreCertificateErrors true --scope repo
pnpm dev --port "$(stim ports get web)" --strictPort
cd apps/mobile
npx stim ports get web
cd ../web
npx stim settings set web.url 'https://localhost:{port:web}/apps/groups/' --scope repo
npx stim settings set web.ignoreCertificateErrors true --scope repo
pnpm dev --port "$(npx stim ports get web)" --strictPort
Keep the dev server running in its own terminal, then work from apps/web:
- Global
- npx
stim web
stim logs --errors
stim reload web
stim stop
npx stim web
npx stim logs --errors
npx stim reload web
npx stim stop
stim ports get webfrom the app directory registers the app without starting Metro. Run it before anyportsorwebcommand in the web package. A reservation made there first keeps the web package as its own workspace until you release it and runstim stop.- Pass
--portand--strictPortthrough the dev script. Otherwise Vite binds its configured port and moves to the next free one when that is taken. - Put the base path in
web.url. A path outside it can reach the dev server's proxy instead of the app. - The certificate and scheme remedies print
--scope workspace, which overrides therepovalue for one worktree only. - API calls that the dev server proxies to a backend that is not running show
up as device errors in
stim logs --errors. The page still loads. stim stopcloses Chrome but leaves the dev server running. Usestim ports stop webto stop it. In a linked worktree,stim worktree removestops both and deletes the profile.
Try it with an agent:
Read stim guide web. Our web app is apps/web (Vite, served under
/apps/groups/, HTTPS with a self-signed certificate) and our Stim app is
apps/mobile. Run stim ports get web in apps/mobile. From apps/web, set web.url
to https://localhost:{port:web}/apps/groups/ and web.ignoreCertificateErrors
to true at --scope repo if stim settings shows them unset. Start
pnpm dev --port "$(stim ports get web)" --strictPort in the background, then
run stim web and stim logs --errors. Tell me whether the page loaded and which
errors it logged, and follow any printed remedy.
What launched means
stim web --json reports launched from evidence inside the owned page, not
from a port that any tab can reach:
true: the page's document answered and its load event fired. For Metro, the page also fetched a web bundle."bundling": Metro was still building the web bundle when the check ended."unverified": the document failed, for example withnet::ERR_CONNECTION_REFUSEDwhen nothing listens on the URL ornet::ERR_CERT_AUTHORITY_INVALIDfor a self-signed certificate, or the page did not finish loading: 20 seconds with no answer, 60 once a server other than Metro answered. The remedy line names the fix. When nothing serves the page, it names this workspace's dev server step:stim startfor Expo web, thestim ports get webrecipe for any other server. A Metro port held by another process is also"unverified";stim startthen reserves a free port for this workspace. When this workspace's supervisor is still recorded, runstim stopfirst.
A page that loads and then throws still reports true. Its errors are in
stim logs --errors.
Logs
Page records carry platform: "web":
client: console calls at their level, and uncaught errors with stack frames.device: failed requests, browser messages such as CSP violations, the browser's own lifecycle, and in-app route changes (web_route). A failed page document is an error, whatever its status. For other requests, a network failure or an HTTP 5xx is an error, a 4xx a warning, and a canceled request debug.stim logs --errorsincludes these device errors.agent: clicks, typing, key presses and scrolls that an attached tool sent to the page (see below).
Expo also prints web console calls on Metro, so they can appear twice: once from the page and once from Metro.
Each load of the page's top-level document starts a new error window: stim web, stim reload, and a reload or navigation the page makes itself. A
reload's record carries reload: true.
stim logs --errors and the status error count report the page's errors from
the latest load only, the way Chrome DevTools clears its console when the page
navigates. A page load does not hide the native app's errors, and a stim ios
or stim android launch does not hide the page's.
Attach Playwright MCP or agent-browser
stim status --json reports environments[].web.cdpEndpoint, a reserved
loopback DevTools endpoint. Attach browser tools to it instead of letting them
start their own browser: Playwright MCP takes --cdp-endpoint, and
agent-browser takes --cdp <port>. The endpoint only reaches the Stim profile.
Chrome refuses remote debugging on your default profile, and Stim never
attaches to a browser it did not start.
While a tool is connected, stim status names it as the browser's driver
(web.activity, "driven by playwright"), the way it names agent-device on a
simulator. web.targetId is the owned page; a tab another tool opens is not
captured.
The input that tool sends to the page is recorded as agent actions, the way agent-device's taps are on a simulator:
14:02:11.204 info agent Clicked button "Sign in"
14:02:12.918 info agent Typed 16 characters into input#email[type=email] "Email"
14:02:13.402 info agent Pressed Enter in input#email[type=email] "Email"
stim logs --source agent lists them, with driver naming the tool in
--json. web.activity.recent["agent-action"] in stim status --json dates
the newest, and Stim Desktop and the phone show them under the Web device.
Stim records clicks, key presses, text input and wheel scrolls in the page's
top frame; a burst of typing, of one key or of scrolling is one record, and
pointer moves and iframes are not recorded. Typed text is never recorded, only
its length. Input from Take over in Stim Desktop or the phone app is not an
agent action, and neither is any other input within 3 seconds of it. Input
while no tool is connected is not recorded. A person clicking a headed Chrome window
while a tool is connected counts as that tool, and a script that disconnects
within a few hundred milliseconds of its first input goes unrecorded, since
Stim checks which tool is connected when the input starts. Navigations and reloads are page
records, not agent actions.
web.page reports the document the page loaded last and how that load went:
{ url, state, error?, route? }, with state one of loading, loaded or
failed. route is the URL the page shows after an in-app route change
(history API or fragment) since that load; it is absent when none happened. A
failed load (the dev server is down, a certificate error, a crash) shows on the
web: line of stim status.
Try it with an agent:
Read stim guide web. Run stim web, attach Playwright MCP to the cdpEndpoint in
stim status --json, sign in to the app, then run stim logs --source agent and
tell me which actions Stim recorded for the page.
Cleanup
stim stopcloses Chrome and keeps the profile, so cookies and storage survive the nextstim web.stim stop --slot webcloses only Chrome, also keeping the profile. Metro, simulators and emulators keep running, so a freshstim webdoes not cost a native relaunch.stim worktree removecloses Chrome and deletes the profile.stim gc --deletedoes the same for a workspace whose path is gone.
Stim signals Chrome only after it verifies the process identity it recorded, and deletes a profile only when its ledger lists it.
Stim Desktop
Stim Desktop shows the owned Chrome as a Web tile next to the simulators,
with live frames of the page, its URL, a "Page failed to load" pill,
"Driven by" when a tool is attached, and the tool's latest actions under it. Take over sends clicks, scrolls and
keys to the page. The tile opens the URL in your own browser, reloads the page
(stim reload web), and closes Chrome (stim stop --slot web).
The phone app
The phone app shows the page as a Web tile in the devices grid and on the
workspace screen, with an attached tool's latest actions under it. Tapping it opens the device viewer, streamed as H.264 video
through stim-server. With Control on, taps click, drags scroll, the
keyboard types, and Back goes back in the page's history. Reload in the
workspace menu runs stim reload web. See
Replay device screens to scrub back
through what an agent did on the page.
Chrome and Chromium are the only engines.
Try it with an agent:
Read stim guide web. Start this app's dev server (stim start for Expo web),
open its web target with stim web, then run
stim logs --errors and tell me whether the page loaded and which errors it
logged. If launched is not true, follow the printed remedy.