Computer-use automation system: an LLM discovers how to complete a task inside a real UI (no API), the successful run is recorded as a typed, versioned, reusable capability, and that capability replays deterministically afterward with no model in the loop.
Status: V10 — complete, hardened through three rounds of review. All core requirements
(Section 3) are real: discovery, artifact compilation, deterministic replay with error
taxonomy, safety guardrails (enforced in both discovery and replay), and human-in-the-loop
escalation with live-session handoff — including escalation from a genuine, unengineered
production failure (a slow backend). Discovery evidence now carries real, unforgeable proof of
each LLM call (Anthropic's own response id + token usage), and every replay records a
per-step drift signal (which locator strategy actually resolved). Plus a verified cross-tenant
reuse demo, generic route canonicalization, a real mutating capability backing the
risky-action gate with its own audit record, and an automated test suite. See REPORT.md for
the design write-up, BUILD_PLAN.md for the full versioned roadmap, HLD.md / LLD.md for
design, and DECISIONS.md for the running decision log.
npm test16 tests (Node's built-in test runner, no extra dependency), true integration tests against a
real spawned target-app instance and real Playwright — covers all 3 Result kinds, both
guardrail mechanisms (allowlist + risky-action gate against the real mutating capability),
redaction (including a regression test for a false-positive bug found while broadening the
pattern set), and the tenant-override merge. Self-contained: spawns/kills its own target-app
processes on dedicated ports, cleans up its own evidence directories.
npm install
npx playwright install chromium
cp .env.example .env # then fill in ANTHROPIC_API_KEY (required for run-agent).env is gitignored — never commit real keys. replay does not need any key (no LLM
involved in replay, by design).
npm run target-app
# serves on http://localhost:40001. Discovery run — real LLM drives the live app:
node --env-file=.env node_modules/.bin/tsx src/cli.ts run-agent \
--goal "look up member 12345 and read their current savings balance" \
--target "http://localhost:4000/"Expected: "kind": "success", outputs.savings_balance containing the balance, and a
transcript + screenshot written to /evidence/discovery-<id>/.
2. Compile that discovery run into a reusable capability:
npx tsx src/cli.ts compile \
--transcript evidence/discovery-<id>/transcript.json \
--capability-id lookup-savings-balance-compiled \
--description "Look up a member by ID and read their current savings balance." \
--goal "look up member 12345 and read their current savings balance" \
--target "http://localhost:4000/" \
--discovery-run-id discovery-<id> \
--out capabilities/lookup-savings-balance-compiled.jsonA hand-written equivalent (capabilities/lookup-savings-balance.json) is also kept for
comparison — it's what V0 proved the Replayer against before any compiler existed.
3. Replay the compiled capability — deterministic, no LLM, works on data never seen during discovery:
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance-compiled.json \
--input '{"memberId":"67890"}'Expected: "kind": "success" with member 67890's balance — even though discovery only ever
saw member 12345. Evidence written to /evidence/replay-<id>/.
4. See all three non-happy-path Result kinds for real:
# business outcome — member doesn't exist, typed result, not a crash
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance.json --input '{"memberId":"99999"}'
# -> { "kind": "businessOutcome", "code": "member_not_found", ... }
# recoverable — a dismissible "session expiring" interstitial, handled automatically
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance-recoverable.json --input '{"memberId":"55555"}'
# -> { "kind": "success", ... } after a logged recovery_triggered/recovery_applied pair
# hard failure — invalid input, short-circuits before any browser launch
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance.json --input '{}'
# -> { "kind": "businessOutcome", "code": "invalid_input", ... }5. Safety guardrails — allowlist + risky-action confirmation, on a REAL mutating capability:
# open-new-subaccount actually submits a form that creates data (a real
# mutating/irreversible action, not a fake harness) — its submit step is
# marked risky and blocked without explicit confirmation
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/open-new-subaccount.json \
--input '{"memberId":"12345","initialDeposit":"500.00"}'
# -> { "kind": "failure", "observed": "blocked by guardrail: step 4 ... requires explicit confirmation ..." }
# same capability, with confirmation — proceeds, returns a real confirmation number
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/open-new-subaccount.json \
--input '{"memberId":"12345","initialDeposit":"500.00","confirm":true}'
# -> { "kind": "success", "outputs": { "confirmationNumber": "SA-100001" } }6. Redaction — sensitive data never persists to evidence, even under an innocuous key:
npx tsx scripts/redaction-demo.ts
cat evidence/redaction-demo/steps.log.jsonl
# an SSN-shaped value under a "note" field comes back "[REDACTED-SSN]";
# an "accountToken" field comes back "[REDACTED]" by key-name match7. Escalation & handoff — human takes over the SAME live session, then hands back:
# terminal A — start a replay against an artifact with a deliberately broken
# locator, with escalation enabled
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance-escalation-demo.json \
--input '{"memberId":"12345"}' \
--escalate --operator-port 4100
# it will block and print:
# Operator console listening on http://localhost:4100 (waiting for human)
# open that URL in a browser — you'll see the live screenshot, the exact
# reason it's stuck, and a form to perform one manual action, e.g.:
# actionType=extract, role=cell, name="4820.55 USD", extractAs=balance
# then click "Resume automation"Expected: the replay (terminal A) completes with "kind": "success" using the value the
human just extracted, and /evidence/replay-<id>/ contains intervention.json plus an
interleaved system/human log showing exactly what the human did.
Without --escalate, the same broken artifact just fails immediately (no server, no hang) —
escalation is strictly opt-in.
8. Cross-tenant reuse — one artifact, two tenants, a small override (not a re-recording):
# start a second target-app instance as "tenant B" (same vendor product, different config —
# its search control is a differently-labeled <button> instead of <input type=submit>)
PORT=4001 TENANT=vendorB npx tsx target-app/server.ts &
# the base artifact, UNMODIFIED, genuinely fails against tenant B
TARGET_URL="http://localhost:4001/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance.json --input '{"memberId":"12345"}'
# -> { "kind": "failure", "observed": "No locator strategy resolved uniquely: ..." }
# the SAME base artifact + a 6-line override succeeds against tenant B
TARGET_URL="http://localhost:4001/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance.json \
--tenant-override capabilities/tenant-overrides/vendorB.json \
--input '{"memberId":"12345"}'
# -> { "kind": "success", "outputs": { "balance": "4820.55 USD" } }9. Escalation from a stuck discovery run, not just a broken replay:
# force discovery to hit max-steps quickly to demo escalation without waiting a full run
AGENT_MAX_STEPS=1 node --env-file=.env node_modules/.bin/tsx src/cli.ts run-agent \
--goal "look up member 12345 and read their current savings balance" \
--target "http://localhost:4000/" \
--escalate --operator-port 4103
# open http://localhost:4103, perform the remaining steps manually (click Search, then
# extract the balance), click "Resume automation"Expected: discovery completes with "kind": "success" and a summary noting it was
"Resolved via human escalation", with the automated step(s) and the human's manual
actions both in the same evidence log.
10. Escalation from a genuinely organic failure (no artifact tampering at all):
# member 88888 has a real, simulated-slow backend response (see target-app/data.ts) — the
# BASE artifact is used completely unmodified
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance.json --input '{"memberId":"88888"}'
# -> { "kind": "failure", "observed": "locator.click: Timeout 5000ms exceeded. ..." }
# re-run the same command with --escalate to resolve it via the operator console instead11. Discovery-path guardrails — the agent itself is scoped, not just the replay artifact:
node --env-file=.env node_modules/.bin/tsx src/cli.ts run-agent \
--goal "look up member 12345 and read their current savings balance" \
--target "http://localhost:4000/" \
--allowed-domains "evil.example.com"
# -> { "kind": "stuck", "reason": "guardrail_blocked: domain \"localhost\" is not in
# allowlistScope.domains [evil.example.com]" }12. Risky-action approval record — a distinct audit trail, not just a log line:
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/open-new-subaccount.json \
--input '{"memberId":"12345","initialDeposit":"250.00","confirm":true}'
cat evidence/replay-open-new-subaccount-<id>/approvals.jsonl
# -> {"ts":"...","capabilityId":"open-new-subaccount","step":4,"action":"click"}13. Real, unforgeable proof of each LLM call — not just a described one:
node --env-file=.env node_modules/.bin/tsx src/cli.ts run-agent \
--goal "look up member 12345 and read their current savings balance" \
--target "http://localhost:4000/"
grep llm_response evidence/discovery-<id>/steps.log.jsonl
# -> {"model":"claude-haiku-4-5-...","responseId":"msg_...","usage":{"input_tokens":1339,...}}Anthropic's own response id and server-reported token counts — not something a hand-written transcript could fake.
14. Drift signal — which locator strategy actually resolved, per step, per run:
TARGET_URL="http://localhost:4000/" npx tsx src/cli.ts replay \
--artifact capabilities/lookup-savings-balance-compiled.json --input '{"memberId":"67890"}'
cat evidence/replay-lookup-savings-balance-compiled-<id>/drift.jsonl
# -> step 2 shows "outcome":"fallback" — the value-specific primary strategy correctly
# missed for a member never seen during discovery, the cssPath fallback resolved insteadV0 — skeleton + deterministic replay
- Sample target app: server-rendered, table-based layout, deliberately missing test IDs on several controls (legacy-surface stand-in — see brief Section 1).
SurfaceAdapter(Playwright-backed): observe/act/snapshot.- Multi-strategy locator resolver with ordered fallback (role/AX-name → text → CSS).
Artifact/Resulttypes locked perLLD.md— unchanged for the rest of the project.- Deterministic
Replayer: executes a hand-authored artifact, verifies a checkpoint, returns a typedResult, writes structured evidence.
V1 — real LLM discovery loop
run-agentCLI command: observe → decide → act loop, Claude (Anthropic Messages API, tool-calling,tool_choice: anyforces exactly one action per turn) drives the sameSurfaceAdapterused by replay.- Stop conditions: max steps (default 8), wall-clock timeout (default 120s).
- Structured transcript + step log + final screenshot/AX-snapshot evidence, same format as replay evidence for easy diffing later.
- Verified for real: Claude completed "look up member 12345 and read their current savings balance" in 3 steps (type → click → extract) with zero human intervention, output matched the actual page value exactly.
V2 — artifact compiler
compileCLI command: turns a discovery transcript into a typedArtifact, decoupled from the raw transcript.- Input parameterization: a typed value is promoted to a named input parameter only if it
appears in the original goal text (vs. a constant) — e.g.
"12345"→{{memberId}}. - Locator robustness: doesn't copy discovery's locators verbatim. For
extractsteps (where discovery often anchors on the literal data value, which won't generalize), the compiler derives a value-independent fallback by finding the adjacent label in the accessibility snapshot (tr:has-text('Savings Balance')), so the artifact resolves data it never saw during discovery. - Verified for real: compiled the actual V1 transcript with zero manual edits, replayed successfully against both the original member and a different one never seen in discovery.
V3 — error taxonomy
- Business-outcome detection now runs before every step (and once more after the loop) — fixes the V0/V1 known gap where a not-found result used to surface as a hard failure instead of a typed outcome.
recoveryRules: checked before each step, bounded bymaxApplicationsper rule.dismissclicks the matched element (e.g. a "Continue" link on an interstitial);reloadAndRetryreloads the page. Added a second target-app member (55555) with a real dismissible "session expiring" interstitial to exercise this for real, not simulated.- Verified all three non-happy-path
Resultkinds with real replay runs (see demo path above), plus regression-checked V0's and V2's existing artifacts still replay correctly.
V4 — safety & guardrails
enforceGuardrails: single choke point called before every step's action, checking domain allowlist, action-type allowlist, and risky-action confirmation — independent of what the artifact/LLM requested (defense in depth). A violation returns a distinctResult.failurewith a clear"blocked by guardrail: ..."reason, not a generic error.Step.risky(optional, backward-compatible): a risky step is blocked unless the caller passesinputs.confirm === true. Deliberately blunt — no partial confirmation, no scoring.- Redaction fixed and hardened:
writeResult/writeJsonused to bypass redaction entirely (onlylog()was covered) — now all three go through the same recursive redaction pass. Added value-pattern matching (SSN-shaped values) so sensitive data under an innocuous key name still gets caught, not just fields whose name says "password"/"token"/etc. - Verified all three for real: domain violation blocked, risky step blocked-then-allowed with
confirmation, and a fake SSN/API-token redacted from persisted evidence (see demo path
above and
scripts/redaction-demo.ts).
V5 — escalation & handoff
- On a hard failure (locator resolution), if
--escalateis passed, the Replayer raises anInterventionRequestand starts a small local operator console bound to the SAMEPlaywrightAdapter/Pagethe paused replay was using — the human operates the live session, not a fresh one. - Operator console (bare/mock per the brief's own scope note): shows the live screenshot +
exact stuck reason, exposes a manual-action form, and a "Resume" button. Human actions are
logged in the same structured format as automated steps (
actor: "human"). - Escalation is opt-in (
--escalate, default off) — V0-V4's verified automated behavior is completely unchanged without it; confirmed by regression test (same broken artifact fails immediately, no hang, when the flag isn't passed). - On resume, replay continues at the next step (trusts the human completed the failed step's intent manually) rather than blindly retrying the same broken locator.
- Verified for real, full loop: broken artifact → escalate → manual fix via the operator
console → resume →
Result.successusing the human-provided value, with full evidence trail (intervention.json+ interleaved system/human log + before/after screenshots).
Post-V6 stretch goals
- Cross-tenant reuse via override (
src/artifact/tenant-override.ts): a baseArtifactplus a small per-step override merges into a tenant-specific artifact, sharing everything else. Demonstrated against a genuinely different second target-app tenant, not a toy example — verified the base artifact actually fails unmodified, actually succeeds with the override, and the base tenant is unaffected either way. - Escalation wired into discovery, not just replay:
runDiscoverytakes the same opt-inenableEscalationoption asreplay. On any stuck exit, escalates against the same live discovery browser session; the human's actions get merged into the discovery outputs. Found and fixed a real bug while verifying this (see DECISIONS.md): the stuck-path return wasn't awaited before afinallyblock closed the browser, crashing mid-escalation.
V6 — write-up
REPORT.md: the 7 required sections (Architecture, Artifact schema, Determinism & error handling, Heterogeneity & multi-tenant, Escalation & handoff, Safety, Cuts), drawn from the decisions logged throughout V0-V5 rather than reconstructed after the fact.
Post-V6 hardening
- Fixed the compiled-artifact output-shape inconsistency flagged in REPORT.md's original Cuts
list: the compiler's derived fallback locator matched a whole
<tr>(label+value concatenated) instead of just the value cell. Fixed in both the compiler (future compiled artifacts) and the existing hand-authored capability files (td:nth-child(2)scoping). Re-verified all 4 affected artifacts — consistent bare-value output regardless of which locator strategy resolves; business-outcome and escalation paths regression-checked.
V8 — hardening from a strict self-review (asked to grade this project honestly against the brief's own weighted criteria, then fix what the review found — not just note it):
- Removed dead code (
openai-client.ts, superseded by Anthropic in V1, never deleted). - Built a real mutating capability (
open-new-subaccount.json— fills a form, submits, creates a sub-account, returns a confirmation number) so the risky-action gate protects an actual use case instead of a fake harness marking a harmless step risky. Retired the old fakelookup-savings-balance-risky-demo.json. - Business-outcome taxonomy split into its own policy module
(
src/replay/business-outcomes.ts) and given a genuine second, distinct entry (permission_denied, backed by a real restricted-member scenario in the target app) — proves it's a taxonomy, not one hardcoded regex. - Redaction patterns split into their own config module
(
src/evidence/redaction-patterns.ts) and broadened (card numbers, email, phone, DOB, account/routing numbers, PIN — beyond the original SSN-only set). Caught a real bug while broadening it: an early card-number pattern matched bare 13-19 digit runs, which would have redacted everyevidenceId(a 13-digitDate.now()timestamp) as a false positive — fixed by requiring visible digit-grouping before shipping, with a regression test. - Operator console's manual-action form now supports
navigate, not justrole+namelocator-based actions — a human can now fix "wrong page" failures, not only "broken locator on the right page" ones. - Added an automated test suite (
npm test, 16 tests, Node's built-in test runner) — the single biggest gap the self-review found: every prior verification in this project was a manual CLI run. Real integration tests now cover all 3Resultkinds, both guardrail mechanisms, redaction (including a regression test for the bug above), and the tenant-override merge.
V9 — closed every remaining gap that was safe/sensible to close
- Fixed a real latent bug found while working on this: the Replayer only caught
LocatorResolutionErrorspecifically — any other error (a genuine Playwright timeout, a network error) would propagate uncaught and crash the whole process instead of returning a typedResult.failure. Broadened the catch to anyError. - Organic escalation, not just engineered failures: added a target-app member whose backend response is genuinely slow (simulating a legacy core-banking lookup), and gave the adapter a real, bounded action/navigation timeout (a genuine production safeguard, not just a test convenience — without it a stalled backend hangs the whole replay indefinitely). Verified: the unmodified base artifact fails with a real Playwright timeout message against this member — no hand-broken locator anywhere — and escalation resolves it the same way as the engineered demos.
- Found and fixed a second latent bug while wiring the above:
Step.timeoutMs/retrywere declared on the schema since V0 but never actually consulted by execution — a step either resolved on one immediate check or failed outright. Implemented real condition-based polling (resolveLocatorWithBudget) that retries within the declared budget before giving up. - Generic route canonicalization (
src/artifact/route-canonicalization.ts,canonicalizeRoute()): normalizes/member?memberId=12345→/member?memberId=:memberId, independent of the per-artifact tenant-override mechanism. Wired into the compiler as an additivecanonicalRoutesfield. Unit-tested (no browser needed). - Distinct risky-action approval record (
approvals.jsonl, separate from the general step log): every risky step that passes confirmation gets its own auditable entry (capability/step/action/timestamp) — verified written correctly on a real confirmed run. - Guardrails now enforced in the discovery path too, not just replay —
run-agenttakes an--allowed-domainsflag (defaults to the target's own hostname) and blocks the agent from acting outside it, reusing the exact same check as the Replayer. Verified: a deliberately restrictive--allowed-domainsvalue blocks discovery immediately with a clear reason, distinct from a locator/timeout failure (which the model can still retry from). - Broadened redaction further (DOB, IPv4), verified the new patterns don't false-positive on ordinary currency/version-shaped strings.
- One item deliberately NOT built: a real desktop/
axPathlocator adapter. The brief explicitly says desktop support isn't expected, and there's no real desktop target in this project to verify an adapter against — building one would be exactly the kind of unverified, speculative code the rest of this hardening pass was about eliminating.
run-agent's locator strategy is role/accessible-name only (no fallback chain during discovery) — the Replayer's multi-strategy fallback is a compiler-time (V2) concern.retrySteprecovery action is a declared no-op (the step loop naturally retries on its next pass) — onlydismissandreloadAndRetryperform an explicit action.LocatorStrategy.kindincludesaxPath/coordinatesfor future surface types (desktop), but neither is implemented — deliberately left design-only (see V9 note above).- Redaction pattern list, while broadened twice now, is still illustrative rather than exhaustive PII coverage a production system would need (see REPORT.md §6).
- Test suite is integration-style for anything touching a real page (real Playwright against a real spawned target-app), which is the right level for what needs proving, but slower (~4s) than pure unit tests; the pure-logic pieces (redaction, tenant-override, route-canonicalization) are unit-tested without a browser.
/README.md— this file: setup, exact demo commands, what's built./REPORT.md— design write-up, 7 required headings./evidence/— real discovery run, real replay runs covering all 3Resultkinds, a guardrail-block example, a redaction example, and a full escalation/handoff run.
See BUILD_PLAN.md for the full versioned build history. See REPORT.md §7 ("Cuts") for
what's deliberately left out and what would come next with more time.