Script Mode: Full Playwright Without the Infrastructure
When steps mode hits its limits, script mode gives you full Playwright—loops, conditionals, error recovery—in a sandboxed, ephemeral browser.
Steps Mode Is Great Until It Isn't
You can fill forms, click buttons, take screenshots—all from a clean JSON array. An LLM can generate it. A human can read it. It's the right tool for 80% of browser automation.
But then you need a loop. Or a conditional. Or error recovery. And suddenly you're fighting the format instead of solving the problem.
That's what script mode is for.
The Progression
Riddle's /v1/run endpoint has four input modes, each adding capability:
url — Point and shoot
One URL, one screenshot.
{ "url": "https://example.com" }urls — Batch screenshots
Same idea, multiple pages.
{ "urls": ["https://example.com", "https://example.com/pricing"] }steps — JSON workflows
Click, fill, wait, assert. Perfect for AI agents generating automation on the fly.
{
"steps": [
{ "goto": "https://example.com/login" },
{ "fill": { "selector": "#email", "value": "user@test.com" } },
{ "click": "button[type=submit]" },
{ "waitForUrl": "/dashboard" },
{ "screenshot": "dashboard" }
]
}script — Full Playwright
Loops, conditionals, try/catch, data transformation. Everything.
{
"script": "await page.goto('https://example.com'); await saveScreenshot('home');"
}The first three are declarative. Script mode is imperative. That's the difference, and it matters when your automation logic stops fitting in a flat list.
What Script Mode Unlocks
1. Scraping Paginated Content with Dynamic Stop Conditions
Steps mode can click a “Next” button. But how many times? What if the last page has 3 items instead of 20? What if there's no “Next” button on the final page?
await page.goto('https://news.ycombinator.com');
const allStories = [];
let pageNum = 0;
while (pageNum < 5) {
const stories = await page.$$eval('.titleline > a', links =>
links.map(a => ({ title: a.textContent, url: a.href }))
);
allStories.push(...stories);
console.log(`Page ${pageNum + 1}: found ${stories.length} stories`);
const moreLink = await page.$('a.morelink');
if (!moreLink) break;
await moreLink.click();
await page.waitForLoadState('networkidle');
pageNum++;
}
console.log(`Total: ${allStories.length} stories`);
await saveJson('stories', allStories);
await saveScreenshot('final-page');One API call. Variable number of pages. Structured JSON output. Try doing that with a fixed step array.
2. A/B Test Detection
Load a page multiple times. Compare what you get. This is fundamentally impossible in steps mode—you can't loop, you can't compare across iterations, you can't aggregate.
const url = 'https://example.com/landing';
const variants = [];
for (let i = 0; i < 5; i++) {
// Fresh context each time — clear cookies
await page.context().clearCookies();
await page.goto(url, { waitUntil: 'networkidle' });
const variant = await page.evaluate(() => {
const hero = document.querySelector('h1')?.textContent;
const cta = document.querySelector('.cta-button')?.textContent;
const color = getComputedStyle(
document.querySelector('.cta-button')
).backgroundColor;
return { hero, cta, color };
});
variants.push(variant);
await saveScreenshot(`variant-${i}`);
}
const unique = [...new Set(variants.map(v => JSON.stringify(v)))];
console.log(`Found ${unique.length} unique variant(s)`);
await saveJson('ab-test-results', { variants, uniqueCount: unique.length });You get back 5 screenshots and a JSON summary telling you if the site is running an A/B test. One request.
3. Multi-Step Checkout with Error Handling
Real checkout flows break. Out-of-stock popups, address validation errors, payment gateway timeouts.
Steps mode has assert with onFail: abort, but that's binary—it
worked or it didn't. Script mode lets you recover.
await page.goto('https://store.example.com/cart');
// Check if cart is empty
const isEmpty = await page.$('.empty-cart-message');
if (isEmpty) {
console.log('Cart is empty, nothing to check out');
await saveScreenshot('empty-cart');
await saveJson('result', { status: 'empty' });
return;
}
try {
await page.click('button.checkout');
await page.waitForSelector('#shipping-form', { timeout: 5000 });
} catch (e) {
// Maybe there's an interstitial or login wall
console.log('Checkout redirect interrupted, checking for login');
const loginForm = await page.$('#login-form');
if (loginForm) {
await page.fill('#email', 'buyer@example.com');
await page.fill('#password', 'test-password');
await page.click('#login-submit');
await page.waitForSelector('#shipping-form', { timeout: 10000 });
} else {
await saveScreenshot('unexpected-state');
await saveJson('result', { status: 'error', reason: 'unexpected page' });
return;
}
}
await page.fill('#address', '123 Test St');
await page.fill('#city', 'Portland');
await page.selectOption('#state', 'OR');
await page.fill('#zip', '97201');
await page.click('#continue-to-payment');
await saveScreenshot('payment-page');
await saveJson('result', { status: 'reached_payment' });The key insight: the script adapts to what it finds on the page. That's the difference between automation and a macro.
4. Data Extraction + Transformation + Screenshot in One Job
Scrape a table, clean the data, compute aggregates, screenshot the source—all in one call.
await page.goto('https://example.com/pricing');
await page.waitForSelector('.pricing-table');
await saveScreenshot('pricing-page');
// Extract raw pricing data
const plans = await page.$$eval('.plan-card', cards =>
cards.map(card => ({
name: card.querySelector('.plan-name')?.textContent?.trim(),
priceRaw: card.querySelector('.plan-price')?.textContent?.trim(),
features: [...card.querySelectorAll('.feature')]
.map(f => f.textContent.trim())
}))
);
// Transform: parse prices, normalize
const processed = plans.map(plan => {
const match = plan.priceRaw?.match(/\$(\d+)/);
return {
...plan,
priceMonthly: match ? parseInt(match[1]) : null,
featureCount: plan.features.length
};
});
// Compute comparisons
const cheapest = processed.reduce((a, b) =>
(a.priceMonthly || Infinity) < (b.priceMonthly || Infinity) ? a : b
);
await saveJson('pricing', {
plans: processed,
cheapest: cheapest.name,
extractedAt: new Date().toISOString()
});
console.log(`Extracted ${processed.length} plans. Cheapest: ${cheapest.name}`);The response includes both the screenshot and the structured JSON artifact. Your application gets everything it needs from a single API call—no post-processing pipeline.
“But Isn't Running Arbitrary Code Dangerous?”
Fair question. Running user-submitted Playwright code sounds like a security nightmare.
Here's how Riddle handles it: every script job runs in an isolated browser context.
Your code gets a page object and a handful of helper functions
(saveScreenshot, saveJson, console.log).
It doesn't get filesystem access, network access to internal services, or the ability to affect
other jobs. The execution environment is sandboxed and ephemeral—it exists for your job
and is torn down after.
You're not shipping code to a shared server. You're shipping code to a disposable browser that exists for 60 seconds and then ceases to exist.
Script Helpers
Scripts run in a Node.js context with the Playwright page object pre-configured.
You get these helpers:
| Helper | What It Does |
|---|---|
await saveScreenshot('label') | Captures a screenshot, included in the response |
await saveJson('label', data) | Attaches a JSON artifact to the response |
console.log(...) | Logs appear in the response's console array |
await waitForWindow(triggerFn) | Captures popups and new tabs opened by the trigger function |
No imports needed. No setup. The browser is already running, the page is already open
(or you goto where you need).
When to Use Which
Use steps when:
- An AI agent is generating the workflow (JSON is safer than code generation)
- The flow is linear: go here, click this, fill that, screenshot
- You want an LLM to be able to read and modify the automation
- You're building a no-code/low-code tool on top of Riddle
Use script when:
- You need loops or conditionals
- The number of actions isn't known ahead of time
- You need error recovery (try/catch)
- You're doing data extraction with transformation
- You need to compare results across multiple page loads
- You're a developer writing automation, not an LLM generating it
Steps mode is a remote control. Script mode is a programmer.
Both run on the same infrastructure, cost the same per job, and return the same response format. The only difference is what you can express.
Get Started
Script mode is available now on all Riddle plans. 5 minutes free. No credit card.