A reading and navigation layer for LLM agents. You give it a URL and a goal in plain language; it drives a real browser and gives you back an answer.
The agent never sees HTML, CSS selectors, or pagination. That is the point: a model asked to "find the cheapest book in the Mystery category" should not have to spend its context on markup.
browse(
url="https://en.wikipedia.org/wiki/Tokyo",
goal="find the current population",
)
# -> "Answer: 14,254,039 [0 steps · 1 pages · 7.8s · 1 model calls · $0.0045]"It is exposed over MCP, so any MCP client (Claude Desktop, Claude Code, your own) can call it as a tool.
Everything a page contains reaches the model through one narrow channel, and every browser mutation leaves through another. Those two boundaries are what make the layer site-agnostic and what make safety enforceable.
┌───────────────────┐
│ MCP client │
│ browse(url,goal) │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ controller │ the loop
└─────────┬─────────┘
┌───────────────┬──────────────┼──────────────┬───────────────┐
│ │ │ │ │
┌──────▼─────┐ ┌──────▼─────┐ ┌─────▼──────┐ ┌─────▼─────┐ ┌──────▼─────┐
│ perception │ │observation │ │ safety │ │ executor │ │ memory │
│ page -> │ │ what we've │ │ may this │ │ the only │ │ routes │
│ elements │ │ seen │ │ action │ │ module │ │ that │
│ + text │ │ │ │ run? │ │ touching │ │ worked │
└──────┬─────┘ └────────────┘ └────────────┘ │ the page │ └────────────┘
│ └─────┬─────┘
└───────────────► browser ◄───────────────────┘
(session)
One pass of the loop is five ordered stages: perceive → decide → validate →
execute → assess. Every way a run can end is one Termination value, produced
in one place.
| Module | Job |
|---|---|
controller.py |
The loop and every termination condition |
perception.py |
Page → elements + readable text. Three layers: accessibility tree (CDP) → DOM → vision |
observation.py |
What has been seen, across pages. Numbers kept with the line that gives them meaning |
executor.py |
The only module that mutates the browser |
safety.py |
May this action run? ALLOW / REJECT / HANDOFF / HALT |
session.py |
Browser lifetime, per-conversation isolation |
llm.py |
Model routing, retries, JSON recovery, cost accounting |
memory.py |
Routes that worked before, per domain |
server.py |
The MCP surface |
An earlier version extracted answers itself — re.findall(r'£(\d+\.\d{2})'),
a max_value_book_title, a hardcoded list of category names — and then
overrode the model's answer with what those rules found.
That works on exactly the site it was written against. A page of Hacker News
scores contains no £, so the tracker saw nothing at all.
Now the layer remembers what it saw and does not interpret it. Each page contributes its text and any numbers, each kept with the line it appeared on. The accumulated evidence goes into the prompt, and the model — which is good at "which of these is cheapest" and needs no regex to do it — decides. The answer is then checked for grounding in observed text rather than substituted.
Consequently observation.py has no notion of currency, of products, or of
what a goal is asking for. Tests assert its executable code contains no
price, book, or site name.
Page text is the largest part of every prompt, so the layer sends each page's content once rather than on every step. The agent re-reads its current page each time it decides; resending it pays for the same tokens repeatedly.
Measured across four real sites, six steps each:
| tokens / 15-step run | |
|---|---|
| resending page text every step | 39,130 |
| sending each page once | 21,745 |
~44% fewer input tokens. On dense pages (Wikipedia, Hacker News) a repeat step costs 60% less.
Dedup is per URL, never across pages. Suppressing a line because a different page carried it deletes content from a page the model is seeing for the first time and shifts its order — a listing whose top entry also appears elsewhere arrives headed by its second entry, and "the first X" is answered wrongly from a truthful reading of what was supplied. That regression was caught live, and the cross-page saving turned out to be worth almost nothing anyway.
Model routing: a cheap model handles early navigation steps, a stronger one takes over for comparison and synthesis. A typical run costs $0.005–$0.04.
safety.py sees every action before the executor does, and answers with one of
four verdicts. REJECT is recoverable — pick a different element. HANDOFF and
HALT end the run.
Some actions are never automated, whatever the model asks for:
- Typing into a credential field — password, card number, CVV, one-time
code — detected by label, placeholder,
name, andinput_type. - Clicking through a transaction — Place order, Pay now, Confirm booking, Delete account.
Both produce HANDOFF: the run stops and reports what it was about to do, for
a human to decide.
Matching is on whole phrases. submit alone flags "Submit search"; pay
alone flags "PayPal". Tests pin both directions — 12 labels that must hand
off, 8 that must not.
Requires Python 3.13+.
git clone https://github.com/samarthm04/agent-interface-layer.git
cd agent-interface-layer
python3.13 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/playwright install chromiumSet your API key (console.anthropic.com):
export ANTHROPIC_API_KEY=sk-ant-...Six scenarios against four real sites, reporting steps, time, model calls and cost for each:
.venv/bin/python example_usage.pyA browser window opens so you can watch. Options:
AIL_HEADLESS=1 |
no visible windows (faster) |
AIL_ONLY=e,f |
run only the named scenarios |
One text box. Describe a task in plain language — the site is picked out of the sentence, so there is no separate URL field.
.venv/bin/python web/app.pyThen open http://127.0.0.1:8420.
Tasks in a tab share one browser, so a follow-up that names no site at all ("on the same page, what year was it founded") continues where the last one left off. New browser session drops that state.
Compare with / without layer runs the identical task twice, side by side:
once through AgentController, once through naive_agent.py — a bare loop
with the same browser, executor, safety guard, model and action schema, whose
only differences are the ones this layer exists to make:
| AgentController | NaiveAgent | |
|---|---|---|
| page text | new lines only, deduped per page | the full page, every step |
| cross-page memory | accumulated evidence | none — raw history only |
| DONE claims | checked for grounding | accepted on first ask |
Both sides stream their steps live with per-step token counts, so you watch the gap open rather than take a number on faith. One measured run — "go to books.toscrape.com and find the cheapest book in the Mystery category":
| With layer | Without | |
|---|---|---|
| Input tokens | 5.6k | 23.7k |
| Steps | 2 | 10 (hit the limit) |
| Cost | $0.0076 | $0.1262 |
| Answer | ✅ Tastes Like Fear — £10.69 | ❌ never finished |
−77% input tokens, −94% cost. The traces show why: without accumulated
evidence the naive loop cannot remember what it already saw, so it re-treads
(next, previous, then five navigate calls) while its per-step cost
climbs 1.4k → 3.2k tokens as raw page text piles up. The layered run stays
flat at ~1.9k and finishes in two steps.
A comparison runs the task twice, for real — roughly 2× the time and cost
of a single run. AIL_COMPARE_MAX_STEPS (default 10) caps both sides
equally.
.venv/bin/python server.pyFor Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"agent-interaction-layer": {
"command": "/absolute/path/to/agent-interface-layer/.venv/bin/python",
"args": ["/absolute/path/to/agent-interface-layer/server.py"],
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"AIL_HEADLESS": "1"
}
}
}
}Two tools are exposed:
browse(url, goal, conversation_id="")— read and navigate a site to answer a goal. Repeat calls with the sameconversation_idreuse one browser, keeping cookies, logins and page position.close_browser(conversation_id="")— release a browser. Optional; they close on their own after 30 minutes idle.
browse returns text in every case, including failure. A run can also stop at
its step or time budget, or halt for a human decision — read the reply rather
than assuming it answered.
Server configuration: AIL_HEADLESS, AIL_MAX_STEPS (default 20),
AIL_MAX_SECONDS (default 300).
.venv/bin/python -m pytest # 625 tests, ~130s, no network, no API calls
.venv/bin/python -m pytest -m live # 4 tests against the real APIThe suite runs against a local fixture site and a scripted stand-in for the model, so it costs nothing and does not depend on live sites staying unchanged. The fixture site is deliberately adversarial: both price extremes sit on the last page, a decoy number precedes the real one in prose, ranked scores carry no currency symbol, and one page renders its content only after a timer.
Exhaustive search over large collections. Asked for the most expensive book across 50 pages, the agent reads a handful and reports the highest it found — honestly labelled "found", but not the true maximum. It is reliable on scoped goals and on collections it can cover; it does not yet reason well about when it has seen enough.
Sites that truncate in listings. Where a listing shows A Light in the ...
and the full title only on the detail page, the answer carries the truncated
form unless the agent clicks through.
No login flows. The agent stops at credential fields by design. For a site behind a login, sign in by hand once and every later run on that conversation starts from that state:
.venv/bin/python seed_profile.py https://example.com my-conversationOne page at a time. No parallel exploration.