Define and run reusable browser automation workflows.
workflow() factory is the entry point for every Libretto automation. You wrap your Playwright logic in a typed handler, define Zod schemas for the workflow input and output, export the result, and the CLI (or your own runner) takes care of launching a browser, validating input, wiring up context, and invoking your handler.workflow()LibrettoWorkflow from input/output Zod schemas and a handler function.1234567891011121314function workflow< InputSchema extends z.ZodType, OutputSchema extends z.ZodType, >( name: string, schemas: { input: InputSchema; output: OutputSchema; }, handler: LibrettoWorkflowHandler< z.infer<InputSchema>, z.infer<OutputSchema> >, ): LibrettoWorkflow<InputSchema, OutputSchema>;
LibrettoWorkflowContext and typed input, and must return a Promise<Output>. The input type is inferred from schemas.input; the output type is inferred from schemas.output.1234type LibrettoWorkflowHandler<Input, Output> = ( ctx: LibrettoWorkflowContext, input: Input, ) => Promise<Output>;
LibrettoWorkflowContextsessionstringpagePagePage instance ready for navigation and interaction.123456789101112131415161718192021222324252627282930import { workflow, launchBrowser, pause } from "libretto"; import { z } from "zod"; const inputSchema = z.object({ query: z.string(), }); const outputSchema = z.array(z.string()); export default workflow( "main", { input: inputSchema, output: outputSchema, startUrl: "https://example.com/search", }, async (ctx, input) => { const { page } = ctx; await page.fill("#query", input.query); await page.click("#submit"); await page.waitForSelector(".results"); const results = await page.$$eval(".result-item", (els) => els.map((el) => el.textContent ?? ""), ); return results; }, );
npx libretto run. The file must have a default-exported workflow():1npx libretto run ./my-workflow.ts
tsx, launches a Chromium browser, and calls your handler with the session context it constructs.startUrl on every workflow. It is the entry URL Libretto opens before the handler runs when Libretto launches the browser (local Chromium or a provider session). Kernel and Libretto Cloud preload it at browser create time (before CDP attach). Browserbase and Steel open it immediately after CDP connect. run --cdp attaches to an existing page and does not navigate to startUrl.12345678910111213export default workflow( "bookMarriott", { input: z.object({ checkIn: z.string() }), output: z.object({ confirmation: z.string() }), startUrl: "https://www.marriott.com/", gpu: true, viewport: { width: 1440, height: 900 }, }, async (ctx, input) => { // Browser is already on startUrl — do not page.goto the same URL here. }, );
path startUrlstringstart_url at session create time and does not navigate
again after connect. run --cdp leaves the existing page URL unchanged.
Prefer this over an initial page.goto in the handler for launch and provider
runs.path gpubooleanpath viewport{ width: number; height: number }--viewport on the CLI.libretto cloud jobs create --start-url ... --gpu --viewport WxH (or the
matching schedule flags) when a hosted run needs them.launchBrowser()BrowserSession. Use this when you want to run workflows programmatically outside the CLI, or when you need direct access to the Browser or BrowserContext objects.1async function launchBrowser(args: LaunchBrowserArgs): Promise<BrowserSession>;
LaunchBrowserArgspath sessionNamestringrequired.libretto/sessions/.path headlessbooleanfalse (visible
window).path viewport{ width: number; height: number }{ width: 1366, height: 768 }.path storageStatePathstringBrowserSession return valuebrowserBrowserBrowser instance.contextBrowserContextBrowserContext created for this session.pagePagePage opened in the context. Pass this to your workflow handler.debugPortnumbermetadataPathstringlaunchBrowser.close() => Promise<void>1234567891011121314151617181920import { launchBrowser } from "libretto"; import main from "./my-workflow"; const session = await launchBrowser({ sessionName: "my-run", headless: true, }); try { const result = await main.run( { session: "my-run", page: session.page, }, { query: "hello world" }, ); console.log(result); } finally { await session.close(); }
pause()1async function pause(session: string): Promise<void>;
path sessionstringrequiredpause() is a no-op when NODE_ENV === "production". It is safe to leave
pause() calls in your code without worrying about them blocking production
runs.pause() writes a .paused signal file and polls for a .resume signal. Use the Libretto CLI to send the resume signal:1npx libretto resume --session <session-name>
1234567891011121314151617181920import { workflow, pause } from "libretto"; import { z } from "zod"; export default workflow( "main", { input: z.object({ id: z.string() }), output: z.void(), }, async (ctx, input) => { const { page, session } = ctx; await page.goto(`https://example.com/items/${input.id}`); // Pause here to inspect the page before continuing await pause(session); await page.click("#confirm"); }, );