agent-browser × Relaticle cookbook (cached hints, verified dates, self-healing)
Prime rule: facts below are cached hints, not truth. The app's URLs, routes,
selectors, and seeders change. When a documented pattern fails twice, stop retrying:
re-derive it from the running app (procedures below), make it work, then update this
file with the new pattern and today's verified: date.
1. URL derivation (NEVER hardcode; panels are conditionally domain-routed)
php artisan tinker --execute 'echo json_encode([
"base" => config("app.url"),
"app_domain" => config("app.app_panel_domain"),
"app_path" => config("app.app_panel_path", "app"),
"sysadmin_domain" => config("app.sysadmin_domain"),
"sysadmin_path" => config("app.sysadmin_path", "sysadmin"),
]);'
app panel = https://{app_domain} if set, else {base}/{app_path}
sysadmin = https://{sysadmin_domain} if set, else {base}/{sysadmin_path}
Routing mode is per-checkout. Derive it, and never carry it over from another
workspace. Both modes are live in the wild:
- Conductor workspace
bamako, APP_PANEL_DOMAIN/SYSADMIN_DOMAIN empty →
path-routed: https://bamako.test/app, https://bamako.test/sysadmin
(verified: 2026-08-12).
- A checkout with the
*_DOMAIN envs set → domain-routed, e.g.
https://app.relaticle.test, https://sysadmin.relaticle.test
(verified: 2026-06-12).
Each Conductor workspace is served by Herd under its own https://<workspace>.test,
so the host changes too. Run the tinker block above every run and use what it
returns.
Login entry points are Filament-registered routes; ground truth:
php artisan route:list --json filtered for login (names like
filament.app.auth.login). If a URL 404s, check the route table before anything else.
Host unreachable? herd sites / herd links shows what Herd actually serves this
checkout as (catches renamed dirs / Polyscope clones). .env vs config() mismatch →
php artisan config:clear.
2. Session setup (every time)
export AB_SESSION="<purpose>-<run-id>" # ALWAYS unique per agent; sessions are machine-global
agent-browser --session "$AB_SESSION" set viewport 1920 1080
agent-browser --session "$AB_SESSION" open "$APP_PANEL_URL"
Pass --session "$AB_SESSION" on EVERY call (or export AGENT_BROWSER_SESSION).
Default 1280x720 clips Filament modals (verified: 2026-05).
3. Credentials (seeded; re-derive when login fails)
| Surface |
Login |
Password |
Source |
| app panel |
manuk.minasyan1@gmail.com |
password |
database/seeders/LocalSeeder.php (verified: 2026-06-12) |
| sysadmin |
sysadmin@relaticle.com |
password |
SystemAdministratorSeeder (verified: 2026-06-12) |
| per-run test users |
br-rel-<run>-…@example.test |
password |
factory |
Login failing? In order: php artisan db:seed --class=LocalSeeder (local-gated; also
tops AI credits) → --class=SystemAdministratorSeeder → factory-create a namespaced
user (User::factory()->withPersonalTeam()->create([...])). If the seeder emails
changed, fix this table (self-heal).
Dev-login affordance: the app registers laravel-login-link (route loginLinkLogin,
POST laravel-login-link-login; verified 2026-06-12 via route:list). Local login
pages may render one-click "Login as …" links; prefer them over typing credentials when
present.
4. Login flow (both panels, Filament stock login)
UPDATE (verified: 2026-09-03, app panel, path-routed astana-v1): the app login is
now identifier-first (PR #285). /app/login renders only id="form.email" plus a
"Continue" submit; there is no form.password on the first step, so the eval recipe
below returns no-inputs. Two things that worked:
- Herd's cert fails Chromium's name check (
ERR_CERT_COMMON_NAME_INVALID). Export
AGENT_BROWSER_IGNORE_HTTPS_ERRORS=1 (or pass --ignore-https-errors) on every call.
- Local login pages render one-click
laravel-login-link buttons labelled by email
(owner@relaticle.test, trial@relaticle.test, ...). Click one via eval:
[...document.querySelectorAll("button[type=submit]")].find(b=>b.innerText.trim()==="owner@relaticle.test").click()
and you land on /app/<team-slug> (acme-sales for owner) with no password step.
CORRECTION (verified: 2026-06-12, review PR 336): the input[name="email"] selector
is WRONG. It matches a hidden input belonging to the laravel-login-link dev package
(the page has hidden _token/email/key/guard/user_model inputs from that form).
agent-browser fill against that hidden field hung the daemon (os error 35,
"daemon may be busy or unresponsive") and never submitted. The REAL Filament inputs have
NO name attribute. They are id="form.email" / id="form.password" with
wire:model="data.email" / data.password, inside the <form wire:submit="authenticate">.
The recipe that works when fill/type hang (eval-driven, daemon-safe):
export AGENT_BROWSER_SESSION="<unique>"
agent-browser open "$PANEL_URL/login"
agent-browser eval '(() => {
const e=document.getElementById("form.email"), p=document.getElementById("form.password");
e.value="'"$LOGIN"'"; e.dispatchEvent(new Event("input",{bubbles:true}));
p.value="password"; p.dispatchEvent(new Event("input",{bubbles:true}));
const f=[...document.querySelectorAll("form")].find(x=>x.getAttribute("wire:submit")==="authenticate");
f.requestSubmit(); return "submitted";
})()'
sleep 4
agent-browser eval 'location.pathname' # confirm you left /login (lands on /<team-slug>)
- Daemon hangs on
fill/type in this environment (verified: 2026-06-12). When a
command returns os error 35 / no output, pkill -9 -f agent-browser; sleep 3 and
re-open. open/eval/snapshot/screenshot are reliable; click is flaky, so prefer
eval with el.click() for <a wire:navigate> links.
- Many stale
--session entries overload the daemon; keep ONE session per run and chain
commands with && in a single shell call (the daemon persists the browser).
agent-browser --session "$AB" open "$PANEL_URL/login"
agent-browser --session "$AB" fill 'input[name="email"]' "$LOGIN"
agent-browser --session "$AB" fill 'input[name="password"]' "password"
sleep 1
agent-browser --session "$AB" click "Sign in"
agent-browser --session "$AB" wait --load networkidle
agent-browser --session "$AB" eval 'location.pathname' # confirm you left /login
click / fill take the element's VISIBLE TEXT or a CSS selector, NOT
find role button "<name>". That subcommand syntax errors on this binary
(verified: 2026-06-12). Use agent-browser click "Sign in".
- After app-panel login you land on the default team path
…/<team-slug>/…
(e.g. /tapix), so re-derive the slug from location.pathname before navigating further
(verified: 2026-06-12).
- After sysadmin login you land on
/ (Dashboard) on sysadmin.relaticle.test
(verified: 2026-06-12).
- A "Developer Login" button is present on the login page but clicking it alone did
not establish a session in testing, so prefer the fill+click recipe above
(verified: 2026-06-12).
4b. Screenshot paths: ALWAYS absolute
agent-browser screenshot parses a RELATIVE path containing / as a CSS selector and
fails (Unexpected token "/" while parsing css selector). Always pass an absolute path:
agent-browser --session "$AB" screenshot "$(pwd)/.context/reviews/<dir>/case-X/shot.png"
(verified: 2026-06-12).
5. The gold patterns (Filament v5 + Livewire v4)
Prefer semantics over CSS selectors. a11y-role finds and Livewire state survive
Blade/Tailwind refactors.
$wire is NOT in scope inside agent-browser eval (it's an Alpine magic; eval runs
in plain page context (verified 2026-06-12, after it cost a run 4 round-trips and one
self-inflicted 500 where the server was asked to call a method literally named
$wire). Resolve the component first, then use .set(...) / .call(...):
// by name (page components):
const meta = window.Livewire.all().find(c => /TasksBoard/.test(c.name)); // metadata ONLY: {id, name}
const comp = window.Livewire.find(meta.id); // the real component
await comp.call("moveCard", "<recordId>", "<columnId>");
// or from a DOM element (modals, nested components):
const comp2 = window.Livewire.find(el.closest("[wire\\:id]").getAttribute("wire:id"));
await comp2.set("mountedActions.0.data.title", "value", true);
await comp2.call("callMountedAction");
Livewire.all() entries have NO .call/.set. They are metadata, so always pass
the id through Livewire.find() (verified: 2026-06-12).
- Select dropdowns (plain click is unreliable):
agent-browser find role combobox "<label>" click then
agent-browser find role option "<option>" click, or comp.set("data.company_id", 42, true).
- Date pickers (plain type does nothing):
comp.set("data.closes_at", "2026-06-15", true).
- Action modals (Delete, custom row actions), the single most useful pattern:
await comp.call("mountAction", "delete", { recordKey: 42 });
await comp.set("mountedActions.0.data.reason", "why", true);
await comp.call("callMountedAction");
Every call must be await-ed. (verified: 2026-06-12 via the create-task modal)
- Read Livewire state:
agent-browser eval '... JSON.stringify(comp.get("data"))'
- Snapshots:
agent-browser snapshot -i -c -d 8 (focused), never bare snapshot.
Refs (@eXX) shift between snapshots, so keep snapshot→interaction adjacent or use
find role/text … click.
- Modals fade ~300ms, so use
agent-browser wait '.fi-modal-window:not([data-state="open"])' 2000
before asserting removal.
- Tenant switching is browser-only (in-app switcher; tinker tenant-switch breaks the
session → persistent 403s). After a switch the URL slug changes, so re-derive.
6. Environment hazards (dated)
- Factory/fresh teams redirect every app-panel page to /billing (verified:
2026-08-25):
EnsureHostedWorkspaceAccess allows only subscribed teams or
trial_ends_at in the future, and factory teams have neither, so login lands on
/app/<slug>/billing and stays there. Fix before browsing:
$team->forceFill(['trial_ends_at' => now()->addDays(14)])->save(); (LocalSeeder's
user is already provisioned; this bites ChatQaSeeder and factory users).
- Shared local Redis across Herd apps: another app's Horizon can consume this app's
queue jobs (verified 2026-06-11, when Journey ate Relaticle chat jobs). Use a dedicated
REDIS_DB in .env; before queue-dependent testing, dispatch a sentinel job and
confirm THIS checkout's worker consumed it.
- Reverb/websockets: agent-browser's Chromium may use a wrong websocket host or a
stale built bundle. It looks like a dead page but is an env defect.
pnpm run build, check
agent-browser console for websocket errors (verified: 2026-06-10).
- 419 CSRF after idle →
agent-browser reload and retry once (verified: 2026-05).
- A failed Livewire request leaves a full-screen error overlay in the DOM (Laravel
error page in a modal) that silently photobombs every later screenshot. The page
underneath still works, so nothing looks wrong until you read the PNG back. After ANY
errored
comp.call, agent-browser open the page fresh (or remove the overlay)
before shooting (verified 2026-06-12, when a stale overlay replaced the board in an
evidence shot).
- Stale session after branch switches → first action of a batch is a fresh login.
- AI credits drain during chat testing → re-seed
LocalSeeder to top up before
chat-heavy flows.
- Screenshot pipeline can serve STALE FRAMES from a dead target (verified:
2026-08-18): after long sessions / viewport changes,
screenshot kept returning a
frame that no longer matched the DOM (evals said dark theme + correct state; PNG
showed an old light half-render). Detection: eval 'document.body.style.outline="40px solid red"' → screenshot → if no red border, the pipeline is stale. Fix:
pkill -9 -f agent-browser; sleep 3, new session, re-login. Don't debug the "bug"
in the PNG before running the red-outline probe. UPDATE (verified: 2026-08-23): the
pkill+new-session fix did NOT clear it, a fresh session's very first screenshots
again failed the red-outline probe (dark-mode text read black in the PNG while
getComputedStyle + elementFromPoint at the same coordinates said white). When
the probe fails twice, stop shooting: assert via DOM reads (computed styles,
elementFromPoint, rects) and treat those as the truth for visual verification.
7. DB-assert (corroboration only; the UI is the proof)
php artisan tinker --execute '$c = \App\Models\Company::where("name", "br-rel-test")->first(); echo $c ? "found:".$c->id : "missing";'
Tenant-scoped query? Set context first:
\Relaticle\CustomFields\Services\TenantContextService::setTenantId($teamId);
Never use tinker or DB writes to fix or fake a result. An on-screen error is a finding.
8. Screenshots
For any deliverable screenshot, invoke Skill('screenshot-with-callout') per shot
(annotate → verify-crop → shoot → read-back). Throwaway debug shots exempt.
9. Eval and rendering hints (verified: 2026-09-07)
agent-browser eval runs every call in the same page scope. A top-level const x
declared in one eval throws Identifier 'x' has already been declared in the next.
Wrap evals in an IIFE: agent-browser eval '(()=>{ const x=...; return JSON.stringify(x) })()'.
- To screenshot a feature-flag branch without flipping the shared
.env, render it to
a file and open that: php artisan tinker --execute '\Laravel\Pennant\Feature::define(\App\Features\Billing::class, false); file_put_contents(".context/off.html", view("pricing")->render());'
then agent-browser open "file://$(pwd)/.context/off.html". Vite assets resolve to the
absolute APP_URL, so the page styles correctly from file://.
10. Turnstile on the signup step (verified: 2026-09-10)
- The widget lives in a closed shadow root, so
document.querySelector("iframe[src*=challenges]")
is always null. Read the enclosing .fi-grid-col instead: fi-hidden = silent pass, 70px tall =
checkbox shown. The schema's grid child is .fi-grid-col, not .fi-fo-field; an empty in-flow
column still costs one 24px grid gap, so measure password-field-bottom to button-top (24 = clean). Cloudflare's dummy sitekeys drive each state: 1x…AA passes silently,
3x…FF forces the checkbox, 2x…AB always fails; secret 1x…AA accepts the dummy token.
- To click the checkbox use coordinates:
agent-browser mouse move X Y && mouse down && mouse up
at rect.x+20, rect.y+32. Under zsh mouse move $XY fails with "Missing arguments": an
unquoted variable is not word-split, so read -r X Y <<< "$XY" first.
agent-browser set media dark exists; toggling document.documentElement.classList also works.
1---2name: agent-browser-relaticle3description: Use whenever driving agent-browser against the local Relaticle app (relaticle.test and its panels) for testing, QA, business review, or UI automation. Covers Filament v5 + Livewire v4 quirks specific to this codebase: panel URL derivation (domain-routed vs path-routed, never assumed), login flows for the app and sysadmin panels, seeded credentials, Select/date-picker interaction, the $wire.mountAction gold pattern, tenant switching, Reverb/queue hazards, and session isolation. Every hard fact here is a DATED CACHED HINT. When one fails, re-derive from the running app and update this file (self-heal). Not for other sites or generic browser automation.4---56# agent-browser × Relaticle cookbook (cached hints, verified dates, self-healing)78**Prime rule: facts below are cached hints, not truth.** The app's URLs, routes,9selectors, and seeders change. When a documented pattern fails **twice**, stop retrying:10re-derive it from the running app (procedures below), make it work, then **update this11file** with the new pattern and today's `verified:` date.1213## 1. URL derivation (NEVER hardcode; panels are conditionally domain-routed)1415```bash16php artisan tinker --execute 'echo json_encode([17 "base" => config("app.url"),18 "app_domain" => config("app.app_panel_domain"),19 "app_path" => config("app.app_panel_path", "app"),20 "sysadmin_domain" => config("app.sysadmin_domain"),21 "sysadmin_path" => config("app.sysadmin_path", "sysadmin"),22]);'23```2425- app panel = `https://{app_domain}` if set, else `{base}/{app_path}`26- sysadmin = `https://{sysadmin_domain}` if set, else `{base}/{sysadmin_path}`27- **Routing mode is per-checkout. Derive it, and never carry it over from another28 workspace.** Both modes are live in the wild:29 - Conductor workspace `bamako`, `APP_PANEL_DOMAIN`/`SYSADMIN_DOMAIN` empty →30 path-routed: `https://bamako.test/app`, `https://bamako.test/sysadmin`31 (verified: 2026-08-12).32 - A checkout with the `*_DOMAIN` envs set → domain-routed, e.g.33 `https://app.relaticle.test`, `https://sysadmin.relaticle.test`34 (verified: 2026-06-12).3536 Each Conductor workspace is served by Herd under its own `https://<workspace>.test`,37 so the host changes too. Run the `tinker` block above every run and use what it38 returns.39- Login entry points are Filament-registered routes; ground truth:40 `php artisan route:list --json` filtered for `login` (names like41 `filament.app.auth.login`). If a URL 404s, check the route table before anything else.42- Host unreachable? `herd sites` / `herd links` shows what Herd actually serves this43 checkout as (catches renamed dirs / Polyscope clones). `.env` vs `config()` mismatch →44 `php artisan config:clear`.4546## 2. Session setup (every time)4748```bash49export AB_SESSION="<purpose>-<run-id>" # ALWAYS unique per agent; sessions are machine-global50agent-browser --session "$AB_SESSION" set viewport 1920 108051agent-browser --session "$AB_SESSION" open "$APP_PANEL_URL"52```5354Pass `--session "$AB_SESSION"` on EVERY call (or export `AGENT_BROWSER_SESSION`).55Default 1280x720 clips Filament modals (verified: 2026-05).5657## 3. Credentials (seeded; re-derive when login fails)5859| Surface | Login | Password | Source |60|---|---|---|---|61| app panel | `manuk.minasyan1@gmail.com` | `password` | `database/seeders/LocalSeeder.php` (verified: 2026-06-12) |62| sysadmin | `sysadmin@relaticle.com` | `password` | `SystemAdministratorSeeder` (verified: 2026-06-12) |63| per-run test users | `br-rel-<run>-…@example.test` | `password` | factory |6465Login failing? In order: `php artisan db:seed --class=LocalSeeder` (local-gated; also66tops AI credits) → `--class=SystemAdministratorSeeder` → factory-create a namespaced67user (`User::factory()->withPersonalTeam()->create([...])`). If the seeder emails68changed, fix this table (self-heal).6970Dev-login affordance: the app registers `laravel-login-link` (route `loginLinkLogin`,71POST `laravel-login-link-login`; verified 2026-06-12 via `route:list`). Local login72pages may render one-click "Login as …" links; prefer them over typing credentials when73present.7475## 4. Login flow (both panels, Filament stock login)7677**UPDATE (verified: 2026-09-03, app panel, path-routed `astana-v1`):** the app login is78now identifier-first (PR #285). `/app/login` renders only `id="form.email"` plus a79"Continue" submit; there is no `form.password` on the first step, so the eval recipe80below returns `no-inputs`. Two things that worked:8182- Herd's cert fails Chromium's name check (`ERR_CERT_COMMON_NAME_INVALID`). Export83 `AGENT_BROWSER_IGNORE_HTTPS_ERRORS=1` (or pass `--ignore-https-errors`) on every call.84- Local login pages render one-click `laravel-login-link` buttons labelled by email85 (`owner@relaticle.test`, `trial@relaticle.test`, ...). Click one via eval:86 `[...document.querySelectorAll("button[type=submit]")].find(b=>b.innerText.trim()==="owner@relaticle.test").click()`87 and you land on `/app/<team-slug>` (`acme-sales` for owner) with no password step.8889**CORRECTION (verified: 2026-06-12, review PR 336):** the `input[name="email"]` selector90is WRONG. It matches a **hidden** input belonging to the `laravel-login-link` dev package91(the page has hidden `_token`/`email`/`key`/`guard`/`user_model` inputs from that form).92`agent-browser fill` against that hidden field **hung the daemon** (`os error 35`,93"daemon may be busy or unresponsive") and never submitted. The REAL Filament inputs have94NO `name` attribute. They are `id="form.email"` / `id="form.password"` with95`wire:model="data.email"` / `data.password`, inside the `<form wire:submit="authenticate">`.9697The recipe that works when `fill`/`type` hang (eval-driven, daemon-safe):9899```bash100export AGENT_BROWSER_SESSION="<unique>"101agent-browser open "$PANEL_URL/login"102agent-browser eval '(() => {103 const e=document.getElementById("form.email"), p=document.getElementById("form.password");104 e.value="'"$LOGIN"'"; e.dispatchEvent(new Event("input",{bubbles:true}));105 p.value="password"; p.dispatchEvent(new Event("input",{bubbles:true}));106 const f=[...document.querySelectorAll("form")].find(x=>x.getAttribute("wire:submit")==="authenticate");107 f.requestSubmit(); return "submitted";108})()'109sleep 4110agent-browser eval 'location.pathname' # confirm you left /login (lands on /<team-slug>)111```112113- **Daemon hangs on `fill`/`type`** in this environment (verified: 2026-06-12). When a114 command returns `os error 35` / no output, `pkill -9 -f agent-browser; sleep 3` and115 re-open. `open`/`eval`/`snapshot`/`screenshot` are reliable; `click` is flaky, so prefer116 `eval` with `el.click()` for `<a wire:navigate>` links.117- Many stale `--session` entries overload the daemon; keep ONE session per run and chain118 commands with `&&` in a single shell call (the daemon persists the browser).119120<details><summary>Older recipe (fill+click), left here for reference; did NOT work on 2026-06-12</summary>121122```bash123agent-browser --session "$AB" open "$PANEL_URL/login"124agent-browser --session "$AB" fill 'input[name="email"]' "$LOGIN"125agent-browser --session "$AB" fill 'input[name="password"]' "password"126sleep 1127agent-browser --session "$AB" click "Sign in"128agent-browser --session "$AB" wait --load networkidle129agent-browser --session "$AB" eval 'location.pathname' # confirm you left /login130```131</details>132133- **`click` / `fill` take the element's VISIBLE TEXT or a CSS selector, NOT134 `find role button "<name>"`**. That subcommand syntax errors on this binary135 (verified: 2026-06-12). Use `agent-browser click "Sign in"`.136- After **app-panel** login you land on the default team path `…/<team-slug>/…`137 (e.g. `/tapix`), so re-derive the slug from `location.pathname` before navigating further138 (verified: 2026-06-12).139- After **sysadmin** login you land on `/` (Dashboard) on `sysadmin.relaticle.test`140 (verified: 2026-06-12).141- A **"Developer Login"** button is present on the login page but clicking it alone did142 not establish a session in testing, so prefer the fill+click recipe above143 (verified: 2026-06-12).144145## 4b. Screenshot paths: ALWAYS absolute146147`agent-browser screenshot` parses a RELATIVE path containing `/` as a CSS selector and148fails (`Unexpected token "/" while parsing css selector`). Always pass an absolute path:149`agent-browser --session "$AB" screenshot "$(pwd)/.context/reviews/<dir>/case-X/shot.png"`150(verified: 2026-06-12).151152## 5. The gold patterns (Filament v5 + Livewire v4)153154Prefer **semantics over CSS selectors**. a11y-role finds and Livewire state survive155Blade/Tailwind refactors.156157**`$wire` is NOT in scope inside `agent-browser eval`** (it's an Alpine magic; eval runs158in plain page context (verified 2026-06-12, after it cost a run 4 round-trips and one159self-inflicted 500 where the server was asked to call a method literally named160`$wire`). Resolve the component first, then use `.set(...)` / `.call(...)`:161162```js163// by name (page components):164const meta = window.Livewire.all().find(c => /TasksBoard/.test(c.name)); // metadata ONLY: {id, name}165const comp = window.Livewire.find(meta.id); // the real component166await comp.call("moveCard", "<recordId>", "<columnId>");167// or from a DOM element (modals, nested components):168const comp2 = window.Livewire.find(el.closest("[wire\\:id]").getAttribute("wire:id"));169await comp2.set("mountedActions.0.data.title", "value", true);170await comp2.call("callMountedAction");171```172173- **`Livewire.all()` entries have NO `.call`/`.set`.** They are metadata, so always pass174 the id through `Livewire.find()` (verified: 2026-06-12).175- **Select dropdowns** (plain click is unreliable):176 `agent-browser find role combobox "<label>" click` then177 `agent-browser find role option "<option>" click`, or `comp.set("data.company_id", 42, true)`.178- **Date pickers** (plain type does nothing): `comp.set("data.closes_at", "2026-06-15", true)`.179- **Action modals (Delete, custom row actions), the single most useful pattern:**180 ```js181 await comp.call("mountAction", "delete", { recordKey: 42 });182 await comp.set("mountedActions.0.data.reason", "why", true);183 await comp.call("callMountedAction");184 ```185 Every call must be `await`-ed. (verified: 2026-06-12 via the create-task modal)186- **Read Livewire state**: `agent-browser eval '... JSON.stringify(comp.get("data"))'`187- **Snapshots**: `agent-browser snapshot -i -c -d 8` (focused), never bare `snapshot`.188 Refs (`@eXX`) shift between snapshots, so keep snapshot→interaction adjacent or use189 `find role/text … click`.190- **Modals fade ~300ms**, so use `agent-browser wait '.fi-modal-window:not([data-state="open"])' 2000`191 before asserting removal.192- **Tenant switching is browser-only** (in-app switcher; tinker tenant-switch breaks the193 session → persistent 403s). After a switch the URL slug changes, so re-derive.194195## 6. Environment hazards (dated)196197- **Factory/fresh teams redirect every app-panel page to /billing** (verified:198 2026-08-25): `EnsureHostedWorkspaceAccess` allows only subscribed teams or199 `trial_ends_at` in the future, and factory teams have neither, so login lands on200 `/app/<slug>/billing` and stays there. Fix before browsing:201 `$team->forceFill(['trial_ends_at' => now()->addDays(14)])->save();` (LocalSeeder's202 user is already provisioned; this bites ChatQaSeeder and factory users).203- **Shared local Redis across Herd apps**: another app's Horizon can consume this app's204 queue jobs (verified 2026-06-11, when Journey ate Relaticle chat jobs). Use a dedicated205 `REDIS_DB` in `.env`; before queue-dependent testing, dispatch a sentinel job and206 confirm THIS checkout's worker consumed it.207- **Reverb/websockets**: agent-browser's Chromium may use a wrong websocket host or a208 stale built bundle. It looks like a dead page but is an env defect. `pnpm run build`, check209 `agent-browser console` for websocket errors (verified: 2026-06-10).210- **419 CSRF after idle** → `agent-browser reload` and retry once (verified: 2026-05).211- **A failed Livewire request leaves a full-screen error overlay in the DOM** (Laravel212 error page in a modal) that silently photobombs every later screenshot. The page213 underneath still works, so nothing looks wrong until you read the PNG back. After ANY214 errored `comp.call`, `agent-browser open` the page fresh (or remove the overlay)215 before shooting (verified 2026-06-12, when a stale overlay replaced the board in an216 evidence shot).217- **Stale session after branch switches** → first action of a batch is a fresh login.218- **AI credits drain during chat testing** → re-seed `LocalSeeder` to top up before219 chat-heavy flows.220- **Screenshot pipeline can serve STALE FRAMES from a dead target** (verified:221 2026-08-18): after long sessions / viewport changes, `screenshot` kept returning a222 frame that no longer matched the DOM (evals said dark theme + correct state; PNG223 showed an old light half-render). Detection: `eval 'document.body.style.outline="40px224 solid red"'` → screenshot → if no red border, the pipeline is stale. Fix:225 `pkill -9 -f agent-browser; sleep 3`, new session, re-login. Don't debug the "bug"226 in the PNG before running the red-outline probe. UPDATE (verified: 2026-08-23): the227 pkill+new-session fix did NOT clear it, a fresh session's very first screenshots228 again failed the red-outline probe (dark-mode text read black in the PNG while229 `getComputedStyle` + `elementFromPoint` at the same coordinates said white). When230 the probe fails twice, stop shooting: assert via DOM reads (computed styles,231 elementFromPoint, rects) and treat those as the truth for visual verification.232233## 7. DB-assert (corroboration only; the UI is the proof)234235```bash236php artisan tinker --execute '$c = \App\Models\Company::where("name", "br-rel-test")->first(); echo $c ? "found:".$c->id : "missing";'237```238239Tenant-scoped query? Set context first:240`\Relaticle\CustomFields\Services\TenantContextService::setTenantId($teamId);`241Never use tinker or DB writes to fix or fake a result. An on-screen error is a finding.242243## 8. Screenshots244245For any deliverable screenshot, invoke `Skill('screenshot-with-callout')` per shot246(annotate → verify-crop → shoot → read-back). Throwaway debug shots exempt.247248## 9. Eval and rendering hints (verified: 2026-09-07)249250- `agent-browser eval` runs every call in the same page scope. A top-level `const x`251 declared in one eval throws `Identifier 'x' has already been declared` in the next.252 Wrap evals in an IIFE: `agent-browser eval '(()=>{ const x=...; return JSON.stringify(x) })()'`.253- To screenshot a feature-flag branch without flipping the shared `.env`, render it to254 a file and open that: `php artisan tinker --execute '\Laravel\Pennant\Feature::define(\App\Features\Billing::class, false); file_put_contents(".context/off.html", view("pricing")->render());'`255 then `agent-browser open "file://$(pwd)/.context/off.html"`. Vite assets resolve to the256 absolute `APP_URL`, so the page styles correctly from `file://`.257258## 10. Turnstile on the signup step (verified: 2026-09-10)259260- The widget lives in a **closed shadow root**, so `document.querySelector("iframe[src*=challenges]")`261 is always null. Read the enclosing `.fi-grid-col` instead: `fi-hidden` = silent pass, 70px tall =262 checkbox shown. The schema's grid child is `.fi-grid-col`, not `.fi-fo-field`; an empty in-flow263 column still costs one 24px grid gap, so measure password-field-bottom to button-top (24 = clean). Cloudflare's dummy sitekeys drive each state: `1x…AA` passes silently,264 `3x…FF` forces the checkbox, `2x…AB` always fails; secret `1x…AA` accepts the dummy token.265- To click the checkbox use coordinates: `agent-browser mouse move X Y && mouse down && mouse up`266 at `rect.x+20, rect.y+32`. Under zsh `mouse move $XY` fails with "Missing arguments": an267 unquoted variable is not word-split, so `read -r X Y <<< "$XY"` first.268- `agent-browser set media dark` exists; toggling `document.documentElement.classList` also works.