From c94de293140fb66d3f9dd8276f8e030b59c02473 Mon Sep 17 00:00:00 2001 From: Max Fritzhand Date: Thu, 30 Apr 2026 01:12:34 -0400 Subject: [PATCH 1/2] docs(browsers): document code constraints and multi-step composition for playwright execution Add three sections to playwright-execution.mdx surfacing constraints and patterns that emerged while building an LLM-driven Playwright goal runner against Kernel's playwright.execute API: - Code constraints: rules for code passed to `code:` (no imports, no redeclaring page/context/browser, no IIFE wrapper, must end with return). Important when an LLM generates the snippet. - Composing multiple executes against one session: each call gets a fresh page/context/browser, but session state (cookies, storage, current URL) persists. Re-goto defensively between calls. - Recovering from empty results: response.success can be true with a null/empty result when a selector misses; pattern for detecting and retrying with stronger waits. All examples include both TypeScript and Python. --- browsers/playwright-execution.mdx | 215 ++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/browsers/playwright-execution.mdx b/browsers/playwright-execution.mdx index 9b28602..6fb2f71 100644 --- a/browsers/playwright-execution.mdx +++ b/browsers/playwright-execution.mdx @@ -73,6 +73,66 @@ Your code has access to these Playwright objects: - `context` - The browser context - `browser` - The browser instance +## Code constraints + +The `code` you pass to `playwright.execute` runs as the body of an async function with `page`, `context`, and `browser` already in scope. Code that tries to set up its own Playwright environment will fail. + + + Code passed to `playwright.execute` must follow these rules: + + - **Don't import Playwright.** No `require('playwright')`, `import { chromium } from 'playwright'`, or `await chromium.launch()`. The browser is already running. + - **Don't redeclare `page`, `context`, or `browser`.** They're injected into scope. Lines like `const page = await browser.newPage()` shadow the injection and break the call. + - **Don't wrap the code in a function.** Write the body directly. Top-level `await` is supported. + - **End with `return`.** Anything you want back in `response.result` must come from a `return` statement. + + +When you generate `code` with an LLM (for example, in an agent), include these rules in your prompt. Models default to producing standalone Playwright scripts that won't run as-is. + + +```typescript Typescript/Javascript +// Won't work — the code string imports and re-creates the browser +const wontWork = ` + import { chromium } from 'playwright'; + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto('https://www.onkernel.com'); + return await page.title(); +`; + +// Works — uses the injected variables directly +const works = ` + await page.goto('https://www.onkernel.com'); + return await page.title(); +`; + +const response = await kernel.browsers.playwright.execute(sessionId, { + code: works, +}); +``` + +```python Python +# Won't work — the code string imports and re-creates the browser +wont_work = """ + import { chromium } from 'playwright'; + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto('https://www.onkernel.com'); + return await page.title(); +""" + +# Works — uses the injected variables directly +works = """ + await page.goto('https://www.onkernel.com'); + return await page.title(); +""" + +response = kernel.browsers.playwright.execute( + id=session_id, + code=works, +) +``` + + ## Returning values Use a `return` statement to send data back from your code: @@ -176,6 +236,161 @@ if not response.success: ``` +## Composing multiple executes against one session + +Each call to `playwright.execute` gets a fresh runtime context — `page`, `context`, and `browser` are new variables — but the underlying browser session is the same. Cookies, `localStorage`, and the navigated URL persist across calls; the JavaScript values you set inside `code` do not. + + + Because each `code` runs in a fresh scope, you can't pass values directly from one execute to the next. Use `return` to surface data, then feed it into the next call's `code` string. For navigation state, treat each call as if the page might already be somewhere — re-`goto` defensively when you need a known starting point. + + +This three-step example reuses one session: navigate, extract, screenshot. + + +```typescript Typescript/Javascript +const kernelBrowser = await kernel.browsers.create(); +const sessionId = kernelBrowser.session_id; + +// Step 1: navigate. Cookies and storage from any subsequent calls will see this state. +await kernel.browsers.playwright.execute(sessionId, { + code: `await page.goto('https://news.ycombinator.com');`, +}); + +// Step 2: separate execute, fresh `page`/`context`/`browser`. Re-goto when you need +// a known starting URL — the runtime doesn't carry over from Step 1. +const titles = await kernel.browsers.playwright.execute(sessionId, { + code: ` + await page.goto('https://news.ycombinator.com'); + return await page.$$eval('.titleline > a', links => + links.slice(0, 5).map(link => link.textContent) + ); + `, +}); + +// Step 3: screenshot the same session, returning base64 (Buffers don't survive `return`). +const shot = await kernel.browsers.playwright.execute(sessionId, { + code: ` + return (await page.screenshot({ type: 'png', fullPage: true })).toString('base64'); + `, +}); + +console.log(titles.result); +``` + +```python Python +kernel_browser = kernel.browsers.create() +session_id = kernel_browser.session_id + +# Step 1: navigate. Cookies and storage from any subsequent calls will see this state. +kernel.browsers.playwright.execute( + id=session_id, + code="await page.goto('https://news.ycombinator.com');", +) + +# Step 2: separate execute, fresh page/context/browser. Re-goto for a known starting URL. +titles = kernel.browsers.playwright.execute( + id=session_id, + code=""" + await page.goto('https://news.ycombinator.com'); + return await page.$$eval('.titleline > a', links => + links.slice(0, 5).map(link => link.textContent) + ); + """, +) + +# Step 3: screenshot the same session, returning base64 (Buffers don't survive return). +shot = kernel.browsers.playwright.execute( + id=session_id, + code=""" + return (await page.screenshot({ type: 'png', fullPage: true })).toString('base64'); + """, +) + +print(titles.result) +``` + + +## Recovering from empty results + +`response.success` is `true` even when your code returns `null`, `undefined`, or an empty array — the execute itself didn't throw, but the selector you targeted might not have matched anything. For agentic flows, treat empty results as a recoverable signal alongside thrown errors. + + +```typescript Typescript/Javascript +function isEmptyResult(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim() === ''; + if (Array.isArray(value)) return value.length === 0 || value.every(isEmptyResult); + if (typeof value === 'object') { + const values = Object.values(value as Record); + return values.length === 0 || values.every(isEmptyResult); + } + return false; +} + +const first = await kernel.browsers.playwright.execute(sessionId, { + code: ` + await page.goto('https://news.ycombinator.com'); + return await page.$$eval('.titleline > a', links => + links.map(l => l.textContent) + ); + `, +}); + +if (first.success && isEmptyResult(first.result)) { + // Retry with an explicit wait — the page might not have hydrated yet. + const retry = await kernel.browsers.playwright.execute(sessionId, { + code: ` + await page.goto('https://news.ycombinator.com'); + await page.waitForSelector('.titleline > a', { timeout: 15000 }); + return await page.$$eval('.titleline > a', links => + links.map(l => l.textContent) + ); + `, + }); + console.log(retry.result); +} +``` + +```python Python +def is_empty_result(value): + if value is None: + return True + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, list): + return len(value) == 0 or all(is_empty_result(v) for v in value) + if isinstance(value, dict): + return len(value) == 0 or all(is_empty_result(v) for v in value.values()) + return False + +first = kernel.browsers.playwright.execute( + id=session_id, + code=""" + await page.goto('https://news.ycombinator.com'); + return await page.$$eval('.titleline > a', links => + links.map(l => l.textContent) + ); + """, +) + +if first.success and is_empty_result(first.result): + # Retry with an explicit wait — the page might not have hydrated yet. + retry = kernel.browsers.playwright.execute( + id=session_id, + code=""" + await page.goto('https://news.ycombinator.com'); + await page.waitForSelector('.titleline > a', { timeout: 15000 }); + return await page.$$eval('.titleline > a', links => + links.map(l => l.textContent) + ); + """, + ) + print(retry.result) +``` + + +When you generate selectors with an LLM, feed the previous (empty) result back into the prompt so the model can refine its approach. + ## Use cases ### Web scraping From d5fb58fd0e9080b0a8b21ac1071ac8dee8668222 Mon Sep 17 00:00:00 2001 From: Max Fritzhand Date: Thu, 30 Apr 2026 01:38:09 -0400 Subject: [PATCH 2/2] docs(browsers): drop multi-step composition section pending state-model verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "Composing multiple executes against one session" section added in c94de29. The section claimed cookies/localStorage/URL persist across playwright.execute calls within one session, but that behavior wasn't verified against the API — the existing doc's "fresh context each time" language suggests Kernel may create a new BrowserContext per execute, which would invert the claim. Code constraints and empty-result recovery sections remain — both are independent of the state model and stand on their own. --- browsers/playwright-execution.mdx | 74 ------------------------------- 1 file changed, 74 deletions(-) diff --git a/browsers/playwright-execution.mdx b/browsers/playwright-execution.mdx index 6fb2f71..56de9a7 100644 --- a/browsers/playwright-execution.mdx +++ b/browsers/playwright-execution.mdx @@ -236,80 +236,6 @@ if not response.success: ``` -## Composing multiple executes against one session - -Each call to `playwright.execute` gets a fresh runtime context — `page`, `context`, and `browser` are new variables — but the underlying browser session is the same. Cookies, `localStorage`, and the navigated URL persist across calls; the JavaScript values you set inside `code` do not. - - - Because each `code` runs in a fresh scope, you can't pass values directly from one execute to the next. Use `return` to surface data, then feed it into the next call's `code` string. For navigation state, treat each call as if the page might already be somewhere — re-`goto` defensively when you need a known starting point. - - -This three-step example reuses one session: navigate, extract, screenshot. - - -```typescript Typescript/Javascript -const kernelBrowser = await kernel.browsers.create(); -const sessionId = kernelBrowser.session_id; - -// Step 1: navigate. Cookies and storage from any subsequent calls will see this state. -await kernel.browsers.playwright.execute(sessionId, { - code: `await page.goto('https://news.ycombinator.com');`, -}); - -// Step 2: separate execute, fresh `page`/`context`/`browser`. Re-goto when you need -// a known starting URL — the runtime doesn't carry over from Step 1. -const titles = await kernel.browsers.playwright.execute(sessionId, { - code: ` - await page.goto('https://news.ycombinator.com'); - return await page.$$eval('.titleline > a', links => - links.slice(0, 5).map(link => link.textContent) - ); - `, -}); - -// Step 3: screenshot the same session, returning base64 (Buffers don't survive `return`). -const shot = await kernel.browsers.playwright.execute(sessionId, { - code: ` - return (await page.screenshot({ type: 'png', fullPage: true })).toString('base64'); - `, -}); - -console.log(titles.result); -``` - -```python Python -kernel_browser = kernel.browsers.create() -session_id = kernel_browser.session_id - -# Step 1: navigate. Cookies and storage from any subsequent calls will see this state. -kernel.browsers.playwright.execute( - id=session_id, - code="await page.goto('https://news.ycombinator.com');", -) - -# Step 2: separate execute, fresh page/context/browser. Re-goto for a known starting URL. -titles = kernel.browsers.playwright.execute( - id=session_id, - code=""" - await page.goto('https://news.ycombinator.com'); - return await page.$$eval('.titleline > a', links => - links.slice(0, 5).map(link => link.textContent) - ); - """, -) - -# Step 3: screenshot the same session, returning base64 (Buffers don't survive return). -shot = kernel.browsers.playwright.execute( - id=session_id, - code=""" - return (await page.screenshot({ type: 'png', fullPage: true })).toString('base64'); - """, -) - -print(titles.result) -``` - - ## Recovering from empty results `response.success` is `true` even when your code returns `null`, `undefined`, or an empty array — the execute itself didn't throw, but the selector you targeted might not have matched anything. For agentic flows, treat empty results as a recoverable signal alongside thrown errors.