Multi-Modal & Browser Agents Basics
9 examples to get you started with Multi-Modal & Browser Agents - 6 basic and 3 intermediate.
These sketches are conceptual and provider-agnostic. They show control flow and message shape, not a full production SDK.
Prerequisites
- Comfort with a simple agent loop: model call, optional tool call, observe result.
- A multi-modal model endpoint (OpenAI-compatible image parts work for the examples).
- Optional: Playwright installed for browser examples (
pip install playwrightthen browser install - verify at build). - Skim How Vision Models Extend an Agent Beyond Text for vocabulary.
Basic Examples
1. Encode a Local Screenshot for a Vision Call
Turn a PNG on disk into a data URL the model can read.
import base64
from pathlib import Path
def image_data_url(path: str, mime: str = "image/png") -> str:
raw = Path(path).read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}"
url = image_data_url("fixtures/checkout-error.png")- Keep fixtures small and realistic; full desktop captures waste tokens.
- Prefer PNG or JPEG per provider guidance (verify at build).
- Never commit screenshots that contain real secrets or customer PII.
Related: How Vision Models Extend an Agent Beyond Text - why images are observations
2. Minimal "Describe What You See" Call
One user message with text + image parts; no tools yet.
def describe_screenshot(client, model: str, data_url: str) -> str:
resp = client.chat.completions.create(
model=model, # must support vision - verify at build
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Describe the UI and any error in 3 bullets."},
{"type": "image_url", "image_url": {"url": data_url}},
],
}],
max_tokens=300,
)
return resp.choices[0].message.content- Explicit instructions beat "look at this."
- Cap
max_tokensso a verbose model does not essay every screenshot. - Use a fixed fixture set so you can regression-test descriptions later.
Related: Combining Vision and Tool Use for Document-Processing Agents - structured extract next
3. Ask for Structured JSON, Then Validate
Vision is more useful when the host parses fields, not free prose.
import json
from pydantic import BaseModel, ValidationError
class UiFinding(BaseModel):
page_type: str
has_error: bool
error_text: str | None
primary_cta: str | None
def extract_ui(client, model: str, data_url: str) -> UiFinding:
prompt = (
"Return JSON only with keys: page_type, has_error, "
"error_text, primary_cta. No markdown."
)
text = describe_with_prompt(client, model, data_url, prompt)
return UiFinding.model_validate(json.loads(text))- Schema validation is part of the agent host, not optional polish.
- On
ValidationError, retry once with the error message and the same image. - Prefer enums for
page_typeonce your domain is known.
Related: Tool Use and Function Calling Basics - tools after extract
4. Screenshot Tool Returns Image + Short Text
Make vision an observation channel inside a tool loop.
def tool_screenshot(page) -> dict:
png = page.screenshot(type="png", full_page=False)
b64 = base64.b64encode(png).decode("ascii")
return {
"ok": True,
"note": f"viewport screenshot, url={page.url}",
"image_data_url": f"data:image/png;base64,{b64}",
}- Always pair pixels with a short text note (URL, timestamp, step id).
full_page=Falseis the default for cost control.- The host must attach
image_data_urlas an image part on the next model call.
Related: Building a Browser-Controlling Agent with Playwright - full tool set
5. Tiny Agent Loop: Goal → Screenshot → Answer
No clicks yet; prove the observe path works.
def run_observe_only(goal: str, page, client, model: str, max_turns: int = 3):
messages = [{"role": "system", "content": "You inspect pages. Call screenshot when needed."}]
messages.append({"role": "user", "content": goal})
for _ in range(max_turns):
out = client.chat.completions.create(model=model, messages=messages, tools=[screenshot_tool_schema])
msg = out.choices[0].message
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
obs = tool_screenshot(page)
messages.append(tool_message_with_image(call.id, obs))
return {"status": "max_turns"}- Bound turns even for read-only agents.
- Implement
tool_message_with_imageso image parts survive into history. - Stop when the model returns a final answer without a tool call.
Related: Stopping Conditions: How an Agent Knows When It's Done - host stop rules
6. Launch Playwright and Open a URL Safely
Browser agents need a real page before vision helps.
from playwright.sync_api import sync_playwright
def open_page(url: str):
p = sync_playwright().start()
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 720})
page = context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
return p, browser, page- Pin viewport size so screenshots are comparable across runs.
- Prefer
domcontentloadedovernetworkidleon modern SPAs (idle may never come). - Always close browser and stop Playwright in a
finallyblock in real code.
Related: Handling Dynamic Pages, Popups, and Login Flows in Browser Agents - waits and popups
Intermediate Examples
7. Prefer Accessibility Snapshot, Fall Back to Vision
Do not spend vision tokens when the DOM already answers.
def observe(page, client, model: str, question: str) -> str:
try:
snap = page.locator("body").aria_snapshot() # API name may vary - verify
if len(snap) > 50:
return client_text_only(client, model, question, snap)
except Exception:
pass
obs = tool_screenshot(page)
return describe_with_image(client, model, question, obs["image_data_url"])- Hybrid observe cuts cost and often improves precision.
- Treat aria/DOM as untrusted content just like page HTML.
- Fall back to vision for canvas-heavy or remote-desktop UIs.
Related: Vision-Grounded Actions: Clicking What the Model Actually Sees - when pixels must drive action
8. Drop Stale Screenshots from History
Old images confuse the model and burn context.
def prune_images(messages: list, keep_last: int = 1) -> list:
images = [i for i, m in enumerate(messages) if message_has_image(m)]
drop = set(images[:-keep_last]) if keep_last else set(images)
pruned = []
for i, m in enumerate(messages):
if i in drop:
pruned.append(replace_image_with_text(m, "[prior screenshot omitted]"))
else:
pruned.append(m)
return pruned- Keep the latest viewport; summarize older visual findings as text.
- Prune before every model call on long browser trajectories.
- Log that you pruned so traces explain missing images.
Related: Multi-Modal & Browser Agents Best Practices - cost and context rules
9. Smoke Checklist Before Adding Click Tools
Prove observe reliability before granting act privileges.
checklist = {
"vision_model": "returns UiFinding on 5 fixtures",
"screenshot_tool": "image + url note each call",
"viewport": "fixed 1280x720",
"max_turns": 5,
"cleanup": "browser.close in finally",
"no_secrets_in_shots": True,
}
assert all(checklist.values())- If describe/extract is flaky, clicks will be worse.
- Separate read tools from act tools in allowlists.
- Add login and popup handling only after smoke passes on public pages.
Related: Building a Browser-Controlling Agent with Playwright - navigate, click, read | Managed Browser Agent Platforms vs Self-Hosted Playwright - host choice
Related
- How Vision Models Extend an Agent Beyond Text - foundations
- Building a Browser-Controlling Agent with Playwright - action tools
- Vision-Grounded Actions: Clicking What the Model Actually Sees - coordinate grounding
- Combining Vision and Tool Use for Document-Processing Agents - documents
- Multi-Modal & Browser Agents Best Practices - production checklist
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.