Probably the fastest goal-driven browser agent for PHP.
Give it a URL and a goal in plain language. It drives a remote Chrome over the Chrome DevTools Protocol, picks each action from the controls on the page in front of it, and returns what it did, evidence of what it found, and the page's console errors and failed requests.
It runs against any CDP endpoint. Cloudflare Browser Run is the tested host: several processes can share one warm browser session, each in its own browser context.
use GuzzleHttp\Client;
use Phox\BrowserAgent\Agent\Agent;
use Phox\BrowserAgent\Agent\AgentOptions;
use Phox\BrowserAgent\Cdp\BrowserTab;
use Phox\BrowserAgent\Cdp\WebSocketConnection;
use Phox\BrowserAgent\Decision\ModelConfig;
use Phox\BrowserAgent\Replay\Report;
$connection = WebSocketConnection::connect($cdpUrl, ['Authorization' => "Bearer {$token}"]);
$tab = BrowserTab::open($connection);
$agent = Agent::make(
$tab->session,
new ModelConfig(typesafeKey: $typesafeKey, textKey: $textModelKey),
new Client,
new AgentOptions(['Find the contact form'], heartbeat: fn ($state) => $monitor->beat()),
);
$report = Report::of($agent->run('https://example.com'));
$tab->close();Decisions go to TypeSafe's /v1/systemone by default. OpenRouter answers the same request at its
own path with an OpenRouter key:
new ModelConfig(typesafeKey: $openRouterKey, typesafeBaseUrl: ModelConfig::OPENROUTER_BASE_URL, decisionPath: ModelConfig::OPENROUTER_DECISION_PATH).
A run is read-only unless AgentOptions sets readOnly: false: it may click to navigate or
reveal, but is offered nothing that types, selects, drags or submits. A run that may write can also
drag an element onto a drop zone, as a native drag or as pointer moves. A run ends done, blocked (with
reason not_found or stuck), budget or off_site. Script::of($state) records what it did
and Replayer plays that back without the model.
Besides what is on screen, a run is offered controls a row or card shows only under the pointer,
and controls out of view inside a panel that scrolls on its own; the executor hovers the one and
scrolls the other into view before pressing. An arriving page is read once its fetches have come
back and its loading indicators have cleared, waiting at most SettleTimings::$arrival.
PageReader::readDocument() reads the whole rendered page, with an outline of its headings, to
give a model the page as context, and TextRendering::of($observation) turns an observation into
that text. A document reading is cut so its rendering fits max_document characters, keeping
headings, then controls, then text, and nothing can be acted on from it.
BrowserTab::open(..., keepOpen: true) leaves the page open when the connection goes.
BrowserTab::attach($connection, $targetId) picks it up again, and Agent::run() without a url
works on it where it is, with goals, queries (new AgentOptions([], $queries)) or both.
Queries are the caller's JavaScript, run in the reader's isolated world: they see the DOM but not
page globals, and el(node) returns the element behind a node number from the latest observation,
as listed in Report::of and the text rendering. A query written as a function body, with
return, runs as the body of an async function.
- PHP 8.3
- A CDP endpoint, for example Cloudflare Browser Run
- A TypeSafe API key for action decisions
- An OpenAI-compatible model key for field values, when a run fills fields
composer install
composer test
composer lintTests run offline and never call a browser host or a model API. The Browser suite drives a local
headless Chrome, found in the Puppeteer cache or through CHROME_PATH; skip it with
vendor/bin/pest --testsuite=Unit.
tools/run.php runs one goal against a real endpoint and real models and prints the report.
tools/soak.php runs hundreds of transport and reader calls against a real endpoint such as
Browser Run and reports latency and every failure. It costs browser time, so it is not a test.
The page reader is JavaScript in reader/src, bundled into resources/page-reader.js, which is
committed so the package installs without Node. Rebuild and test it in a local headless Chrome:
npm ci
npm testnpm test finds chrome-headless-shell in the Puppeteer cache, or uses CHROME_PATH.
MIT