How the headless browser slots works in Hyperlambda

How the headless browser slots works in Hyperlambda

When I build automation inside Magic, I do not want browser scripting to feel like some weird side system bolted onto the runtime.

I want it to feel like the rest of Hyperlambda.

That means a few things matter immediately.

I want deterministic control. I want simple primitives. I want browser automation to be composable. And I want it to be obvious how to move from one step to the next without hiding state in magical abstractions.

That is exactly why the headless browser slots work the way they do.

They give me a small set of explicit operations for launching Chromium, navigating to pages, waiting for DOM elements, clicking buttons, filling forms, reading page state, evaluating JavaScript, taking screenshots, and closing the session when I am done.

This article is not about browser automation in theory.

It is about how to actually use these slots in Hyperlambda.

The basic idea

The browser automation API uses an explicit session model.

That means I first create a browser session, get back a session_id, and then pass that session_id into every other slot I want to use.

I prefer this approach because it makes the control flow obvious.

A session starts here. Navigation happens here. Interaction happens here. And cleanup happens here.

There is no hidden scope and no implicit browser object floating around in a .lambda.

You connect once, keep the returned session identifier, and use it for everything until you close the browser.

At the simplest level, a browser flow looks like this.

.session_id
set-value:x:@.session_id
   puppeteer.connect

puppeteer.goto:x:@.session_id
   url:"https://ainiro.io"

puppeteer.title:x:@.session_id
puppeteer.close:x:@.session_id

That is the basic pattern behind everything else in this article.

How to connect to a browser and get a session_id

The first thing I do is open a browser session.

That is done with puppeteer.connect.

In its most minimal form, it looks like this.

.session_id
set-value:x:@.session_id
   puppeteer.connect

This launches Chromium and returns a session identifier.

If I need more control, I can pass additional arguments such as whether Chromium should run headless, what executable to use, launch timeout, extra Chromium flags, and how long the session should remain alive.

For example:

.session_id
set-value:x:@.session_id
   puppeteer.connect
      headless:true
      timeout:30000
      args
         .:--no-sandbox
         .:--disable-dev-shm-usage
      timeout-minutes:30
      max-lifetime-minutes:120

Most of the time, the default invocation is enough.

If I am running in a more constrained environment, or I need to tune how Chromium launches, then I use the extended version.

The important part is that the returned session_id becomes the handle for the rest of the workflow.

How to open a page and wait until it is ready

Once I have a session, the next step is navigation.

That is done with puppeteer.goto.

puppeteer.goto:x:@.session_id
   url:"https://ainiro.io"

If I want more predictable timing, I can provide both timeout and wait strategy.

puppeteer.goto:x:@.session_id
   url:"https://ainiro.io"
   timeout:30000
   wait-until:networkidle2

This matters because different pages become "ready" in different ways.

Some pages are ready once the DOM is loaded. Some pages keep making requests after initial load. Some pages render critical elements asynchronously.

That is why I usually do not stop at goto.

If I know a specific element must be present before I continue, I wait for that selector explicitly.

puppeteer.wait-for-selector:x:@.session_id
   selector:"#name"
   visible:true
   timeout:10000

If the page transitions after some action and I want to wait for a specific destination, I can wait for the URL.

puppeteer.wait-for-url:x:@.session_id
   url:"https://ainiro.io/contact-us"
   timeout:10000

In practice, this means I can make browser automation much more reliable by waiting for what I actually care about instead of assuming page load alone is enough.

How to click, type, fill, and submit forms

Once the page is ready, I can interact with it.

The most common actions are clicking, typing, filling, pressing keys, and selecting values from dropdowns.

Clicking an element

puppeteer.click:x:@.session_id
   selector:"#submit_contact_form_button"

If I need more control, I can specify button, delay, or click count.

puppeteer.click:x:@.session_id
   selector:"#submit_contact_form_button"
   click-count:2

Typing into an input

puppeteer.type:x:@.session_id
   selector:"#name"
   text:"Thomas Hansen"

This types into the target field without first clearing it.

If I want to control typing speed, I can add delay.

puppeteer.type:x:@.session_id
   selector:"#info"
   text:"Hello from AI agent"
   delay:25

Filling an input after clearing it

This is the slot I use when I want to replace any existing value.

puppeteer.fill:x:@.session_id
   selector:"#email"
   text:"[email protected]"

Pressing a key

Sometimes a form or control responds better to keyboard interaction.

puppeteer.press:x:@.session_id
   selector:"#submit_contact_form_button"
   key:Enter

