diff --git a/examples/013/README.md b/examples/013/README.md
new file mode 100644
index 00000000000..4150411f800
--- /dev/null
+++ b/examples/013/README.md
@@ -0,0 +1,30 @@
+# Browser agent example
+
+## Setup for local headed browsing session
+
+### Tab 1
+```
+ockam
+```
+
+### Tab 2
+```
+cd node_server
+npm install
+npx playwright install
+node server.js
+```
+This will start a local playwright session in headed mode.
+
+It will also emit the command to create an outlet, copy that command.
+
+### Tab 3
+- wait until the `ockam` command from tab 1 has started the zone
+- paste and run the command from step 2
+
+## next steps
+
+Tab 2 and tab 3 can be left as is.
+
+The main `ockam` session from tab 1 will work after either k8s restarts the container or after a manual restart.
+
diff --git a/examples/013/images/main/Dockerfile b/examples/013/images/main/Dockerfile
new file mode 100644
index 00000000000..95706220fbf
--- /dev/null
+++ b/examples/013/images/main/Dockerfile
@@ -0,0 +1,9 @@
+FROM ghcr.io/build-trust/ockam-python-dev:latest AS dev
+COPY requirements.txt ./
+RUN pip install -r requirements.txt
+
+FROM ghcr.io/build-trust/ockam-python:latest
+COPY --from=dev /app/venv venv
+COPY main.py ./
+COPY index.html ./
+ENTRYPOINT ["python", "main.py"]
\ No newline at end of file
diff --git a/examples/013/images/main/index.html b/examples/013/images/main/index.html
new file mode 100644
index 00000000000..6130b9a7967
--- /dev/null
+++ b/examples/013/images/main/index.html
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Streaming?
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/013/images/main/main.py b/examples/013/images/main/main.py
new file mode 100644
index 00000000000..f91fa0cb07a
--- /dev/null
+++ b/examples/013/images/main/main.py
@@ -0,0 +1,425 @@
+from datetime import datetime
+from sys import argv
+from ockam import Agent, Model, Node, Tool, set_log_levels
+from playwright.async_api import async_playwright
+
+import asyncio
+import re
+
+# set_log_levels("agent=debug,ockam_node=info,ockam=info,LiteLLM=DEBUG")
+set_log_levels("DEBUG")
+
+
+class Browser:
+ def __init__(self, playwright, browser):
+ self.playwright = playwright
+ self.browser = browser
+
+ async def new_context(self):
+ return await self.browser.new_context()
+
+ async def stop(self):
+ await self.browser.close()
+ await self.playwright.stop()
+
+ @staticmethod
+ async def start(address):
+ playwright = await async_playwright().start()
+ browser = await playwright.chromium.connect(address)
+ return Browser(playwright, browser)
+
+
+class BrowserSession:
+ def __init__(self, browser, context):
+ self.browser = browser
+ self.context = context
+ self.pages = []
+ self.locators = []
+
+ def tools(self):
+
+ async def bring_to_front(page_index: int = 0) -> dict:
+ """
+ Brings the page at the given index to the front.
+ """
+ await self.pages[page_index].bring_to_front()
+ return {"status": "ok"}
+
+ async def click(page_index: int = 0) -> dict:
+ """
+ Clicks on the locator for the page at the given page index.
+ """
+ await self.locators[page_index].click()
+ return {"status": "ok"}
+
+ async def click_nth(page_index: int = 0, nth: int = 1) -> dict:
+ """
+ Clicks on the nth element that was matched by the locator
+ for the page at the given page index.
+ """
+ nth = nth - 1
+ await self.locators[page_index].nth(nth).click()
+ return {"status": "ok"}
+
+ async def current_iso8601_utc_time() -> str:
+ """
+ Returns the current UTC time in ISO 8601 format.
+ """
+ return datetime.utcnow().isoformat() + "Z"
+
+ async def enter(page_index: int = 0) -> dict:
+ """
+ Press enter anywhere on the page at the given page index.
+ """
+ await self.pages[page_index].keyboard.press('Enter')
+ await self.pages[page_index].wait_for_load_state("domcontentloaded")
+ await asyncio.sleep(2)
+ return {"status": "ok"}
+
+ async def fill(value: str, page_index: int = 0) -> dict:
+ """
+ Fills the element specified by the current locator
+ for the page at the given index with the given value.
+ """
+ await self.locators[page_index].fill(value)
+ return {"status": "ok"}
+
+ async def fill_active(value: str, page_index: int = 0) -> dict:
+ """
+ Fills the active element on the page at the given
+ index with the given value.
+
+ Returns true if the element was filled, false otherwise.
+ """
+ page = self.pages[page_index]
+ active = await page.evaluate_handle("() => document.activeElement")
+ element = active.as_element()
+ if element:
+ await element.fill(value)
+ return {"status": "ok", "filled": True}
+
+ selector = page.locator("input[autofocus]")
+ count = await selector.count()
+ if count == 1:
+ await selector.fill(value)
+ return {"status": "ok", "filled": True}
+
+ return {"status": "ok", "filled": False}
+
+ async def filter(text: str, page_index: int = 0) -> dict:
+ """
+ Filters the current locator for the page at the given index
+ by applying a has_text filter to the current locator
+
+ Returns the count of elements currently matched by the locator.
+ """
+ locator = self.locators[page_index].filter(has_text=text)
+ count = await locator.count()
+ if count == 0:
+ return {"status": "error", "message": f"No elements found with text: {text}"}
+ else:
+ self.locators[page_index] = locator
+ return {"status": "ok", "count": count}
+
+ async def goto(url: str, page_index: int = 0) -> dict:
+ """
+ Go to the given url for the page at the given index and
+ reset the locator on that page.
+ """
+ if page_index >= len(self.pages):
+ result = await new_tab()
+ page_index = result["page_index"]
+
+ page = self.pages[page_index]
+ # Ignore timeout errors when navigating
+ try:
+ await page.goto(url, timeout=5000)
+ except Exception as e:
+ None
+ await asyncio.sleep(1)
+ self.locators[page_index] = page.locator("body")
+ return {"status": "ok", "url": page.url}
+
+ async def locate(selector: str, page_index: int = 0) -> dict:
+ """
+ Sets the locator for the page at the given index
+ with the given selector.
+
+ Returns the count of elements currently matched by the locator.
+ """
+ locator = self.pages[page_index].locator(selector)
+ count = await locator.count()
+ if count == 0:
+ return {"status": "error", "message": f"No elements found for selector: {selector}"}
+ else:
+ self.locators[page_index] = locator
+ return {"status": "ok", "count": count}
+
+ async def locate_by_css(selector: str, page_index: int = 0) -> dict:
+ """
+ Sets the locator for the page at the given index to the result
+ of a css query on the page with the given css selector.
+
+ Returns the count of elements currently matched by the locator.
+ """
+ locator = self.pages[page_index].locator(f"css={selector}")
+ count = await locator.count()
+ if count == 0:
+ return {"status": "error", "message": f"No elements found for selector: {selector}"}
+ else:
+ self.locators[page_index] = locator
+ return {"status": "ok", "count": count}
+
+ async def locate_by_xpath(selector: str, page_index: int = 0) -> dict:
+ """
+ Sets the locator for the page at the given index to the result
+ of a xpath query on the page with the given xpath selector.
+
+ Returns the count of elements currently matched by the locator.
+ """
+ locator = self.pages[page_index].locator(f"xpath={selector}")
+ count = await locator.count()
+ if count == 0:
+ return {"status": "error", "message": f"No elements found for selector: {selector}"}
+ else:
+ self.locators[page_index] = locator
+ return {"status": "ok", "count": count}
+
+ async def locate_by_role(role: str, page_index: int = 0) -> dict:
+ """
+ Sets the locator for the page at the given index to the result
+ of get_by_role with the given role on the current page.
+
+ Returns the count of elements currently matched by the locator.
+ """
+ locator = self.pages[page_index].get_by_role(role)
+ count = await locator.count()
+ if count == 0:
+ return {"status": "error", "message": f"No elements found for role: {role}"}
+ else:
+ self.locators[page_index] = locator
+ return {"status": "ok", "count": await locator.count()}
+
+ async def new_tab() -> dict:
+ """
+ Opens a new tab in the current context.
+ """
+ new_page = await self.context.new_page()
+ self.pages.append(new_page)
+ self.locators.append(new_page)
+ await new_page.bring_to_front()
+ return {"status": "ok", "page_index": len(self.pages) - 1}
+
+ async def search(query: str, page_index: int = 0) -> dict:
+ """
+ Performs a search for the given query on the page at the given index.
+ """
+ page = self.pages[page_index]
+
+ locators = [
+ (page.get_by_label, re.compile(r"search", re.IGNORECASE)),
+ (page.locator, "input[name='q']"),
+ (page.locator, "textarea[name='q']"),
+ (page.locator, "input[type='search']"),
+ (page.locator, "textarea[type='search']"),
+ (page.get_by_placeholder, re.compile(r"search", re.IGNORECASE)),
+ (page.get_by_role, "searchbox"),
+ ]
+
+ for func, selector in locators:
+ input_locator = func(selector)
+ count = await input_locator.count()
+ if count == 1:
+ await input_locator.fill(query)
+ await input_locator.press('Enter')
+ await page.wait_for_load_state("domcontentloaded")
+ await asyncio.sleep(1)
+ return {"status": "ok", "message": "Search submitted"}
+
+ return {"status": "error", "message": "Search input not found"}
+
+ async def snapshot_full(page_index: int = 0) -> dict:
+ """
+ Takes a snapshot of the full page at the given index.
+
+ Returns a list of all elements on the page along with
+ details about each element.
+ """
+ page = self.pages[page_index]
+ snapshot = await page.evaluate("""
+ () => {
+ function getRole(el) {
+ return el.getAttribute('role') || null;
+ }
+
+ function isVisible(el) {
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ function isInteractable(el) {
+ const tag = el.tagName.toLowerCase();
+ return ['button', 'a', 'input', 'select', 'textarea', 'label'].includes(tag) || getRole(el);
+ }
+
+ function getElementInfo(el) {
+ const rect = el.getBoundingClientRect();
+ return {
+ tag: el.tagName.toLowerCase(),
+ role: getRole(el),
+ text: el.innerText || el.value || '',
+ name: el.getAttribute('name') || '',
+ type: el.getAttribute('type') || '',
+ ariaLabel: el.getAttribute('aria-label') || '',
+ id: el.id || '',
+ class: el.className || '',
+ boundingBox: {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height
+ }
+ };
+ }
+
+ const elements = Array.from(document.querySelectorAll('*'));
+ return elements
+ .filter(el => isVisible(el) && isInteractable(el))
+ .map(el => getElementInfo(el));
+ }
+ """)
+ return {"status": "ok", "snapshot": snapshot}
+
+ async def snapshot_main(page_index: int = 0) -> dict:
+ """
+ Takes a snapshot of the main content of the page
+ at the given index.
+
+ Returns a list of all elements under the 'main' element
+ on the page along with details about each element, or
+ and empty list if no 'main' element is found.
+ """
+ page = self.pages[page_index]
+ snapshot = await page.evaluate("""
+ () => {
+ function getRole(el) {
+ return el.getAttribute('role') || null;
+ }
+
+ function isVisible(el) {
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ function isInteractable(el) {
+ const tag = el.tagName.toLowerCase();
+ return ['button', 'a', 'input', 'select', 'textarea', 'label'].includes(tag) || getRole(el);
+ }
+
+ function getElementInfo(el) {
+ const rect = el.getBoundingClientRect();
+ return {
+ tag: el.tagName.toLowerCase(),
+ role: getRole(el),
+ text: el.innerText || el.value || '',
+ name: el.getAttribute('name') || '',
+ type: el.getAttribute('type') || '',
+ ariaLabel: el.getAttribute('aria-label') || '',
+ id: el.id || '',
+ class: el.className || '',
+ boundingBox: {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height
+ }
+ };
+ }
+
+ let main = document.querySelector("main, #main, [role=main]")
+ if (!main) return [];
+
+ const elements = Array.from(main.querySelectorAll('*'));
+ return elements
+ .filter(el => isVisible(el) && isInteractable(el))
+ .map(el => getElementInfo(el));
+ }
+ """)
+ if not snapshot:
+ return {"status": "error", "message": "No main content found"}
+ else:
+ return {"status": "ok", "snapshot": snapshot}
+
+ async def text_contents(page_index: int = 0) -> dict:
+ """
+ Gets an array of the text contents of the elements
+ matched by the locator for the page at the given index.
+ """
+ texts = await self.locators[page_index].all_text_contents()
+ return {"status": "ok", "texts": texts}
+
+ async def title(limit: int, page_index: int = 0) -> dict:
+ """
+ Get the title of the current page.
+ """
+ title = await self.pages[page_index].title()
+ return {"status": "ok", "title": title}
+
+ return [
+ Tool(bring_to_front),
+ Tool(click),
+ Tool(click_nth),
+ Tool(current_iso8601_utc_time),
+ Tool(enter),
+ Tool(fill),
+ Tool(fill_active),
+ Tool(filter),
+ Tool(goto),
+ Tool(locate),
+ Tool(locate_by_css),
+ Tool(locate_by_xpath),
+ Tool(locate_by_role),
+ Tool(new_tab),
+ Tool(search),
+ Tool(snapshot_full),
+ Tool(snapshot_main),
+ Tool(text_contents),
+ Tool(title)
+ ]
+
+ @staticmethod
+ async def start(browser):
+ context = await browser.new_context()
+ return BrowserSession(browser, context)
+
+
+async def main(node):
+ browser = await Browser.start("ws://localhost:3000/browser")
+ browser_session = await BrowserSession.start(browser)
+ tools = browser_session.tools()
+
+ await Agent.start(
+ node=node,
+ name="jack",
+ model=Model("nova-pro-v1"),
+ tools=tools,
+ instructions="""
+ You are a web browsing agent.
+ You are given a set of tools to drive a web browser and
+ to interact with web pages.
+
+ Your primary means of getting information from a page is
+ through the snapshotss. When finding information on a page
+ prefer snapshot_main and then snapshow_full over locators. However
+ because snapshots are long, do not put snapshots directly in a response.
+
+ For any search request, attempt fill_active then enter before resorting
+ to locators, fill, and clicks.
+
+ For any request you should first break the request down into a series
+ of steps that you can accomplish with the tools you have available.
+ """
+ )
+
+
+Node.start(main, llm_debug=True)
diff --git a/examples/013/images/main/requirements.txt b/examples/013/images/main/requirements.txt
new file mode 100644
index 00000000000..fa4bf8fc0c8
--- /dev/null
+++ b/examples/013/images/main/requirements.txt
@@ -0,0 +1 @@
+playwright==1.52.0
\ No newline at end of file
diff --git a/examples/013/node_server/package-lock.json b/examples/013/node_server/package-lock.json
new file mode 100644
index 00000000000..a73731cf669
--- /dev/null
+++ b/examples/013/node_server/package-lock.json
@@ -0,0 +1,56 @@
+{
+ "name": "headed-playwright-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "headed-playwright-server",
+ "version": "1.0.0",
+ "dependencies": {
+ "playwright": "1.52.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
+ "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
+ "dependencies": {
+ "playwright-core": "1.52.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
+ "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/examples/013/node_server/package.json b/examples/013/node_server/package.json
new file mode 100644
index 00000000000..2460d399943
--- /dev/null
+++ b/examples/013/node_server/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "headed-playwright-server",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "Local Playwright WebSocket server with headed Chromium",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "playwright": "1.52.0"
+ }
+}
\ No newline at end of file
diff --git a/examples/013/node_server/server.js b/examples/013/node_server/server.js
new file mode 100644
index 00000000000..e65d668d234
--- /dev/null
+++ b/examples/013/node_server/server.js
@@ -0,0 +1,24 @@
+import { chromium } from 'playwright';
+import { URL } from 'url';
+
+(async () => {
+ const browserServer = await chromium.launchServer({
+ headless: false,
+ wsPath: 'browser',
+ });
+
+ const wsEndpoint = browserServer.wsEndpoint();
+ const url = new URL(wsEndpoint);
+ console.log(`ockam zone outlet --relay browser --to localhost:${url.port}`);
+ console.log(`WebSocket endpoint: ${wsEndpoint}`);
+
+ console.log("Press Ctrl+C to exit.");
+ process.stdin.resume();
+
+ process.on('SIGINT', () => {
+ console.log('Ctrl+C detected. Exiting.');
+ process.exit(0);
+ });
+
+
+})();
\ No newline at end of file
diff --git a/examples/013/ockam.yaml b/examples/013/ockam.yaml
new file mode 100644
index 00000000000..3ce89e75dc9
--- /dev/null
+++ b/examples/013/ockam.yaml
@@ -0,0 +1,12 @@
+name: example013
+pods:
+ - name: main-pod
+ size: big
+ containers:
+ - name: main
+ image: main
+
+ portals:
+ inlets:
+ - from: 3000
+ name: browser