Fetch HTML and submit web forms¶
The builtin.web tools include get_web_content (Markdown) and web_search, but sometimes an
agent needs to see a page's raw markup, discover its forms, and submit them (a site
search, a login, a data entry). Two additions cover this:
get_webpage_html(url)— a stateless tool returning the page's raw HTML.make_web_tools()— a factory returningfind_forms,submit_form, andsubmit_json, which share arequests.Sessionso cookies (and thus logins and CSRF tokens) persist across calls.
Server-rendered HTML only
These fetch HTML over requests; they do not execute JavaScript. Pages built client-side
(SPAs) return their pre-render markup, and anti-bot-protected sites (Cloudflare, captchas) will
be blocked. A headless-browser backend is a possible future addition.
Read raw HTML¶
get_webpage_html sits in builtin.web alongside get_webpage. Use get_webpage when you want
clean article text; use get_webpage_html when you need the markup itself (links, attributes, form
structure). Long pages are truncated to stay within the model's context window.
import aimu
from aimu.tools import builtin
agent = aimu.agents.Agent(aimu.client("ollama:qwen3:8b"), tools=builtin.web)
agent.run("Fetch the HTML of https://example.com and list every <a> href you find.")
Discover and submit forms¶
make_web_tools() returns three tools bound to a shared session. Pass them alongside
get_webpage_html:
import aimu
from aimu.tools.builtin import get_webpage_html, make_web_tools
agent = aimu.agents.Agent(
aimu.client("anthropic:claude-sonnet-4-6"),
tools=[get_webpage_html, *make_web_tools()],
)
agent.run("On https://httpbin.org/forms/post, submit the form with custname='Ada' and comments='hi'.")
find_forms(url)fetches the page and lists each form's index, submission URL (resolved to an absolute URL), HTTP method, and fields — includingtype=hiddenfields, so CSRF tokens surface for the agent to echo back.submit_form(url, method="POST", data=None)submitsdatato a URL.method="POST"sends it as a form body;method="GET"sends it as query parameters. It returns the response status, the final URL after redirects, and the (truncated) response body.
Because these tools close over one requests.Session, cookies set during one call are sent on the
next. A login flow works end to end: find_forms scrapes the login form's hidden token,
submit_form posts credentials (the server sets a session cookie), and a later
submit_form(url, method="GET") reads a page behind the login using that cookie.
Call a JSON API¶
submit_json(url, payload, method="POST") is the JSON counterpart to submit_form. The model
supplies payload as a structured object, not a string of JSON, so it never hand-serializes:
the @tool decorator advertises the parameter as a JSON Schema object and validates the model's
argument at dispatch. The body goes out JSON-encoded with the matching Content-Type.
method accepts "POST" (default), "PUT", and "PATCH" — the verbs that carry a body. GET is
submit_form's job, and DELETE is deliberately absent. Anything else is refused before a request
goes out, with a message the model can act on.
One limit worth knowing: payload is a JSON object, so a top-level array (a bulk endpoint that
takes [{...}, {...}]) cannot be expressed. Wrap it in an object if the API allows, or write your
own @tool for that endpoint.
Error bodies come back, which is the point
Both submit tools return the response whatever its status, because a 400 or 422 body
normally names the field that was rejected. That is what a corrected retry needs; an agent told
only Error: 422 has nothing to work from. Transport failures (DNS, connection refused,
timeout) are still reported as an error string, since there is no response to show.
Authentication, and why there is no headers argument¶
Neither submit tool lets the model set request headers. Credentials belong on the session you pass to the factory, so a token you configure for one host cannot be aimed at a host the model names:
import requests
session = requests.Session()
session.headers["Authorization"] = "Bearer …"
tools = make_web_tools(session=session)
Note the remaining exposure: the model still chooses the URL, and the session's credentials ride along with it. Combine that with a page the agent just read (whose text can instruct it) and an arbitrary-target write is a confused-deputy risk. The control for that is the tool-approval hook, which sees the URL before the call runs and can refuse it with a reason.
Redirects stop at the host boundary¶
Both submit tools follow a redirect only while it stays on the same host. A cross-host redirect is reported instead of followed, naming the target:
Status: 307
URL: http://site.example/start
Not followed: this redirects to http://other.example/sink on a different host. Nothing was sent
there. Submit to that URL directly if you intend to.
The reason is specific: a 307 or 308 replays the request method and body, so following one
off-host would re-send your whole payload to a host the model never named and no approval policy
ever inspected. (requests does strip Authorization across hosts, so the credential is not the
leak — the payload is.) Refusing the hop puts the new host back in front of the approval gate,
because reaching it now takes a fresh tool call that the policy sees.
Same-host redirects are followed, which is what keeps the login flow above working in one call, with
a 301/302/303 downgrading to a bodyless GET exactly as requests and browsers do. A chain is
capped at five hops, and hitting the cap is reported rather than followed further. The read tools are
unaffected: a redirected GET carries no body and no credential, so requests' own handling stays
in charge there.
Control the session¶
Pass your own session to share one across several tool sets, or to raise the timeout and the response cap:
user_agent= sets the User-Agent sent on every call (it is a per-request header, so it takes
precedence over the session's own). Pass user_agent=None to send none and leave a session-level
User-Agent in charge.
Confirm before submitting¶
submit_form and submit_json perform writes, to any URL the model names. To require confirmation
before one runs, gate it with the tool-approval hook — the policy sees the tool
name and the model's arguments, so you can approve reads and prompt on submits:
def confirm_submits(name, arguments):
if name not in ("submit_form", "submit_json"):
return True
return input(f"Submit to {arguments.get('url')}? [y/N] ").strip().lower() == "y"
agent = aimu.agents.Agent(client, tools=[get_webpage_html, *make_web_tools()],
tool_approval=confirm_submits)
Async¶
Both entry points are re-exported from aimu.aio.tools.builtin; the async agent dispatches these sync
requests tools via asyncio.to_thread:
from aimu import aio
from aimu.aio.tools.builtin import get_webpage_html, make_web_tools
agent = aio.Agent(aio.client("ollama:qwen3:8b"), tools=[get_webpage_html, *make_web_tools()])
await agent.run("…")
See also¶
- Add a custom tool: write your own
@tool - Gate tool calls: confirm or block the mutating
submit_form/submit_json - Use MCP tools: cross-process tool servers