diff --git a/browsers/playwright-execution.mdx b/browsers/playwright-execution.mdx
index 9b28602..56de9a7 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,87 @@ if not response.success:
```
+## 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