Frame 09Open sourceAgentsNov 2025
A browser agent that never sees the password and writes up what it did
Browser Autopilot takes a plain-English task, drives a real Chromium page through a LangGraph loop, and hands the run to a second agent that turns it into a screenshot tutorial. I wrote it as a single file to test two ideas: that a model can log in without ever holding a secret, and that an agent should leave a readable record of what it touched.
- Stack
- Python, LangGraph, Playwright (sync), Pydantic, gpt-5-mini through LangChain
- Scope
- Sole author, one file
- Three nodes in a loop read the page, plan one structured action and execute it; a conditional edge decides whether to continue. A fourth node, the documentation agent, then curates the run into 3 to 7 steps.
- The model emits
credential_siteandcredential_type; the executor readsGITHUB_USERorGITHUB_PASSWORDfrom the environment at fill time and logs the password as[HIDDEN]. - Four real runs are committed with 16 screenshots. No tests, one file, and the README names the wrong API key variable.
What could go wrong, and how we would know
Two things go wrong with browser agents. They loop, clicking the same dead element until the budget runs out. And they leak, because the easiest way to log in is to paste the password into the prompt.
The loop is handled with a counter. Every action appends a line to the history; failures are prefixed FAILED and shown back to the model as "failed", so it sees its own misses. consecutive_failures resets on success. At 2 the prompt gets a warning suggesting Escape or a direct goto; at 4 the loop exits. Every exit path, including the aborts, routes into the documentation agent, so a stuck run still produces a write-up of how far it got.
The leak is handled by never giving the model the value. The action schema has credential_site and credential_type fields, both enums. The model says, in effect, "fill this index with the github password"; execute_action builds the key GITHUB_PASSWORD, reads it with os.getenv, types it, and prints [HIDDEN]. Usernames log as three characters and asterisks. A missing variable raises, which counts as a failed step rather than a silent fallback.
A failure counter fed back into the prompt: warning at 2 consecutive failures, abort at 4, plus a 15-step budget and a LangGraph recursion limit of 50.
What it cannot tell: the model sets task_complete itself and nothing checks the claim. A confident "done" on the wrong page ends the run.
What I built
Four nodes. get_page_state runs a script in the page that collects every link, button, input, select, textarea and role="button" or role="link" element, drops the invisible ones, and only then assigns an index. The order matters: the number the model returns has to point at the element it saw. Each element carries a unique selector when it has an id, data-testid or input name, and its center coordinates either way.
plan_action sends a JPEG screenshot, the request, the history and the element list to gpt-5-mini bound to a Pydantic PlaywrightAction, so the reply is one of five actions (click, fill, press, goto, wait_for_user) plus a one-line reason. Past 60 interactive elements the list is trimmed: inputs first, then up to 40 named elements, then a short tail.
execute_action uses the selector when it exists and is visible, and clicks the recorded coordinates when it does not. wait_for_user blocks on input() so a human can clear 2FA or a CAPTCHA. After every successful action it stores a PNG plus the action, reason and URL for the documentation agent.
| Chose | Over | Because | Cost |
|---|---|---|---|
| Symbolic credential reference in the action schema | Secrets in the prompt, or a hard-coded login step | The model only needs to know that a github password exists, not its value | One enum entry per site; only github today, so a Notion sign-in falls to the human |
| Index assigned after the visibility filter, with a selector and coordinates per element | Raw DOM indices, or letting the model write selectors | The index the model returns must map to the element it saw | No selector without an id, data-testid or name; the rest get a coordinate click |
| Pydantic structured output for both agents | Parsing free text | An invalid action fails at the schema, not mid-click | Five action types only; no scroll, hover or select |
The documentation agent, and the four runs
After the loop, generate_documentation sends the request, the full action history and every post-action PNG to the same model bound to a TutorialOutput schema: title, introduction, steps that each reference a screenshot index, and a conclusion. The prompt says to keep only the essential states, "typically 3-7 screenshots", to skip failed actions and retries, and to merge micro-actions. export_tutorial writes tutorial.md, tutorial.json and only the selected PNGs into a timestamped folder.
Four runs are committed:
- YouTube, play a named channel's latest video: three steps.
- GitHub, add a README to an empty repository: six steps. Click Sign in, fill both credentials from the environment and submit, open the repository, click Add a README, edit, commit. This run exercises credential indirection end to end.
- Notion, create a page titled "hello" with a description: four steps. Notion is not in the credential enum (only github is), so that sign-in was manual or already authenticated; the tutorial does not record which.
- YouTube, search "langchain" and play the first result: three steps, with the search box click, the typing and the Enter press folded into one step by the curator.
Sixteen screenshots in all. None are embedded here: they were captured inside my own accounts and show a GitHub username and a Notion workspace name. That is also the limit of the indirection. It keeps the password out of the prompt and the logs, not the username or anything the page renders afterwards, and those frames go to the documentation model and into the repo.
Outcome, and what it does not prove
The four runs show the loop completing real tasks on live sites and the second agent producing readable tutorials with sensible step boundaries. They do not show reliability: there is no success rate, no run that hit the abort path is committed, and the curated tutorial drops failures by design, so the repo holds a clean-path record rather than an audit log. The full history is printed to stdout and never saved.
What I would do differently
Persist the unfiltered history and every captured frame next to the tutorial; pin requirements.txt; write a test for the element cap, because others[:20 - len(inputs)] goes negative once a page has more than 20 inputs, and a negative slice keeps everything except the last few, the opposite of a cap; and gate task_complete on a second look at the page instead of trusting the planner's own claim.
- One file,
main.py, about 600 non-blank lines. All 13 commits landed in a 47-minute window on 26 Nov 2025, and the first one is the whole program, so there is no development history to read. The four run folders are timestamped 25 and 26 Nov 2025, so the program existed at least a day before the push. - No tests and no CI.
- README says set
OPENAI_API_KEY;main.pyreadsChatGPT_API_KEY. Following the README as written fails at startup. The README also listsdocs/architecture.mdand.env.example, which do not exist. requirements.txthas no version pins and omitslangchain-anthropic, although the README and a commented block inmain.pyoffer a Claude option.- Several bare
exceptblocks swallow errors around load-state waits and visibility checks, and arange(1)retry loop never retries. - The committed screenshots contain account identifiers, which is why this page has none.
Sources README main.py tutorials/ Scope: I (sole author) Verified 3 Sep 2026