14 — LLM-over-URI agent (drive Chrome + tools by URI)
An LLM-style decision loop over a URI registry: compile connectors into one registry (the agent's *action space*), let a planner pick {uri, payload} steps, run them under policy (query freely, command only when permitted), and feed each result into the next decision.
This is the pattern from ../AUTOMATION-INTEGRATIONS.md: registry → LLM picks a URI → policy gate → run → result → repeat.
Run
python3 agent.py "check and read https://example.com"
# queries (time/http/dom) run; the log COMMAND is skipped (gated)
python3 agent.py "check and read https://example.com" --allow-commands
# now the log:// command runs too
python3 agent.py "..." --json # machine-readable trace
Ready-to-run YAML flow
The plan this agent produces is also committed as a runnable flow of real URIs — run it directly, no planner/LLM:
python3 agent.py --flow flows/web-recon.yaml --allow-commands
# time:// -> httpcheck:// -> browser:// -> log:// (see flows/web-recon.yaml)
Example output:
✓ time://host/clock/query/now stamp the run
-> {"ok": true, "utc": "2026-…Z"}
✓ httpcheck://host/url/query/status is the site up?
-> {"ok": true, "status": 200, ...}
✓ browser://chrome/page/query/dom read the page
-> {"ok": true, "bytes": 561, "sample": "<!DOCTYPE html> ...Example Domain..."}
· log://host/run/command/write record the run (gated)
Pieces
tools.py— a tiny self-contained connector: emits v2bindingsand answers
time, httpcheck, browser://chrome and log routes. The browser route reads a page via headless Chrome (chrome --headless --dump-dom), with a graceful dry-run when no Chrome is installed. In production these are real connectors (time-tools, http-check, a Chrome CDP browser-control, sqlite-context).
agent.py—load_registry()→action_space()→plan()→run_step().
plan() is deterministic so the demo runs in CI.
Reusing the real browser-control connector
The browser route doesn't have to be the tools.py stub. When the sibling package urirun-connector-browser-control is checked out next to this repo, agent.py reuses it for the whole browser:// surface — the same registry, but backed by the packaged connector instead of the demo stub:
without connector: browser://chrome/page/query/dom (tools.py stub)
with connector: browser://chrome/page/query/dom ┐
browser://chrome/page/query/text │ urirun-connector-
browser://chrome/page/command/screenshot │ browser-control
browser://desktop/page/command/open │
browser://desktop/page/command/screenshot ┘
The connector's routes run out-of-process (it ships local-function-subprocess handlers via the shared python -m urirun.exec runner), so they're registry-portable and the agent drives them as external tools — no in-process import of the connector at call time, and the routes survive being compiled into a registry. agent.browser_control_bindings() loads the connector straight from the checkout (adds the package dir to sys.path/PYTHONPATH, no install needed) only to read those bindings. load_registry() then drops the inline browser:// stub and merges in the connector's routes; the planner prefers the connector-only page/query/text route, and run_step unwraps the handler result from the run envelope (result.value). The run banner reports which backend is active:
python3 agent.py "check and read https://example.com"
# action space: 8 routes (browser: urirun-connector-browser-control)
# ✓ browser://chrome/page/query/text read the page
# -> {"connector": "browser-control", "ok": true, ...}
If the connector isn't present the agent silently falls back to the tools.py stub (browser: tools.py (inline stub)), so the example still runs standalone. This is the point of the URI + registry substrate: a connector is *plugged into* the agent's action space without changing the loop — see ../18-connector-transport.
Built into urirun
This loop now ships as a runtime command — point it at any compiled registry:
tools.py bindings > b.json && urirun compile b.json --out reg.json
urirun agent space reg.json # the action space
urirun agent run reg.json --goal "check https://example.com" \
--planner agent:plan --allow-commands # run a pluggable planner
--planner module:function takes (goal, action_space) and returns the [{uri, payload}] steps — wire it to an LLM. agent.py here is the long-form equivalent.
Swap in a real LLM
Replace plan(goal, routes) with a model call: send the goal plus the action space (action_space(registry) — the same data as urirun's MCP tools) and have the model return the [{uri, payload}] steps. Anything MCP/A2A-speaking can consume the registry directly; or use the llm connector (llm://host/chat/command/complete) inside the loop.
Why URI + registry is a good agent substrate
- one validated, typed contract for every capability (no N bespoke SDKs);
queryvscommandmakes read-vs-write safe by construction;--allow/--executepolicy curates exactly what the agent may do;- the same registry is already MCP tools / an A2A card — discover-and-call, no glue;
- every step is a URI + payload + decision → auditable and replayable.
Real Chrome control (CDP)
chrome --headless --dump-dom is the one-shot shown here. For full control use the Chrome DevTools Protocol (chrome --remote-debugging-port=9222) and map: Page.navigate → browser://chrome/page/command/navigate, Runtime.evaluate → …/page/query/eval, Page.captureScreenshot → …/page/command/screenshot, Input.dispatchMouseEvent → …/page/command/click. query = read DOM/eval, command = navigate/click (gated). See ../AUTOMATION-INTEGRATIONS.md §4.
Test
pytest test_agent.py -q