A browser that writes down the form instead of sending it

An agent-first browser engine runs a click the way page code expects, then records a form submission and stops, leaving network access, protocol transport, threads and session state with the application that embeds it.

November 20266 min readBrowsersAgent systemsRuntimeArchitecture

Quick rough cream and bronze green strokes surround a dense precise knot of dark plum with one vivid red mark at centre-right.

When you click the submit button on a web page, a desktop browser does a great deal on your behalf without stopping to ask. It runs the page’s handlers, builds a request from the form’s fields, attaches whatever cookies it holds, sends the request, replaces the document with the response and paints the result. All of that lives in one program because a person is watching and wants the next page.

The engine I’ve been developing for agents on Ferrite, my constrained Rust application platform, does the first of those things and stops. If no handler cancels the click, it records that the form was submitted, leaves that record for the application embedding it and goes no further. No request goes out, because the engine has no way of sending one.

That boundary follows from who the output is for. The design starts from Kitesurf, the headless browser Cloudflare published in August 2026 for agents rather than for people looking at screens, on the reasoning that an agent consumes extracted text, Markdown, links, serialized HTML and the occasional screenshot. Ferrite couldn’t use Kitesurf’s libraries, so I developed this engine on the Rust standard library, directing coding agents through the work, and it gives up a good deal of browser fidelity on purpose. Its screenshots draw text in an 8×16 bitmap face derived from the old VGA font, which an agent doesn’t mind and a designer would. What it can’t give up is the behaviour the agent’s next decision depends on, and most of the design work lies in telling that behaviour apart from everything a host should hold instead.

What a click has to get right

Take a form an agent might be sent to:

<form action="/projects/42/delete" method="post">
  <label><input type="checkbox" name="confirm"> Yes, delete this project</label>
  <button>Delete project</button>
</form>

An agent driving the engine over the Chrome DevTools Protocol can ask the DOM domain where the label is and then send a press and a release through the Input domain. Hit-testing finds the node under that point, and the press and the release have to land on the same node before they count as a click. The click goes to its target and bubbles up through the form, running handlers on the way, with stopPropagation and stopImmediatePropagation doing what scripts expect of them and any handler free to cancel the default. If nothing cancels it, the label forwards the activation to its checkbox and the checkbox toggles. A click on the button takes the same route, and its default action is the submission.

None of this shows up in a screenshot, and all of it decides what the agent learns. A great deal of page code listens at the form or the document and relies on events bubbling up to it, so an engine without that phase wouldn’t fail in any way you would notice: the click would do nothing, the DOM would stay as it was and the agent would conclude that the button is broken. A handler that cancels the submission because the box isn’t ticked is telling the agent something, as long as the engine honours the cancellation. A crude glyph costs a screenshot some of its looks, whereas a missed handler gives the agent a false account of what the page did, and its next step is built on that account. So the interpreter’s DOM layer spends its effort here, on the target and bubble phases, inline handlers, cancelable defaults, focus and the activation behaviour of checkboxes, radio buttons, labels and forms. Page code that depends on something outside that list won’t get it.

The agent also hears about failure. Console output and exceptions surface as protocol events, so a handler that throws arrives as something the agent can read instead of a click that quietly came to nothing, and afterwards the agent can read the resulting state back through remote objects or the serialized HTML.

Where the engine stops

Everything past the submission record needs authority the engine doesn’t hold. It does no I/O at all, and network access belongs to the consuming application through its scoped egress handle. An embedder that wants to follow the form has to send the request itself, through the same allowlisted handle as every other byte the application fetches, and then load the response as a new page. One that would rather not has lost nothing, and one that wants a person to look at a deletion first has an obvious place to put that check. A host that wants the same control over a desktop browser under automation has to intercept traffic the browser has already decided to send, and has to be sure it has caught every way out. Here the default runs the other way, since nothing leaves unless the host sends it.

The same division repeats wherever a desktop browser would act for itself. The protocol facade, shaped after the obscura project, answers for the Browser, Schema, Target, Page, Runtime, DOM, Input, CSS, Accessibility, Emulation and Network domains, but it runs over a transport the embedder owns, so the engine opens no port and a client reaches it only through whatever the host has chosen to carry.

Continuity works the same way. A Page is a pure value computed from one HTML string, with no session, no shared cache and nothing to reconstruct after a crash. A host that wants script state to last from one run to the next can hold on to a Realm and carry it forward, which makes continuity something the host chose and can see, where a desktop browser would have accumulated it by default. The price is that a session becomes the host’s job to build, so a journey that depends on staying signed in belongs in a full browser until a host has built one.

Refusing to start a thread

The refusal that shows the boundary best removed something useful. Scripts use stack, and a native tool that wants to get through a difficult page can start a thread with a very large stack and run the engine on it. The package used to include an optional helper for doing that, run_on_deep_stack. In version 0.1.4 it moved out of the package and into the native examples, so native tools now own their thread creation, deployed applications keep the interpreter’s default stack budget and create no thread to render a page, and a native embedder that controls its own stack can raise the budget explicitly through RunLimits::stack_budget_bytes.

The package is installed as source into applications that have no threads of their own to start, so keeping the helper there would have advertised a capability those applications don’t have. The move also brings forward a difference you would otherwise discover later: a page whose scripts recurse deeply can get further in a native tool with a generous budget than in a deployed application on the default one, so a native evaluation says little about those pages until the budgets match.

The other bounds work in the same spirit, by stating in advance what a page may cost. The rasterizer caps its pixel buffer at 64 MiB however tall the page is, and consuming applications cap fetched documents at 1 MiB. A protocol method the engine doesn’t implement answers with -32601, the error for a method that doesn’t exist, instead of a success with nothing behind it, because a client that receives a fake success takes its next step from a state that never existed.

What the agent needs for its next decision, the events, the defaults and the errors, is where the engine spends its effort. What would act on the world, keep a session or claim resources goes to a host that can see it and refuse it. If I were starting another agent browser, the form record is the part I’d copy first, because it turns the most consequential thing a browser does on an agent’s behalf into something a host has to do on purpose.

Chris Chabot, November 2026