Selecting values from a dropdown

puppeteer.select:x:@.session_id
   selector:"#plan"
   values
      .:basic
      .:pro

Or as a comma-separated string depending on what is most convenient in the current flow.

The main thing I try to do is keep each step explicit.

Wait for the field. Fill the field. Click the button. Wait for the next state.

That style makes browser automation much easier to debug.

How to read page title, URL, and HTML

Sometimes I do not want to interact with the page at all.

I just want to inspect it.

For that, there are dedicated slots for title, URL, and page HTML.

Get the title

puppeteer.title:x:@.session_id

Get the current URL

puppeteer.url:x:@.session_id

Get the full page HTML

puppeteer.content:x:@.session_id

This is useful when I want to inspect rendered output instead of raw server response.

That distinction matters because many modern pages build their content client-side.

If I only fetch HTML over HTTP, I may miss what the browser actually renders.

With puppeteer.content, I can inspect the page after JavaScript execution and DOM updates.

If I need something more targeted, I can evaluate JavaScript in the page.

puppeteer.evaluate:x:@.session_id
   expression:"document.title"

Or:

puppeteer.evaluate:x:@.session_id
   expression:"typeof window.mcaptcha"

This gives me a simple way to inspect runtime state inside the page without manually extracting the entire HTML.

How to take screenshots and close the browser

Once a flow works, I often want evidence.

That usually means screenshots.

A screenshot can be useful for debugging, verifying visual output, or documenting a workflow result.

puppeteer.screenshot:x:@.session_id
   filename:"/etc/tmp/example.png"
   full-page:true

If I want JPEG output instead, I can specify type and quality.

puppeteer.screenshot:x:@.session_id
   filename:"/etc/tmp/example.jpg"
   type:jpeg
   quality:85

When I am done, I close the session.

puppeteer.close:x:@.session_id

I consider this part important.

Opening the session is the beginning of the lifecycle. Closing it is the end.

That makes the flow easy to reason about and keeps resource usage under control.

A complete example

Here is a full example that connects to Chromium, opens a page, waits for it to load, reads the title and URL, saves a screenshot, and closes the session.

.session_id
set-value:x:@.session_id
   puppeteer.connect

puppeteer.goto:x:@.session_id
   url:"https://ainiro.io"
   timeout:30000
   wait-until:networkidle2

puppeteer.title:x:@.session_id
puppeteer.url:x:@.session_id

puppeteer.screenshot:x:@.session_id
   filename:"/etc/tmp/ainiro-homepage.png"
   full-page:true

puppeteer.close:x:@.session_id

And here is a form interaction example.

.session_id
set-value:x:@.session_id
   puppeteer.connect

puppeteer.goto:x:@.session_id
   url:"https://ainiro.io/contact-us"
   timeout:30000
   wait-until:networkidle2

puppeteer.wait-for-selector:x:@.session_id
   selector:"#name"
   visible:true
   timeout:10000

puppeteer.fill:x:@.session_id
   selector:"#name"
   text:"Thomas Hansen"

puppeteer.fill:x:@.session_id
   selector:"#email"
   text:"[email protected]"

puppeteer.type:x:@.session_id
   selector:"#info"
   text:"Hello from Hyperlambda"

puppeteer.click:x:@.session_id
   selector:"#submit_contact_form_button"

puppeteer.close:x:@.session_id

That is really the whole mental model.

Connect. Navigate. Wait. Interact. Read state if needed. Take a screenshot if useful. Close the session.

What I think matters most when using these slots

When I use browser automation in Hyperlambda, I try to keep three things in mind.

First, I do not assume navigation means readiness. If the next step depends on a specific element, I wait for that element.

Second, I prefer explicit steps over clever abstractions. Browser automation becomes fragile very quickly if too much state is implied.

Third, I always treat session lifecycle as part of the actual program. If I connect, I should also close.

That style keeps the code clean, easy to read, and much easier to debug when something changes in the target page.

My takeaway

The headless browser slots in Hyperlambda are intentionally simple.

They do not try to hide the browser session from me. They do not force me into nested scope patterns. They just give me a clean set of primitives for automating Chromium from Hyperlambda.

That is why I like them.

I can compose them however I want. I can keep the control flow explicit. And I can build browser-driven automation using the same style I use everywhere else in Magic.

If I need to scrape rendered HTML, click through a workflow, submit a form, inspect page state, or save visual output, these slots give me everything I need without turning browser automation into its own language.

That is exactly how I think it should work.