# Libretto

> Toolkit for building, debugging, and maintaining browser automations.

This file contains the full content of all documentation pages. For a compact index, see [llms.txt](https://libretto-docs.vercel.app/docs/llms.txt). To download all pages as a zip, use [docs.zip](https://libretto-docs.vercel.app/docs/docs.zip).

---
title: Quickstart
url: "https://libretto-docs.vercel.app/docs/get-started/quickstart.md"
description: Install Libretto into the Node.js package that contains your browser automations. Run Libretto commands with npx libretto from that package. If you are...
---

> Install Libretto and run your first workflow end to end.

## Agent prompt

> Fetch and follow [https://libretto.sh/start.md](https://libretto.sh/start.md) to set up Libretto and create a new browser automation.

## Manual

Install Libretto into the Node.js package that contains your browser automations. Run Libretto commands with `npx libretto` from that package.

<Steps>
  <Step title="Choose a setup path">
    Decide whether you want to create a new Libretto package or add Libretto to an existing Node.js package.

    If you create a new package, choose where it should live and what it should be named. If you use an existing package, switch into that package directory before installing Libretto.
  </Step>

  <Step title="Option A: create a new Libretto package">
    From the directory where the new package should be created:

    ```bash theme={null}
    npm create libretto@latest
    ```

    You can pass a package name directly:

    ```bash theme={null}
    npm create libretto@latest my-automations
    ```

    This scaffolds a package, installs dependencies, runs setup, and downloads Chromium.
  </Step>

  <Step title="Option B: add Libretto to an existing package">
    From the existing package directory, add Libretto as a dependency and run setup:

    ```bash theme={null}
    npm install libretto zod
    npx libretto setup
    ```

    Then create a workflow file and run it with `npx libretto run <path> --headless`.
  </Step>

  <Step title="Create and run a smoke workflow">
    Create `src/workflows/scrape-page.ts` in the package you set up:

    ```ts src/workflows/scrape-page.ts
    import { workflow } from "libretto";
    import { z } from "zod";

    export default workflow(
      "scrape-page",
      {
        input: z.object({}),
        output: z.object({
          title: z.string(),
        }),
        startUrl: "https://example.com",
      },
      async ({ page }) => {
        const title = await page.title();
        console.log(`Page title: ${title}`);

        return { title };
      },
    );
    ```

    Run it headless:

    ```bash theme={null}
    npx libretto run src/workflows/scrape-page.ts --headless
    ```

    This confirms your local setup works and shows the basic run loop.
  </Step>
</Steps>

<Card title="Next: First workflow" icon="arrow-right" href="/get-started/first-workflow">
  Expand the smoke workflow into a minimal real workflow you can iterate on.
</Card>

## Compare browser automation tools

If you are choosing between Libretto and another browser automation tool, these comparisons explain the production trade-offs:

<CardGroup cols={3}>
  <Card title="Libretto vs Browser Use" icon="lucide:monitor" href="https://libretto.sh/vs/browser-use">
    Compare deterministic scripts with a runtime browser agent.
  </Card>

  <Card title="Libretto vs Stagehand" icon="wand-magic-sparkles" href="https://libretto.sh/vs/stagehand">
    Compare generated workflows with act(), observe(), and runtime inference.
  </Card>

  <Card title="Libretto vs Playwright codegen" icon="code" href="https://libretto.sh/vs/playwright-codegen">
    Compare browser recording with agent-built workflows and validation loops.
  </Card>
</CardGroup>


---
title: First workflow
url: "https://libretto-docs.vercel.app/docs/get-started/first-workflow.md"
---

> Author a minimal workflow, run it, and inspect the result.

## Agent prompt

> Ask me what website and workflow I want to demonstrate. If I’m not sure, give me a few examples of simple workflows I could do. Then open a Libretto browser session for the site I choose. After I finish, create a Libretto workflow.

## Manual

<Steps>
  <Step title="Create a workflow file">
    Create `src/workflows/scrape-page.ts`:

    ```ts src/workflows/scrape-page.ts
    import { workflow } from "libretto";
    import { z } from "zod";

    export default workflow(
      "scrape-page",
      {
        input: z.object({}),
        output: z.object({
          title: z.string(),
        }),
        startUrl: "https://example.com",
      },
      async ({ page }) => {
        const title = await page.title();
        console.log(`Page title: ${title}`);

        return { title };
      },
    );
    ```
  </Step>

  <Step title="Run it locally">
    ```bash theme={null}
    npx libretto run src/workflows/scrape-page.ts
    ```

    The object you `return` is the workflow output. On a hosted run, that output is `result` on the job. See [Get the workflow output](/libretto-cloud-api/jobs-and-logs#get-the-workflow-output).
  </Step>

  <Step title="Inspect page state when needed">
    When a step fails or behaves unexpectedly, open a named debug session first:

    ```bash theme={null}
    npx libretto open https://example.com --session debug-example
    ```

    Then inspect and prototype with the same session:

    ```bash theme={null}
    npx libretto snapshot --session debug-example
    npx libretto exec --session debug-example "await page.url()"
    ```

    When the prototype works, move the logic back into the workflow file and run again.
  </Step>
</Steps>

<Card title="Next: Deploying" icon="arrow-right" href="/get-started/deploying">
  Choose where to run workflows in production: Libretto Cloud Hosting or an alternative provider.
</Card>


---
title: Deploying
url: "https://libretto-docs.vercel.app/docs/get-started/deploying.md"
description: "Libretto has two production paths: Start here if you want the shortest path from local workflow to hosted runs. Use this if you want a provider other than..."
---

> Pick a production path and deploy.

## Agent prompt

> Help me deploy my Libretto workflow and choose the right production path.
>
> First, ask me one question: do I want managed hosting (Libretto Cloud Hosting) or an alternative provider?
>
> Then:
>
> * If I choose managed hosting, follow the Libretto Cloud Hosting docs to authenticate, deploy, and run a hosted workflow.
> * If I choose an alternative provider, use the alternative providers docs and ask whether I want Kernel, Browserbase, Steel, AWS, or GCP, then guide me through that path.
>
> After setup, provide:
>
> 1. commands executed
> 2. files/config edited
> 3. deployment/run status
> 4. next recommended docs page
>
> Do not make unrelated changes.

## Manual

Libretto has two production paths:

* **Libretto Cloud Hosting**: managed infrastructure, fastest path to production.
* **Alternative providers**: run the browser on Kernel, Browserbase, or Steel, or self-host the workflow runtime on AWS or GCP.

## Option A: Libretto Cloud Hosting (managed)

Start here if you want the shortest path from local workflow to hosted runs.

* [Cloud overview](/libretto-cloud-hosting/overview)
* [Cloud authentication](/libretto-cloud-hosting/authentication)
* [Cloud deployments](/libretto-cloud-hosting/deployments)

## Option B: Alternative providers

Use this if you want a provider other than Libretto Cloud Hosting.

* [Alternative providers overview](/alternative-providers/overview)
* [Kernel](/alternative-providers/kernel)
* [Browserbase](/alternative-providers/browserbase)
* [Steel](/alternative-providers/steel)
* [Deploy on GCP](/alternative-providers/gcp)
* [Deploy on AWS](/alternative-providers/aws)

## After deployment

To read the object a hosted workflow returned, poll `jobs/get` and use `result`. See [Get the workflow output](/libretto-cloud-api/jobs-and-logs#get-the-workflow-output).

Use these workflow guides to build, debug, and optimize production automations:

<CardGroup cols={2}>
  <Card title="One-shot workflow generation" icon="bolt" href="/guides/one-shot-workflow-generation">
    Give your agent a goal and a URL, then review the generated browser automation.
  </Card>

  <Card title="Interactive workflow building" icon="hand-pointer" href="/guides/interactive-workflow-building">
    Demonstrate a workflow manually and let your agent turn the recorded steps into code.
  </Card>

  <Card title="Debugging workflows" icon="bug" href="/guides/debugging-workflows">
    Reproduce failures, inspect live page state, and fix broken automations.
  </Card>

  <Card title="Convert to network requests" icon="network-wired" href="/guides/convert-to-network-requests">
    Replace UI automation with direct API calls when a site exposes usable network traffic.
  </Card>
</CardGroup>

Other references:

* Conceptual model: [Understand Libretto](/understand-libretto/core-concepts)
* Command details: [CLI reference](/reference/cli/open-and-connect)
* Runtime API details: [Library API](/reference/runtime/workflow)


---
title: One-shot workflow generation
url: "https://libretto-docs.vercel.app/docs/guides/one-shot-workflow-generation.md"
description: "One-shot generation is the fastest way to go from an idea to a working automation. You describe what you want in a single prompt, and your coding agent opens a..."
---

> Give your agent a goal and a URL, and get back a complete browser automation script.

One-shot generation is the fastest way to go from an idea to a working automation. You describe what you want in a single prompt, and your coding agent opens a browser, explores the site, and writes a complete workflow file.

### Steps

<Steps>
  <Step title="Prompt your agent">
    Describe the **site**, the **data or action** you want, and the **input/output shape** you want the workflow to expose as Zod schemas. The more specific you are, the better your results.

    ```text Example prompt
    Use the Libretto skill. Go on https://sfbay.craigslist.org and scrape the first 10 "free stuff" listings for title, location, and link.
    ```

    Include "Use the Libretto skill" so your agent uses Libretto's CLI tools instead of writing a Playwright script from scratch.
  </Step>

  <Step title="Log in when the browser opens (if needed)">
    The agent opens a browser window on your screen. If the site requires authentication, the agent will ask you to log in manually. Do that, then tell the agent you're done.

    ```bash theme={null}
    # What the agent runs behind the scenes
    npx libretto open https://sfbay.craigslist.org --headless --session one-shot-flow
    ```

    <Note>
      If the site needs login, make sure your prompt doesn't say `--headless`. You can mention "I need to log in first" in your prompt to be explicit.
    </Note>
  </Step>

  <Step title="The agent explores the page">
    The agent takes snapshots and tests selectors against the live DOM. You'll see it running commands like `npx libretto snapshot` and `npx libretto exec` in your terminal.

    You don't need to do anything during this phase, but you can redirect the agent if it seems stuck:

    > The listings are in the main content area, not the sidebar.
  </Step>

  <Step title="Review the generated workflow file">
    The agent writes a TypeScript file that exports a `workflow()`. Here's roughly what to expect:

    ```typescript theme={null}
    import { workflow } from "libretto";
    import { z } from "zod";

    const outputSchema = z.object({
      listings: z.array(z.object({
        title: z.string(),
        location: z.string(),
        link: z.string().url(),
      })),
    });

    export default workflow(
      "freeStuff",
      {
        input: z.object({}),
        output: outputSchema,
        startUrl: "https://sfbay.craigslist.org/search/zip",
      },
      async (ctx) => {
        const { page } = ctx;

        await page.waitForSelector(".result-row");

        const rows = await page.locator(".result-row").all();
        const listings = [];

        for (const row of rows.slice(0, 10)) {
          const title = await row.locator(".result-title").textContent();
          const link = await row.locator("a").getAttribute("href");
          // ... extract location
        }

        return { listings };
      },
    );
    ```

    Things to check:

    * **`startUrl`** is set to the workflow's entry page
    * **Input/output schemas** match what you asked for
    * **Selectors** look reasonable (class names, data attributes, or ARIA roles, not brittle positional selectors)
    * **Pagination** logic is present if you asked for more items than fit on one page
  </Step>

  <Step title="Check the validation run">
    The agent runs the workflow headless and shows you the output. Check that the returned data looks right.

    ```bash theme={null}
    # What the agent runs
    npx libretto run ./free-stuff.ts --headless
    ```

    If the output is wrong, tell the agent what's off:

    > The location is always empty. It looks like it's reading the wrong element.

    The agent will re-inspect the page and fix the issue.
  </Step>
</Steps>

### When to use this approach

* You're automating a site for the first time and don't know its structure
* The task is self-contained and clearly scoped ("get the first 10 listings")
* You want a working script with minimal back-and-forth
* You don't need to demonstrate the workflow manually first

### Tips

<Tip>
  After you log in, tell the agent to save the session with `npx libretto save`.
  Future runs can restore your login state so you don't have to authenticate
  every time.
</Tip>

<Note>
  Make sure your coding agent has the Libretto skill installed before starting.
  Run `npm create libretto@latest` or `npx libretto setup` in your project root
  if you haven't already.
</Note>

### Related guides

<CardGroup cols={2}>
  <Card title="Interactive workflow building" icon="hand-pointer" href="/guides/interactive-workflow-building">
    Show the agent your workflow manually and let it turn your actions into code.
  </Card>

  <Card title="Convert to network requests" icon="network-wired" href="/guides/convert-to-network-requests">
    Speed up an existing browser script by switching to direct API calls.
  </Card>
</CardGroup>


---
title: Interactive workflow building
url: "https://libretto-docs.vercel.app/docs/guides/interactive-workflow-building.md"
description: "Some workflows are too complex to describe in words alone, especially in healthcare software and legacy web apps where the interaction sequence matters and the..."
---

> Record your actions in the browser and let your agent turn them into automation code.

Some workflows are too complex to describe in words alone, especially in healthcare software and legacy web apps where the interaction sequence matters and the UI has unusual patterns. Interactive script building lets you perform the workflow yourself while Libretto records every click, fill, and navigation. Your agent then reads the action log and translates what you did into a reusable automation script.

This approach combines your knowledge of the application (you drive the browser and show the agent the workflow) with the agent's ability to figure out the right selectors and write reliable Playwright code.

### Steps

<Steps>
  <Step title="Prompt your agent">
    Describe the **site**, the **workflow** you're going to demonstrate, and the **input/output shape** you want. Mention that you'll show the steps manually.

    ```text Example prompt
    Use the Libretto skill. I'm going to show you a workflow in eClinicalWorks to look up a patient's primary insurance ID. Turn it into a script that takes patient name and DOB as input and returns the insurance ID. URL is https://your-ehr.example.com
    ```

    Include "Use the Libretto skill" so your coding agent uses Libretto's CLI tools instead of writing a Playwright script from scratch.
  </Step>

  <Step title="Perform the workflow in the browser">
    The agent opens a headed browser window on your screen. If the site requires login, do that first. Then walk through the workflow exactly as you normally would: search for a patient, open their record, navigate to the insurance tab, whatever the task requires.

    Libretto records every click, form fill, and navigation in the background. You don't need to do anything special, just use the app normally.

    When you're done, tell the agent what you did and where the result ended up:

    > I'm done. I searched by patient name, opened their chart, and clicked the Insurance tab. The primary insurance ID is visible in the Patient Info section on screen.

    Call out anything non-obvious about what you did:

    > I clicked the second search result because the first row is always a header. Also, the insurance tab sometimes takes a few seconds to load.

    <Note>
      Take your time. The recording captures everything, so there's no penalty for pausing to think or scrolling around. If you make a mistake, just undo it naturally. The agent can filter out dead ends.
    </Note>
  </Step>

  <Step title="Wait for the agent to recreate your workflow">
    The agent reads the action log that Libretto recorded: every click, fill, and navigation along with the selectors and page context for each one. You'll see it run commands like:

    ```bash theme={null}
    tail -n 20 .libretto/sessions/interactive-flow/actions.jsonl | jq .
    ```

    It uses this log as a blueprint to write a TypeScript workflow file that replicates your steps programmatically. The agent may also snapshot the page or test selectors against the live DOM to make sure everything works.

    If the agent asks clarifying questions about what you did, answer them. Usually the action log plus your summary is enough.
  </Step>

  <Step title="Review the generated workflow file">
    The agent writes a TypeScript file that exports a `workflow()`. Things to check:

    * **`startUrl`** is set to the workflow's entry page
    * **Input/output schemas** match what you asked for
    * **Selectors** look reasonable (class names, data attributes, or ARIA roles, not brittle positional selectors)
    * **Parameterization**: values you typed during the demo should be replaced with input parameters, not hardcoded
    * **Wait logic**: if you mentioned something takes time to load, make sure there's a `waitForSelector` or similar

    If something's off, tell the agent:

    > The patient name is hardcoded. It should come from the input parameter. Also, the insurance tab selector looks fragile, can you use the tab's text instead?
  </Step>

  <Step title="Check the validation run">
    The agent runs the workflow headless with test parameters and shows you the output. Check that the returned data looks right.

    ```bash theme={null}
    # What the agent runs
    npx libretto run ./get-insurance-id.ts --headless \
      --params '{"patientName": "Jane Smith", "dob": "1985-04-12"}'
    ```

    If the output is wrong, tell the agent what's off:

    > It returned an empty string for the insurance ID. I think the tab content hadn't loaded yet when it tried to read the value.

    The agent will re-inspect the page and fix the issue.
  </Step>
</Steps>

### When to use this approach

* The workflow involves many steps and is hard to describe concisely
* You're working with EHR, healthcare, or legacy enterprise software with unusual UI patterns
* The workflow involves conditional paths that are easier to show than explain

### Tips

<Tip>
  After you log in, tell the agent to save the session with `npx libretto save`.
  Future runs can restore your login state so you don't have to authenticate
  every time.
</Tip>

### Related guides

<CardGroup cols={2}>
  <Card title="One-shot workflow generation" icon="bolt" href="/guides/one-shot-workflow-generation">
    Let the agent explore a site and build the workflow without your involvement.
  </Card>

  <Card title="Debugging workflows" icon="bug" href="/guides/debugging-workflows">
    Reproduce failures, inspect live page state, and fix broken automations.
  </Card>
</CardGroup>


---
title: Debugging workflows
url: "https://libretto-docs.vercel.app/docs/guides/debugging-workflows.md"
description: "There are two common debugging scenarios: connecting to a remote browser session that's already open (e.g. a production run that failed), and reproducing a..."
---

> Reproduce failures, inspect live page state, and fix broken browser automations.

There are two common debugging scenarios: connecting to a remote browser session that's already open (e.g. a production run that failed), and reproducing a failure locally from error logs.

### Remote debugging (connect to an open session)

When a workflow fails in production or a remote environment, the browser often stays open at the point of failure. You can connect to it directly with Libretto and debug the live page state.

<Steps>
  <Step title="Connect to the remote browser">
    Get the CDP (Chrome DevTools Protocol) endpoint URL from your deployment logs. Then connect:

    ```bash theme={null}
    npx libretto connect <cdp-endpoint-url> --session remote-debug
    ```
  </Step>

  <Step title="Prompt your agent to investigate">
    Tell the agent what went wrong and point it at the open session.

    ```text Example prompt
    Use the Libretto skill. I have a remote browser session open at --session remote-debug. The eligibility check workflow failed with a broken selector error. Inspect the current page state and figure out what changed.
    ```

    The agent can snapshot the page, test selectors, and execute code against the live browser without restarting the workflow.
  </Step>

  <Step title="Review the fix">
    The agent patches the workflow file based on what it found in the live page. Check the diff and verify the fix addresses the root cause.
  </Step>
</Steps>

### Local debugging (reproduce from error logs)

When you have error output from a failed run (stack traces, log files, GCP logs, etc.), you can have your agent reproduce and fix the issue locally.

<Steps>
  <Step title="Prompt your agent with the failure">
    Describe the **script**, what it's **supposed to do**, and what's **going wrong**. Include the error output.

    ```text Example prompt
    Use the Libretto skill. The workflow at ./integration.ts is supposed to go to Availity and perform an eligibility check for a patient. But I'm getting a broken selector error. Fix it.
    ```

    Paste the error or stack trace directly into your prompt.
  </Step>

  <Step title="Watch the failure reproduce">
    The agent runs the script in headed mode so you can see the browser. It stops at the failure point with the browser still open.

    ```bash theme={null}
    # What the agent runs
    npx libretto run ./integration.ts --session debug-flow --headed
    ```

    If you notice something useful (a popup that wasn't there before, a redesigned page), mention it to save the agent time.
  </Step>

  <Step title="Wait for the agent to diagnose and fix">
    The agent snapshots the broken page state, identifies what's wrong, and tests new selectors against the live DOM. You'll see it running `npx libretto snapshot` and `npx libretto exec` commands while the browser stays open.

    If you have context that would help, share it:

    > They redesigned the eligibility form last week. The submit button used to be in a sidebar but now it's inline.
  </Step>

  <Step title="Review the fix">
    The agent patches the workflow file. Check the diff:

    * Is the new selector reasonable? (`data-testid`, ARIA role, or visible text are more stable than class names)
    * Did it address the **root cause** or just the immediate symptom?
    * If the site changed its markup, are there other selectors in the file that might have the same problem?
  </Step>

  <Step title="Check the verification run">
    The agent re-runs the workflow headless to confirm the fix works.

    ```bash theme={null}
    npx libretto run ./integration.ts --headless
    ```

    If it fails again, the browser stays open and the agent repeats the diagnose/fix cycle.
  </Step>
</Steps>

### Using `pause()` as a breakpoint

If you need the workflow to stop at a specific point (not at failure, but at a known state you want to inspect), ask the agent to add a `pause()` call:

```typescript theme={null}
import { workflow, pause } from "libretto";
import { z } from "zod";

export default workflow(
  "eligibilityCheck",
  {
    input: z.object({
      memberId: z.string(),
    }),
    output: z.void(),
  },
  async (ctx, input) => {
    const { session, page } = ctx;

    await page.goto("https://availity.com");
    await page.locator("#memberSearch").fill(input.memberId);

    // Pause here to inspect the page before submitting
    await pause(session);

    await page.locator("button[type='submit']").click();
    // ...
  },
);
```

The workflow halts at the `pause()` with the browser open. The agent can also drive this autonomously by adding `pause()` calls where it wants to inspect, running the workflow, and calling `npx libretto resume --session <name>` to continue when it's ready. `pause()` is a no-op when `NODE_ENV === "production"`, so it's safe to leave in during development.

### Common errors

**Broken selectors:** the most common failure. A selector that worked last week no longer matches because the site updated its markup. Tell the agent what changed if you know, or just let it snapshot and figure it out.

**Popups blocking the workflow:** a modal, cookie banner, or session-expiry dialog appears unexpectedly. Tell the agent about the popup and ask it to add recovery logic using `attemptWithRecovery()` so future runs handle it automatically.

**Timeout errors:** a `waitForSelector` or locator action times out because the expected element never appeared. The page might show a loading spinner, an error state, or a redirect. The agent can snapshot to see what actually rendered.

### Tips

<Tip>
  Paste your error message into the prompt. Agents do a better job debugging
  when they have the stack trace.
</Tip>

### Related guides

<CardGroup cols={2}>
  <Card title="One-shot workflow generation" icon="bolt" href="/guides/one-shot-workflow-generation">
    Build a new automation from scratch without any prior knowledge of the site.
  </Card>

  <Card title="Interactive workflow building" icon="hand-pointer" href="/guides/interactive-workflow-building">
    Show the workflow manually and let your agent turn it into reusable code.
  </Card>
</CardGroup>


---
title: Convert to network requests
url: "https://libretto-docs.vercel.app/docs/guides/convert-to-network-requests.md"
description: "Browser automation is reliable, but it's not always the fastest approach. When a site's internal API is accessible from within the browser context, calling..."
---

> Convert browser automations to direct API calls for speed and reliability.

Browser automation is reliable, but it's not always the fastest approach. When a site's internal API is accessible from within the browser context, calling those endpoints directly is faster and less fragile than driving the UI. No selectors to maintain, no page rendering to wait for, and no DOM changes to break your script.

Libretto captures all network traffic during a browser session, so your agent can inspect exactly which API calls the site makes and convert a UI-based workflow to direct requests.

<Warning>
  Not every site is a good candidate for network conversion. Stay with browser automation if:

  * The site has enterprise bot protection (Akamai, PerimeterX, Shape Security) **and** monkey-patches `window.fetch` with call-stack inspection
  * The request you want to make is normally loaded as a document, script, image, stylesheet, or iframe rather than fetch/XHR
  * The site does API-level monitoring that correlates request timing to UI interactions
  * The data you need is rendered client-side and doesn't come from a clean API response

  In these cases, stick with your existing Playwright automation or use passive interception (`page.on('response', ...)`) to capture API data without making extra requests.
</Warning>

### Steps

<Steps>
  <Step title="Prompt your agent">
    Point the agent at the **existing workflow file** and ask it to convert to network requests.

    ```text Example prompt
    Use the Libretto skill. We have a browser script at ./integration.ts that automates going to Hacker News and getting the first 10 posts. Convert it to direct network requests instead.
    ```

    Include "Use the Libretto skill" so your agent uses Libretto's network capture tools instead of guessing at API endpoints.
  </Step>

  <Step title="Watch the agent analyze the traffic">
    The agent runs the existing script to capture all network requests and responses, then examines the log to find API endpoints that return the data you need.

    ```bash theme={null}
    # What the agent runs
    npx libretto run ./integration.ts
    jq . .libretto/sessions/conversion-flow/network.jsonl
    ```

    If the site requires login, the agent opens a headed browser and you'll log in as usual. Otherwise you don't need to do anything during this phase.
  </Step>

  <Step title="Review the agent's approach">
    The agent reports what API endpoints it found and whether the site has bot protection. It recommends one of two approaches:

    * **In-browser fetch**: call endpoints directly from within the browser's JavaScript context. The requests share the browser's cookies and TLS fingerprint, but they still look like fetch/XHR requests. Use this when the real site also calls that endpoint with fetch or XHR.
    * **Passive interception**: set up a `page.on('response', ...)` listener and let the site's own code make the requests. Zero detection risk because no extra requests are made, but you're limited to whatever requests the site naturally triggers. Safer, but less flexible. Use this when the site has bot protection.

    If the agent's recommendation looks reasonable, let it proceed. If you'd rather use one approach over the other, say so:

    > Use passive interception, I don't want to risk triggering their bot detection.
  </Step>

  <Step title="Review the rewritten workflow">
    The agent rewrites the workflow to use network requests instead of DOM extraction. Things to check:

    * **Endpoints:** are the URLs and HTTP methods correct?
    * **Auth/cookies:** does the new version handle authentication the same way as the original?
    * **Output shape:** the return type should be identical to the browser-based version
    * **Error handling:** API calls should check response status codes and handle failures gracefully
    * **Pagination:** if the original script paginated through UI, the new version should paginate through the API

    If something's off:

    > The original script also grabbed the comment count, but the new version doesn't fetch that field from the API.
  </Step>

  <Step title="Check the validation run">
    The agent runs the rewritten workflow headless and shows you the output. Compare it to what the browser-based version returned. The data should match.

    ```bash theme={null}
    # What the agent runs
    npx libretto run ./integration.ts --headless
    ```

    If the output is wrong or incomplete, tell the agent what's off:

    > The scores are all null. It looks like the API response has a different field name for that.
  </Step>
</Steps>

### When to convert to network requests

* The site exposes a usable JSON API behind the UI
* The target requests are normally made with fetch/XHR by the site itself
* You need to paginate deeply (fetching page 50 without clicking "next" 49 times)
* You want data the UI doesn't display (hidden fields, metadata, IDs)
* Speed and reliability matter more than DOM fidelity

### Related guides

<CardGroup cols={2}>
  <Card title="One-shot workflow generation" icon="bolt" href="/guides/one-shot-workflow-generation">
    Start from scratch. Give your agent a goal and let it build the workflow.
  </Card>

  <Card title="Debugging workflows" icon="bug" href="/guides/debugging-workflows">
    Reproduce failures and fix broken automations interactively.
  </Card>
</CardGroup>


---
title: Core concepts
url: "https://libretto-docs.vercel.app/docs/understand-libretto/core-concepts.md"
description: "Libretto ships two interfaces: All Libretto state lives in a .libretto/ directory at your project root: config.json: Workspace settings such as viewport,..."
---

> How Libretto organizes browsers, sessions, and state.

### CLI vs Library API

Libretto ships two interfaces:

* **CLI** (`npx libretto <command>`): the primary interface for both agents and humans. Commands open browsers, take snapshots, execute Playwright code, run workflow files, and manage sessions. Every command accepts `--session <name>` to target a specific browser session.
* **Library API** (`import { workflow } from "libretto"`): used inside workflow files you run with `npx libretto run`. The `workflow()` function wraps your automation handler, validates input with a Zod schema, and gives the handler typed access to `page`, `session`, and parsed input.

### The `.libretto/` directory

All Libretto state lives in a `.libretto/` directory at your project root:

```bash theme={null}
.libretto/
├── config.json              # Workspace settings such as viewport defaults
├── sessions/
│   └── <name>/
│       ├── state.json       # Debug port, browser PID, session status
│       ├── logs.jsonl       # Structured CLI and runtime logs
│       ├── network.jsonl    # Captured HTTP requests and responses
│       ├── actions.jsonl    # Recorded user and agent actions
│       └── snapshots/       # PNG screenshots and HTML captures
│           └── <snapshot-id>/
└── profiles/
    └── <domain>.json        # Saved auth state (cookies, localStorage)
```

**`config.json`:** Workspace settings such as viewport, browser provider, and session-mode defaults. Created by `npm create libretto@latest` or `npx libretto setup`. See [Configuration](/understand-libretto/configuration) for details.

Sessions and profiles are automatically git-ignored by a `.libretto/.gitignore` file created during setup. The config file and skill files are meant to be committed.

### Sessions

A **session** is a named browser context. When you run `npx libretto open https://example.com --session my-session`, Libretto launches a Chromium instance and registers it under that name. Subsequent commands (`snapshot`, `exec`, `pages`) all target that session by name.

Sessions are created automatically when you run `open`, `connect`, or `run`, and cleaned up with `npx libretto close`.

If you don't pass `--session`, Libretto generates a unique session name automatically. Sessions are independent: you can have multiple sessions open at the same time, each pointing to a different browser or URL.

#### Session files

**`state.json`:** Metadata Libretto uses to reconnect to a browser session: the CDP debug port, the browser process PID, and the session status (`active`, `paused`, `completed`, `failed`, `exited`).

**`logs.jsonl`:** Structured JSONL log of CLI events for the session. Useful for tracing what happened during a run.

**`network.jsonl`:** One entry per HTTP request/response captured while the session was open. Each entry includes `ts`, `method`, `url`, `status`, `contentType`, and `responseBody`. Query with `jq`:

```bash theme={null}
# Show all POST requests
jq 'select(.method == "POST")' .libretto/sessions/my-session/network.jsonl
```

**`actions.jsonl`:** One entry per user or agent action. User entries include `bestSemanticSelector`, `nearbyText`, and coordinates. Agent entries include the Playwright locator and duration. Query with `jq`:

```bash theme={null}
# Last 20 actions
tail -n 20 .libretto/sessions/my-session/actions.jsonl | jq .
```

**`snapshots/`:** One subdirectory per `snapshot` run, containing the captured PNG and HTML file.

For authenticated sites, see [Website authentication](/understand-libretto/website-authentication) to learn how to declare credentials, use TOTP secrets, and save reusable login profiles.


---
title: How workflow generation works
url: "https://libretto-docs.vercel.app/docs/understand-libretto/how-workflow-generation-works.md"
description: "Whether the agent is generating a workflow from scratch, recording your actions, or debugging a broken script, it follows the same four-phase process. Each..."
---

> What happens under the hood when your agent builds a browser automation.

Whether the agent is generating a workflow from scratch, recording your actions, or debugging a broken script, it follows the same four-phase process. Each phase is designed around a specific constraint of how AI agents interact with browsers.

### 1. Browser session

The agent starts by launching a browser and registering it as a named session. The session becomes the agent's handle to the browser for everything that follows: snapshots, interactions, and validation all target the session by name.

Sessions track all state (network traffic, actions, snapshots) in `.libretto/sessions/<name>/`. This gives the agent a persistent record of what it has seen and done, so it can refer back to earlier observations without re-inspecting the page.

If the site requires authentication, the agent opens the browser in headed mode and asks you to log in. It cannot handle login flows on its own because most login pages include CAPTCHAs or multi-factor prompts that require a human.

### 2. Page snapshot

Rather than reading the full page DOM, the agent captures a screenshot and compact accessibility snapshot. The snapshot output gives the agent a token-efficient view of selectors, interactive elements, and visible state.

The compact snapshot strips away irrelevant markup while preserving the information the agent needs to reason about what's on the page and how to interact with it. This keeps the agent's context window small while still giving it a detailed understanding of the page.

The agent snapshots frequently: before its first interaction, after navigation, and any time it needs to understand what changed. For large pages, it can inspect a subtree from the latest full snapshot by passing a printed ref back to `snapshot`.

### 3. Interaction prototyping

Before writing any code, the agent tests its plan against the live browser. It runs Playwright expressions one at a time through `exec`, validating that each selector actually targets the right element and each action produces the expected result.

The agent iterates between `snapshot` and `exec` during this phase. A typical loop looks like: snapshot the page to find a selector, exec an action using that selector, snapshot again to confirm the result. This cycle repeats until the agent has working selectors for each step of the workflow.

Prototyping against the live page is what separates Libretto from writing a Playwright script from scratch. Instead of guessing at selectors and hoping they work, the agent confirms each one interactively before committing it to the final script.

### 4. Code generation and validation

Once the agent has validated all the interactions, it writes a TypeScript file that exports a `workflow()` with Zod input and output schemas. The workflow uses the same Playwright selectors the agent already tested, so the script is grounded in what actually worked on the live page.

The agent then runs the workflow headless to verify it works end-to-end without any manual intervention. If the run fails, the agent re-enters the snapshot-and-prototype loop (phases 2 and 3) to diagnose and fix the issue, then tries the headless run again.

Validation requires a successful headless run with correct output. The agent does not consider a workflow complete until it has passed this check.

### How the workflow guides fit in

Each [workflow guide](/guides/one-shot-workflow-generation) follows this same four-phase process with different starting points:

* **One-shot generation:** The agent drives the entire process. You provide a prompt, the agent handles all four phases.
* **Interactive building:** You drive phases 1 and 2 by performing the workflow in the browser while Libretto records your actions. The agent handles phases 3 and 4.
* **Debugging:** You start at a failed workflow (phase 4) and the agent uses phases 2 and 3 to diagnose and fix it.


---
title: Configuration
url: "https://libretto-docs.vercel.app/docs/understand-libretto/configuration.md"
description: "Both npm create libretto@latest and npx libretto setup handle initial configuration for you. The commands on this page are for changing settings after the..."
---

> Change viewport, browser provider, or other workspace settings after initial setup.

Both `npm create libretto@latest` and `npx libretto setup` handle initial configuration for you. The commands on this page are for changing settings after the fact.

### Environment variables

Libretto automatically loads the repo root `.env` file before running CLI commands. Put provider credentials there:

```dotenv theme={null}
# inside .env
KERNEL_API_KEY=<kernel-api-key>
BROWSERBASE_API_KEY=<browserbase-api-key>
BROWSERBASE_PROJECT_ID=<browserbase-project-id>
STEEL_API_KEY=<steel-api-key>
```

Use `.libretto/config.json` for persistent workspace preferences like the default browser provider.

### Config file reference

The config file is at `.libretto/config.json`. You can edit it directly.

```json theme={null}
{
  "version": 1,
  "provider": "local",
  "viewport": { "width": 1280, "height": 800 },
  "sessionMode": "write-access"
}
```

<ParamField path="version" type="number" required>
  Config schema version.
</ParamField>

<ParamField path="snapshotModel" type="string">
  Deprecated. This key is ignored by `npx libretto snapshot`, which now prints a compact accessibility tree directly instead of using a configured analysis model.
</ParamField>

<ParamField path="provider" type="string">
  Optional. The browser provider to use. Must be `"local"`, `"kernel"`, `"browserbase"`, `"steel"`, or `"libretto-cloud"`. Defaults to `"local"`. Can be overridden per-command with `--provider` or the `LIBRETTO_PROVIDER` environment variable. Resolution order: `--provider` flag → `LIBRETTO_PROVIDER` env var → this config field → `"local"`.

  <Note>
    If you deploy to a cloud host using `kernel`, `browserbase`, `steel`, or `libretto-cloud`, set `provider` here too. Then local `open`, `run`, and debugging use the same browser as production — the container's Chromium isn't identical to your local one, and the drift causes inconsistent rendering and timing.
  </Note>
</ParamField>

<ParamField path="viewport" type="object">
  Optional, manually set. Default browser window size (width and height in pixels) used by `open` and `run` when you don't pass `--viewport`. Defaults to `1366x768` if not set.
</ParamField>

<ParamField path="sessionMode" type="string">
  Optional, manually set. Default access mode for new sessions created by `open`, `connect`, and `run`. Must be `"read-only"` or `"write-access"`. Defaults to `"write-access"` when omitted. Pass `--read-only` or `--write-access` to `open`, `connect`, or `run` to override when creating a session.
</ParamField>

<ParamField path="windowPosition" type="object">
  Optional, manually set. Sets the initial position of the headed browser window on screen. Has no effect in headless mode.
</ParamField>


---
title: "Automation & bot detection"
url: "https://libretto-docs.vercel.app/docs/understand-libretto/automation-and-bot-detection.md"
description: "Libretto supports four distinct approaches to capturing data and automating web interactions. Each makes different trade-offs between detection risk, setup..."
---

> Choose the right integration strategy and understand bot detection trade-offs.

### Overview

Libretto supports four distinct approaches to capturing data and automating web interactions. Each makes different trade-offs between detection risk, setup complexity, data quality, and control. Knowing the trade-offs helps you pick the right approach for your target site.

| Approach                                     | Bot detection risk | Best for                                            |
| -------------------------------------------- | ------------------ | --------------------------------------------------- |
| Regular Playwright                           | Low-Moderate       | Simple DOM extraction, server-rendered sites        |
| Passive interception (`page.on('response')`) | Low                | SPAs that load data via API calls during navigation |
| In-browser fetch (`pageRequest()`)           | Moderate           | API endpoints normally called with fetch/XHR        |
| Direct HTTP from Node.js                     | Very high          | Sites with no bot detection where API speed matters |

### Assessing bot detection

<Warning>
  Bot detection avoidance is best-effort. Libretto cannot guarantee your
  automation will go undetected. Using Libretto with authenticated accounts may
  violate the terms of service of those services. Understand the risks before
  automating against any site you don't control.
</Warning>

Libretto captures all network traffic and page state during a session, which lets you (or your agent) check what bot detection measures a site uses before committing to an automation approach:

* **Network log inspection.** Query `.libretto/sessions/<session>/network.jsonl` with `jq` to review all requests and responses. Look for calls to bot detection services (Cloudflare, Akamai, PerimeterX, DataDome) or challenge endpoints.
* **Fetch patching check.** Run `npx libretto exec` to evaluate `window.fetch.toString()` in the browser console. If it returns actual JavaScript (not `"[native code]"`), the site monkey-patches fetch and you should prefer passive interception over in-browser fetch.
* **Request type comparison.** Compare requests in `network.jsonl` before replacing browser behavior with `pageRequest()`. Browsers attach request-context headers that say what caused the request. A top-level navigation has `sec-fetch-dest: document` and `sec-fetch-mode: navigate`. A JavaScript fetch has `sec-fetch-dest: empty` and `sec-fetch-mode: cors` or `same-origin`. Script, image, stylesheet, and iframe loads have their own shapes too.
* **Snapshot inspection.** Use `npx libretto snapshot` to check for challenge pages, CAPTCHAs, or interstitials that indicate detection.

***

### Approach details

<Tabs>
  <Tab title="Regular Playwright">
    Standard Playwright usage: navigate pages, click elements, fill forms, and read DOM content using selectors and `page.evaluate()`.

    ```typescript theme={null}
    // Navigate and interact
    await page.goto('https://example.com/search');
    await page.fill('#query', 'search term');
    await page.click('#submit');
    await page.waitForSelector('.results');

    // Extract data from the DOM
    const results = await page.evaluate(() => {
      return Array.from(document.querySelectorAll('.result-item')).map(el => ({
        title: el.querySelector('h2')?.textContent,
        price: el.querySelector('.price')?.textContent,
      }));
    });
    ```

    **Pros:**

    * Simplest approach, uses Playwright as intended
    * No need to understand the site's API structure
    * Works with any site regardless of how data is rendered (server-side, client-side, or hybrid)
    * Data extraction is visual/DOM-based, which maps naturally to what a user sees
    * Easy to debug with `headless: false` and Playwright's trace viewer
    * Integrates directly with Libretto's step-based workflow and recovery features

    **Cons:**

    * Slower than API-based approaches because it requires full page rendering
    * Fragile against DOM changes, since selectors break when the site updates its markup
    * Harder to get structured data because you're scraping rendered HTML rather than clean API responses
    * Cannot access data that isn't rendered in the DOM (e.g., API responses with fields the UI doesn't display)

    **Bot detection risk: LOW-MODERATE**

    Plain Playwright is detectable by browser fingerprinting (Layer 1). Sites with any enterprise bot protection will likely flag it. Sites without active detection won't notice.

    <Note>
      Use `playwright-extra` with the stealth plugin to patch common fingerprint leaks, or run Playwright with a persistent browser context that looks more like a real browser profile.
    </Note>
  </Tab>

  <Tab title="Passive Interception">
    Listen to network responses the browser naturally makes as you navigate. You don't make any extra requests, you just capture the data flowing through.

    ```typescript theme={null}
    const capturedData: any[] = [];

    page.on('response', async (response) => {
      const url = response.url();
      if (url.includes('/api/search/results')) {
        const json = await response.json();
        capturedData.push(json);
      }
    });

    // Trigger the data load by interacting with the UI normally
    await page.goto('https://example.com/search?q=term');
    await page.waitForSelector('.results');
    // capturedData now has the raw API response
    ```

    **Pros:**

    * Zero additional bot detection risk from network requests because you're not making any extra calls. The requests that happen are the ones the site's own code triggers.
    * Gets clean, structured API data (JSON) rather than scraped DOM content
    * API responses often contain more data than the UI displays (hidden fields, IDs, metadata)
    * Not fragile against DOM changes, since the API contract tends to be more stable than CSS selectors
    * Works with Playwright's existing page context with no additional setup

    **Cons:**

    * You only get data that the page naturally loads, so you must trigger the right UI flow to cause the requests you need
    * Still requires Playwright browser automation to drive the page, so you have the browser fingerprinting risk for the navigation itself
    * Timing can be tricky. You must set up the listener before the navigation that triggers the request.
    * Responses may be paginated or partial. The site's UI might lazy-load data, requiring you to trigger scrolling or "load more" interactions.
    * If the site uses GraphQL or batched API calls, parsing the right data out of responses requires understanding the API structure
    * Some responses may be encrypted or obfuscated by bot protection services

    **Bot detection risk: LOW**

    The network requests themselves carry zero additional risk since they originate from the site's own JavaScript. The only risk is from the browser automation layer needed to drive the UI. No extra fetch calls means no anomalous network patterns for API-level monitoring to flag.
  </Tab>

  <Tab title="In-Browser Fetch">
    Execute fetch calls from within the browser page's JavaScript context. The requests originate from the browser process with the browser's cookies, session, and TLS fingerprint. They still look like fetch/XHR requests. Libretto's `pageRequest()` function provides a typed wrapper for this pattern.

    ```typescript theme={null}
    const data = await page.evaluate(async () => {
      const res = await fetch('/api/search/results?q=term&page=2', {
        headers: {
          'Content-Type': 'application/json',
          'X-Requested-With': 'XMLHttpRequest',
        },
      });
      return res.json();
    });
    ```

    **Pros:**

    * Requests come from the real browser with the same TLS fingerprint, cookies, origin, and HTTP/2 settings. This works best for endpoints the site's own app calls with fetch or XHR.
    * Full control over which endpoints you call and with what parameters, no need to trigger UI flows
    * Can call endpoints the UI doesn't naturally hit (e.g., fetch page 50 of results without clicking "next" 49 times)
    * Gets clean, structured API data (JSON)
    * Faster than driving the UI: skip page rendering and go straight to the data

    **Cons:**

    * Requires understanding the site's API: you need to know the endpoint URLs, required headers, authentication tokens, and request body format. You'll need to reverse-engineer the site's network traffic first.
    * Does not inherit the request type of the browser primitive the site normally uses. A browser fetch is sent as `resourceType: "fetch"` with headers like `sec-fetch-dest: empty` and `sec-fetch-mode: cors`, while a page navigation is sent as `resourceType: "document"` with `sec-fetch-dest: document`, `sec-fetch-mode: navigate`, `sec-fetch-user: ?1`, and `upgrade-insecure-requests: 1`. Script, image, CSS, and iframe loads have different request shapes too.
    * Browser JavaScript generally cannot set the key `Sec-Fetch-*` headers manually. Copying headers into a fetch call usually does not make it look like a different kind of browser request.
    * Vulnerable to fetch/XHR monkey-patching. If the site wraps `window.fetch`, your calls may be intercepted and flagged because the call stack won't match the site's expected code paths.
    * Riskier for full document URLs, such as search result pages. Use `page.goto()` and parse the loaded document, or capture the responses the site's own app naturally triggers.
    * Still requires a Playwright browser to be running (for the execution context)
    * API endpoints can change without notice
    * Must handle authentication tokens and CSRF tokens that the site's own code normally manages

    **Bot detection risk: MODERATE**

    The requests are genuine browser requests, but they still expose a fetch/XHR request context. The risk comes from browser fingerprinting, request type mismatches, fetch/XHR monkey-patching, and timing or pattern analysis if your requests don't match normal UI flow.

    <Note>
      Rule of thumb: match the browser primitive to the site's real request type. Use `page.goto()` or a link click for page HTML, `pageRequest()` for API requests the site normally makes with fetch/XHR, and passive interception when you can let the site's own code make the request.
    </Note>
  </Tab>

  <Tab title="Direct HTTP">
    Make HTTP requests directly from Node.js using the built-in `fetch` API. No browser involved.

    ```typescript theme={null}
    const response = await fetch('https://example.com/api/search/results?q=term&page=1', {
      headers: {
        'User-Agent': 'Mozilla/5.0 ...',
        'Cookie': 'session=abc123; ...',
      },
    });
    const data = await response.json();
    ```

    **Pros:**

    * Fastest approach, with no browser overhead, no page rendering, and minimal memory usage
    * Simple code: just HTTP requests, no browser lifecycle management
    * Easy to parallelize. Make many concurrent requests without launching multiple browser instances.
    * Lowest resource consumption, suitable for high-volume data collection

    **Cons:**

    * No cookies unless manually managed. You must extract cookies from a browser session and replicate them, including HttpOnly cookies you can't access from JS.
    * No browser-specific headers. `sec-ch-ua`, `sec-fetch-*`, and other headers that browsers add automatically must be manually fabricated.
    * No JavaScript execution. If the site requires JS to set cookies, generate tokens, or solve challenges, you can't do it.
    * CSRF and auth tokens must be manually extracted and refreshed
    * Breaks easily. API changes, new security headers, or updated bot protection will break requests with no fallback.

    <Warning>
      **Bot detection risk: VERY HIGH.** This approach is detectable at nearly every layer. TLS fingerprinting alone will catch Node.js HTTP clients on any site with even basic bot protection. The TLS fingerprint is fundamentally different from any real browser, and this is one of the strongest detection signals. This approach only works reliably against sites with zero bot detection, or against documented public APIs that expect programmatic access.
    </Warning>
  </Tab>
</Tabs>

***

### Decision guide

<Tip>
  **Recommended approach:** Use in-browser fetch (`pageRequest()`) for verified API endpoints that the site normally calls with fetch/XHR. It gives you full control over which endpoints you call, structured JSON responses, and the real browser's network fingerprint.

  For high-security sites with aggressive bot detection (Cloudflare, Akamai, PerimeterX, DataDome), use Regular Playwright for navigation and passive `page.on('response')` interception for data capture. This avoids making any extra requests that could trigger detection.
</Tip>

**Use Regular Playwright when:**

* The data you need is visible in the DOM and straightforward to extract with selectors
* The site doesn't have aggressive bot protection, or you're using stealth plugins
* You want the simplest implementation that integrates with Libretto's recovery features
* The data is rendered server-side and doesn't come from a separate API call

**Use passive interception (`page.on('response')`) when:**

* The site loads data via API calls during normal navigation (most modern SPAs)
* You want structured JSON data without reverse-engineering the full API
* Minimizing detection risk is important
* You're already navigating through the UI and want to passively capture data along the way

**Use in-browser fetch (`pageRequest()`) when:**

* You need data from API endpoints that the UI doesn't naturally trigger (e.g., deep pagination, bulk exports)
* You've verified the site doesn't monkey-patch `fetch` (or you can work around it)
* You've verified the target endpoint is normally called with fetch/XHR, not loaded as a document, script, image, stylesheet, or iframe
* You want maximum control over which data you fetch and when
* You've already reverse-engineered the relevant API endpoints

**Use Direct Node.js HTTP when:**

* The target site has zero bot detection
* Speed and resource efficiency are the primary concerns
* You're hitting a public/documented API (not scraping a website)
* You need to make thousands of concurrent requests


---
title: Website authentication
url: "https://libretto-docs.vercel.app/docs/understand-libretto/website-authentication.md"
description: "Libretto supports two website authentication tools: By default, Libretto-generated login workflows combine both: the workflow starts with the named auth..."
---

> Use credentials for scripted login, auth profiles for saved browser state, and `librettoAuthenticate` to recover when a saved session is missing or expired.

Libretto supports two website authentication tools:

* `credentials` are secrets such as usernames, passwords, API keys, and TOTP shared secrets. Use them when the workflow can log in with Playwright.
* `authProfile` is named browser state for a signed-in session. Use it when a workflow should start from an existing login or preserve login state across runs.

By default, Libretto-generated login workflows combine both: the workflow starts with the named auth profile, checks whether it is signed in, and signs in with credentials when the profile is missing, expired, or logged out.

```typescript theme={null}
import { librettoAuthenticate, workflow } from "libretto";
import { z } from "zod";

export default workflow("portalReport", {
  startUrl: "https://portal.example.com",
  credentials: ["username", "password"],
  authProfile: {
    name: "portal",
    refresh: true,
  },
  input: z.object({
    reportId: z.string(),
  }),
  output: z.object({
    title: z.string(),
  }),
  async handler(ctx, input) {
    const { page } = ctx;

    await librettoAuthenticate(ctx, {
      credentials: input.credentials,
      isSignedIn: async ({ page }) =>
        await page
          .getByRole("button", { name: /account|sign out/i })
          .isVisible()
          .catch(() => false),
      signIn: async ({ page }, credentials) => {
        await page.goto("https://portal.example.com/login");
        await page.getByLabel("Email").fill(credentials.username);
        await page.getByLabel("Password").fill(credentials.password);
        await page.getByRole("button", { name: /log in/i }).click();
      },
    });

    await page.goto(`https://portal.example.com/reports/${input.reportId}`);
    const title = await page.locator("h1").innerText();
    return { title };
  },
});
```

In this pattern, the auth profile handles the common case where the browser is already signed in. The `signIn` step handles the recovery case where the profile is missing, expired, or logged out. If sign-in succeeds and `refresh: true` is set, the run saves the updated browser state back to the profile for future runs.

### Credentials

Credentials are separate from normal workflow input. Declare the names the workflow needs, then read the resolved values from `input.credentials`. Do not pass secrets through `--params`.

For local runs, store matching variables in `.env`:

```dotenv theme={null}
LIBRETTO_CLOUD_USERNAME=alice@example.com
LIBRETTO_CLOUD_PASSWORD=secret
LIBRETTO_CLOUD_TOTP_SECRET=JBSWY3DPEHPK3PXP
```

Libretto removes the `LIBRETTO_CLOUD_` prefix and lowercases the rest, so `LIBRETTO_CLOUD_USERNAME` becomes `input.credentials.username`.

For hosted runs, store the same credential names in Libretto Cloud. See [Libretto Cloud credentials](/libretto-cloud-api/credentials).

<Warning>
  Never put secrets in workflow params, logs, or source code. Use declared credentials backed by `.env` locally and Libretto Cloud credentials in hosted runs.
</Warning>

### Auth profiles

An auth profile is named browser state for a login session. Local profiles live in `.libretto/profiles/<name>.json`; hosted profiles are provider-native browser profiles managed by Libretto Cloud and the active browser provider.

Use the workflow-level `authProfile` option:

```typescript theme={null}
authProfile: "portal"

authProfile: {
  name: "portal",
  refresh: true,
}
```

Use `{ name, refresh: true }` when successful runs should persist updated browser state back to the profile. On local runs, this updates the local `.libretto/profiles/<name>.json` file. On hosted runs, it asks the browser provider to persist changes to the provider-native profile.

The preferred way to create a local profile is to sign in through a headed Libretto session and save it:

```bash theme={null}
npx libretto open https://portal.example.com --headed --session portal-login
# Log in manually in the browser window.
npx libretto save portal --session portal-login --sites portal.example.com
```

Use this when the login cannot be scripted with Playwright, such as CAPTCHAs, device approvals, or magic links. For repeatable SMS one-time codes on Libretto Cloud, see [`claimSmsOtp`](/reference/runtime/sms-otp). Once an auth profile is saved, the workflow can use `authProfile: "portal"` or `authProfile: { name: "portal", refresh: true }` without repeating the manual login every run.

If you already have a signed-in Chrome profile and explicitly want to copy from it, start that Chrome profile with remote debugging enabled and import only the sites you need:

```bash theme={null}
npx libretto import-chrome-profiles portal --cdp-url http://127.0.0.1:9222 --sites portal.example.com
```

Chrome often needs to be closed and relaunched with a temporary user-data directory before remote debugging can attach. Importing from Chrome can close or relaunch that browser window, so confirm with the user before doing it.

Use `--sites` only when saving or importing a local profile. It controls which site storage is copied into the local profile. See [CLI profiles](/reference/cli/profiles).

### Hosted website authentication

Hosted runs do not upload local `.libretto/profiles/<name>.json` files. They use provider-native profiles with the workflow's `authProfile.name`. `libretto cloud deploy` registers missing profile names, and hosted runs populate or refresh the provider profile when sign-in succeeds.

For the hosted model, provider behavior, and profile persistence details, see [Website authentication in Libretto Cloud](/libretto-cloud-hosting/website-authentication).

### Runtime helpers

Use [`librettoAuthenticate`](/reference/runtime/authentication) when a workflow has an auth profile and scripted sign-in. The helper runs your `isSignedIn` check, calls your `signIn` function if needed, and checks again before the workflow continues.

Use [`claimSmsOtp`](/reference/runtime/sms-otp) inside `signIn` (or any scripted login) when the portal texts a one-time code. Provision the inbox outside the workflow, claim it before send-code, then wait for the code.


---
title: Error recovery
url: "https://libretto-docs.vercel.app/docs/understand-libretto/error-handling-and-recovery.md"
description: "Libretto helps your agent handle failures in two ways: inspect failed runs so it can fix the deterministic workflow, or add a narrow runtime recovery action..."
---

Libretto helps your agent handle failures in two ways: inspect failed runs so it can fix the deterministic workflow, or add a narrow runtime recovery action for expected nondeterminism.

Most workflow failures should be fixed with better selectors, waits, or assertions. Runtime recovery is for cases where the workflow is on the right path but the site sometimes shows unexpected UI that blocks the next action.

## Debugging failed runs

When a workflow fails during development, the Libretto CLI helps your agent debug the code by preserving the browser session. The agent can run headed, add `pause(ctx.session)` at useful checkpoints, inspect the page state, iterate on code, and use `resume` to continue from the pause before redeploying.

For deployed workflows on the hosted platform, a failed run automatically starts an analysis agent. It reviews the error, logs, recording, screenshots, and browser state so it can explain what went wrong and suggest the code change.

## Runtime recovery actions

Add `recoveryAction` to a workflow when you want Libretto to recover after a supported Page or Locator action fails. Libretto calls the recovery action, then retries the original action once.

```typescript theme={null}
export default workflow("reviewOrder", {
  input,
  output,
  recoveryAction: popupRecoveryAction({
    provider: "openai",
    apiKey: process.env.OPENAI_API_KEY!,
    model: "gpt-5.5",
    maxSteps: 3,
  }),
  handler: async ({ page }, params) => {
    await page.goto(params.url);
    await page.locator("#review").click();
    return { complete: true };
  },
});
```

## AI vision recovery

The computer use recovery action looks at the current screen and takes one or more browser actions to get back to the expected path. It is useful for nondeterministic blockers such as cookie banners, popups, modals, interstitials, and overlays that intercept clicks.

It uses a viewport screenshot, asks the model for an action, scales the returned screenshot coordinates to Playwright coordinates, and executes the action.

`popupRecoveryAction()` is the built-in preset for closing popups and other blockers. Use `computerUseRecoveryAction()` when you want to provide your own instruction.

Simple sites without nondeterministic UI usually do not need AI vision recovery. For complicated sites automated through the DOM, add it when exploration shows blockers across sessions, accounts, or time. Do not use it to make business decisions that belong in the workflow handler.

Supported provider shortcut models are `gpt-5.5` and `claude-sonnet-4-6`.


---
title: Playwright debugging agent
url: "https://libretto-docs.vercel.app/docs/understand-libretto/autofix-debugging.md"
description: How Libretto investigates Playwright failures and opens pull requests that fix broken scripts.
---

When a Playwright automation fails, Libretto's Playwright debugging agent
investigates the failure against the live website and automatically opens a pull
request that fixes the broken script. It turns a broken run into a reviewable
change instead of a pager alert.

You add one call in your script's failure path; Libretto does the rest. For the
API and configuration, see the
[Playwright debugging agent reference](/reference/runtime/playwright-debugger).

## Why it inspects the live page

A stack trace and a static DOM snapshot are rarely enough to fix a browser
automation. A selector like `input[name="username"]` might have failed because
the real field is `name="login"`, because the page redirected, or because an
element loads late — and the source code alone cannot tell you which.

The debugging agent does not guess. It runs an agent loop on the
[`libretto-browser-tools`](https://www.npmjs.com/package/libretto-browser-tools) SDK and
drives the supplied failed page in its existing browser context. Authentication,
storage, open tabs, network routes, and in-memory state remain available while
the agent confirms the cause. Fixes are grounded in what the agent actually
observed on the page.

## The flow

1. **Capture.** On failure, the debugger records the error, stack, URL, title,
   screenshot, and DOM snapshot, and reads the relevant source files from your
   repo at the base branch.
2. **Investigate.** The agent snapshots the failed page's live accessibility
   tree and runs Playwright code in the same browser context (for example,
   checking whether a selector resolves) until it is confident about the root
   cause.
3. **Fix.** It produces a minimal, full-file change for the files that must
   change — or, if the evidence is insufficient, it proposes nothing.
4. **Open a PR.** The change is committed to a new branch and opened as a pull
   request whose description cites the failure and the evidence behind the fix.

## What you need

* **A connected GitHub repository.** Install the Libretto GitHub App and link
  the repo so Libretto can open PRs on it.
* **A `LIBRETTO_API_KEY`.** The agent exchanges it for a short-lived,
  repo-scoped GitHub token — you never manage a raw GitHub token in production.
* **The debugger call in your script.** Add
  `playwrightDebugger.debugFailure(error, page)` in your automation's `catch`
  block before Playwright teardown.

The [setup flow](https://libretto.sh/setup) walks through connecting GitHub,
minting the API key, and adding the debugger to an existing script.

## Relationship to error recovery

The Playwright debugging agent fixes the deterministic workflow *after* a
failure by opening a PR. For expected nondeterminism that should be handled
*during* a run, use a narrow runtime recovery action instead — see
[Error recovery](/understand-libretto/error-handling-and-recovery).


---
title: "open & connect"
url: "https://libretto-docs.vercel.app/docs/reference/cli/open-and-connect.md"
description: "The open command launches a browser and navigates to a URL, creating a new named session. It is the starting point for any interactive workflow: once a session..."
---

> Launch a browser session or attach to an existing Chrome DevTools Protocol endpoint.

### open

The `open` command launches a browser and navigates to a URL, creating a new named session. It is the starting point for any interactive workflow: once a session is open, `exec`, `snapshot`, and `pages` all work against it.

```bash theme={null}
npx libretto open https://example.com
npx libretto open https://example.com --headless --session debug-example
```

#### When to use `open`

* At the start of script authoring when you need live page state to decide how the workflow should work.
* When the user needs to log in manually before automation begins (use `--headed`).
* Any time you need a fresh, isolated browser instance on a specific URL.

#### Flags

<ParamField path="url" type="string" required>
  The URL to open. Passed as the first positional argument.
</ParamField>

<ParamField path="--headed" type="boolean">
  Run the browser in headed (visible) mode. This is the default when neither
  flag is passed.
</ParamField>

<ParamField path="--headless" type="boolean">
  Run the browser in headless mode. Useful for automated runs that don't need a
  visible window.
</ParamField>

<ParamField path="--session" type="string">
  Name for this session. If omitted, Libretto generates a unique name
  automatically. Use a consistent name to target the session with later
  commands.
</ParamField>

<ParamField path="--viewport" type="string">
  Viewport size in `WIDTHxHEIGHT` format, for example `1920x1080`. Falls back to
  the value in `.libretto/config.json`, then `1366x768`.
</ParamField>

<ParamField path="--provider, -p" type="string">
  Browser provider to use. One of `local`, `kernel`, `browserbase`, `steel`, or `libretto-cloud`. Defaults to `local`.

  **Resolution order:** `--provider` flag → `LIBRETTO_PROVIDER` env var → `provider` field in `.libretto/config.json` → `"local"`.
</ParamField>

<Note>
  If a saved auth profile exists for the URL's domain, `open` automatically
  loads it, injecting the saved cookies and localStorage into the new session
  before navigating. You can override this by passing `--auth-profile`
  explicitly, or skip profile loading by not having a saved profile for that
  domain.
</Note>

#### Examples

```bash theme={null}
# Open in headed mode (default)
npx libretto open https://example.com --headed

# Open in headless mode with an explicit session name
npx libretto open https://example.com --headless --session debug-example

# Open with a custom viewport
npx libretto open https://example.com --viewport 1440x900 --session my-session

# Open for a manual login flow, then save the session
npx libretto open https://linkedin.com --headed --session app-login
# ... user logs in ...
npx libretto save linkedin.com --session app-login
```

***

### Cloud browser providers

By default, `open` launches a local Chromium instance. You can instead run the browser in the cloud using the `--provider` flag (or `LIBRETTO_PROVIDER` env var, or `provider` in `.libretto/config.json`).

#### Available providers

| Provider                 | Value            | Required environment variables                  |
| ------------------------ | ---------------- | ----------------------------------------------- |
| Local Chromium (default) | `local`          | None                                            |
| Kernel                   | `kernel`         | `KERNEL_API_KEY`                                |
| Browserbase              | `browserbase`    | `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID` |
| Steel                    | `steel`          | `STEEL_API_KEY`                                 |
| Libretto Cloud           | `libretto-cloud` | `LIBRETTO_API_KEY`                              |

#### Configuration

You can set the provider in three ways (highest precedence first):

1. **CLI flag:** `--provider kernel` (or `-p kernel`)
2. **Environment variable:** `LIBRETTO_PROVIDER=kernel`
3. **Config file:** add `"provider": "kernel"` to `.libretto/config.json`

If none is set, the default is `local`.

#### Examples

```bash theme={null}
# Open with Kernel cloud browser
export KERNEL_API_KEY="your-key"
npx libretto open https://example.com --provider kernel

# Open with Browserbase
export BROWSERBASE_API_KEY="your-key"
export BROWSERBASE_PROJECT_ID="your-project-id"
npx libretto open https://example.com -p browserbase

# Open with Steel
export STEEL_API_KEY="your-key"
npx libretto open https://example.com --provider steel

# Open with Libretto Cloud
export LIBRETTO_API_KEY="your-key"
npx libretto open https://example.com --provider libretto-cloud

# Set provider via config so you don't need the flag every time
# .libretto/config.json: { "version": 1, "provider": "kernel" }
npx libretto open https://example.com
```

<Note>
  Cloud providers ignore `--headed` / `--headless` and `--viewport` flags since
  the browser runs remotely. The session is always headless from the local
  machine's perspective.
</Note>

***

### connect

The `connect` command attaches to an existing Chrome DevTools Protocol (CDP) endpoint rather than launching a new browser. Use it to drive a browser that is already running, for example one started with `--remote-debugging-port`, an Electron application, or any other CDP-compatible target.

```bash theme={null}
npx libretto connect http://127.0.0.1:9222 --session my-session
npx libretto connect ws://127.0.0.1:9223 --session another-session
```

#### When to use `connect`

* You have an existing browser process you want to automate without restarting it.
* You are driving an Electron app exposed over CDP.
* You want to attach to a remote browser in a CI environment that has already navigated to the right state.

For executing a default-exported `workflow()` file against that same CDP endpoint without an interactive session first, use [`run --cdp`](/reference/cli/run-and-resume#run-against-an-external-cdp-browser) instead.

#### Flags

<ParamField path="cdp-url" type="string" required>
  The CDP endpoint URL to connect to. This can be either an HTTP or WebSocket
  URL and is passed as the first positional argument. Example:
  `http://127.0.0.1:9222`.
</ParamField>

<ParamField path="--session" type="string">
  Name for this session. Recommended to set explicitly when using `connect` so
  you can reference it later.
</ParamField>

#### After connecting

Once connected, all session-scoped commands work normally against the attached browser:

* `exec`: run Playwright TypeScript code
* `snapshot`: capture a screenshot and compact accessibility tree
* `pages`: list open pages

<Warning>
  Libretto does **not** manage the lifecycle of a connected process. Running `npx libretto close --session <name>` clears the Libretto session record but does **not** terminate the remote browser or Electron app.
</Warning>

#### Examples

```bash theme={null}
# Attach to a Chrome instance started with --remote-debugging-port
npx libretto connect http://127.0.0.1:9222 --session chrome-debug

# Inspect the attached page
npx libretto snapshot --session chrome-debug

# Run code against it
npx libretto exec --session chrome-debug "await page.url()"
```

<CardGroup cols={2}>
  <Card title="exec" icon="code" href="/reference/cli/exec">
    Execute Playwright TypeScript against the open page.
  </Card>

  <Card title="snapshot" icon="camera" href="/reference/cli/snapshot">
    Capture a screenshot and compact accessibility tree.
  </Card>
</CardGroup>


---
title: sessions
url: "https://libretto-docs.vercel.app/docs/reference/cli/sessions.md"
description: "Almost every Libretto command accepts --session <name> to target a specific browser session. When you omit --session on commands that support auto-generation..."
---

> Manage named browser sessions and control session access modes.

### The `--session` flag

Almost every Libretto command accepts `--session <name>` to target a specific browser session. When you omit `--session` on commands that support auto-generation (`open`, `run`), Libretto creates a unique name automatically.

Session state is stored under `.libretto/sessions/<session>/`:

```txt
.libretto/
  sessions/
    <session>/
      state.json
      logs.jsonl
      network.jsonl
      actions.jsonl
      snapshots/
```

Session directories are git-ignored. Each session runs an isolated browser instance on a dynamic port.

***

### close

The `close` command closes a browser session and cleans up its state.

```bash theme={null}
npx libretto close --session debug-example
npx libretto close --all
```

#### When to use `close`

* When you are done with a session.
* When an exploration session is no longer helping progress and you want to free up the browser.
* For full workspace cleanup at the end of a task.

<Note>
  When using `connect` to attach to an external CDP endpoint, `close` clears the
  Libretto session record but **does not terminate the remote browser process**.
</Note>

#### Flags

<ParamField path="--session" type="string">
  The session to close. Required unless `--all` is passed.
</ParamField>

<ParamField path="--all" type="boolean">
  Close all tracked sessions in this workspace.
</ParamField>

<ParamField path="--force" type="boolean">
  Force-kill sessions that do not respond to SIGTERM. Requires `--all`.
</ParamField>

#### Examples

```bash theme={null}
# Close a named session
npx libretto close --session debug-example

# Close all sessions in the workspace
npx libretto close --all

# Close all sessions, force-killing any that are unresponsive
npx libretto close --all --force
```

***

### Read-only sessions

Read-only mode prevents any mutating browser actions (clicks, typing, navigation, form fills) while still allowing inspection. This is useful for diagnosing live production sessions without risking accidental state changes.

#### Starting a read-only session

Pass `--read-only` to `open`, `connect`, or `run`:

```bash theme={null}
npx libretto open https://example.com --read-only --session diag
npx libretto connect ws://localhost:9222 --read-only --session remote-diag
npx libretto run ./workflow.ts --read-only --session test-run
```

You can also manually set `sessionMode` in `.libretto/config.json` to change the default for all new sessions:

```json theme={null}
{
  "version": 1,
  "sessionMode": "read-only"
}
```

Pass `--write-access` to `open`, `connect`, or `run` to override a `read-only` config default when creating a session.

#### Changing session mode

View or change the mode of an existing session with `session-mode`:

```bash theme={null}
# View current mode
npx libretto session-mode --session my-session

# Switch to read-only
npx libretto session-mode read-only --session my-session

# Switch back to write-access
npx libretto session-mode write-access --session my-session
```

#### What's allowed in read-only mode

| Command         | Allowed |
| --------------- | ------- |
| `snapshot`      | Yes     |
| `readonly-exec` | Yes     |
| `pages`         | Yes     |
| `close`         | Yes     |
| `session-mode`  | Yes     |
| `exec`          | No      |

#### `readonly-exec`

A restricted version of `exec` that only allows read operations on the page. Available methods include:

* **Page reads**: `url()`, `title()`, `content()`, `viewportSize`, `waitForLoadState`, `waitForURL`
* **Locator factories**: `locator()`, `getByRole()`, `getByText()`, `getByLabel()`, `getByPlaceholder()`, `getByAltText()`, `getByTitle()`, `getByTestId()`
* **Locator reads**: `textContent()`, `innerText()`, `boundingBox()`, `count()`, `getAttribute()`, `inputValue()`, `isVisible()`, `isHidden()`, `isEnabled()`, `isChecked()`
* **Network**: `get(url)` (GET/HEAD only, no request bodies)
* **Viewport**: `scrollBy(deltaX, deltaY)` for scrolling by pixel offset
* **Other**: `state`, `console`, `URL`, `Buffer`

Any mutating operation (click, fill, goto, reload, etc.) throws a `ReadonlyExecDenied` error.

```bash theme={null}
# Read page title
npx libretto readonly-exec --session diag "return await page.title()"

# Check if an element is visible
npx libretto readonly-exec --session diag "return await page.locator('#submit').isVisible()"

# Get text content of a section
npx libretto readonly-exec --session diag "return await page.locator('.results').textContent()"
```


---
title: profiles
url: "https://libretto-docs.vercel.app/docs/reference/cli/profiles.md"
description: "The save command saves the current browser session's cookies, localStorage, and IndexedDB as a named local auth profile. Workflows load it by declaring..."
---

> Save and reuse authenticated browser state across runs.

### save

The `save` command saves the current browser session's cookies, localStorage, and IndexedDB as a named local auth profile. Workflows load it by declaring `authProfile`.

```bash theme={null}
npx libretto save linkedin --session app-login --sites linkedin.com
```

#### When to use `save`

Only use `save` when you want to save or reuse authenticated browser state. A typical flow:

<Steps>
  <Step title="Open the site in headed mode">
    ```bash theme={null}
    npx libretto open https://linkedin.com --headed --session app-login
    ```
  </Step>

  <Step title="Log in manually">
    The browser window is visible. Log in as usual.
  </Step>

  <Step title="Save the session">
    ```bash theme={null}
    npx libretto save linkedin --session app-login --sites linkedin.com
    ```

    The profile is saved to `.libretto/profiles/linkedin.json`.
  </Step>

  <Step title="Use the profile in future runs">
    Declare `authProfile: "linkedin"` in the workflow, then run it normally:

    ```bash theme={null}
    npx libretto run ./integration.ts
    ```
  </Step>
</Steps>

#### Flags

<ParamField path="profileName" type="string" required>
  The local profile name. Passed as the first positional argument. Example:
  `linkedin`, `twitter`, or `twitter2`.
</ParamField>

<ParamField path="--session" type="string" required>
  The session to save. Use the same name you passed to `npx libretto open   --session`.
</ParamField>

<ParamField path="--sites" type="string" required>
  Comma-separated site or domain list to capture, for example
  `linkedin.com,auth.linkedin.com`.
</ParamField>

Subdomains do not need to be listed separately when the parent domain is enough
for the login. For example, `linkedin.com` captures cookies for
`www.linkedin.com` and `auth.linkedin.com`. Add multiple sites when the login
uses separate parent domains, such as `example.com,example-login.com`.

### Storage-state profiles and persistent browser profiles

The `save` command creates a Playwright storage-state profile. It preserves
cookies, localStorage, and IndexedDB, which is sufficient for most websites. It
does not preserve sessionStorage, service-worker memory, or authentication that
the server binds to a specific browser process.

Use a persistent browser profile when signing in again after loading a saved
auth profile invalidates the session. Launch Chrome with a dedicated
`--user-data-dir`, keep that browser process running, and attach Libretto with
`connect`.

<Warning>
  A persistent browser profile contains credentials and the browser's complete
  local state. Give each account a separate directory, restrict filesystem
  access, and do not run multiple browser processes against the same directory.
</Warning>

### import-chrome-profiles

The `import-chrome-profiles` command imports cookies, localStorage, and IndexedDB
from an existing Chrome DevTools Protocol session into a named local profile.
The profile name is the local Libretto profile you want to create. The `--sites`
list decides which signed-in sites are copied from Chrome.

```bash theme={null}
npx libretto import-chrome-profiles twitter --cdp-url http://127.0.0.1:9222 --sites x.com,twitter.com
```

Start Chrome with remote debugging enabled, log in normally, then run the fetch
command. The CDP URL comes from the Chrome process you launched with
`--remote-debugging-port`; for example, port `9222` gives
`http://127.0.0.1:9222`. The profile is saved under
`.libretto/profiles/<profileName>.json`.

The command asks before attaching because disconnecting from an existing CDP
session can close or relaunch that Chrome window. Use `--yes` only when the
user has already consented.

Recent Chrome versions reject remote debugging against the default user-data
directory; if Chrome prints that error, copy the desired profile to a temporary
user-data directory and launch Chrome from that copy.

### cloud profiles list

List hosted auth profile names known to Libretto Cloud:

```bash theme={null}
npx libretto cloud profiles list
```

`libretto cloud deploy` automatically registers any `authProfile` names declared
by deployed workflows. Hosted runs use the browser provider's native profile API;
local profile files are not uploaded.

Local profiles and cloud profiles are intentionally separate. There is no command
to copy a local profile file into a cloud profile, or to download a cloud profile
into `.libretto/profiles/`. Local profiles are Playwright storage-state JSON for
local runs; cloud profiles are provider-native browser profiles managed by the
hosted browser provider.

### cloud profiles delete

Delete a hosted auth profile name and its provider mappings:

```bash theme={null}
npx libretto cloud profiles delete twitter
```

***

### Profile storage

Profiles are stored in `.libretto/profiles/<profileName>.json`. They are:

* Machine-local, not shared across environments or team members.
* Git-ignored by default.
* Subject to session expiry. If authentication stops working, repeat the login-and-save flow.

<Note>
  Profiles contain session cookies that are effectively credentials. The
  `.libretto/` directory is already git-ignored, but double-check your
  `.gitignore` if you're storing Libretto state in a non-standard location.
</Note>


---
title: snapshot
url: "https://libretto-docs.vercel.app/docs/reference/cli/snapshot.md"
description: The snapshot command is the primary page observation tool. It captures a screenshot and prints a compact accessibility tree for the current page so agents can...
---

> Capture a screenshot and compact accessibility snapshot of the current page.

The `snapshot` command is the primary page observation tool. It captures a screenshot and prints a compact accessibility tree for the current page so agents can inspect page structure without sending page state to a separate analysis model.

Use `snapshot` before guessing at selectors, after workflow failures, and whenever the visible page state is unclear.

```bash theme={null}
npx libretto snapshot --session debug-example
```

### Arguments

<ParamField path="ref" type="string">
  Optional element ref to scope output to that subtree, for example `l16` or
  `e16`. Each call captures a fresh screenshot and accessibility tree, then
  scopes the printed tree to that ref.
</ParamField>

### Required flags

<ParamField path="--session" type="string" required>
  The session to snapshot.
</ParamField>

### Optional flags

<ParamField path="--page" type="string">
  Target a specific page ID within the session. Use `npx libretto pages --session <name>` to list page IDs when a popup or new tab is open. See [pages](/reference/cli/pages) for details.
</ParamField>

### What snapshot produces

For each run, `snapshot` saves a screenshot under `.libretto/sessions/<session>/snapshots/<snapshot-id>/page.png`.

The command prints:

* the screenshot path
* a compact accessibility tree for the page or requested subtree
* a hint for inspecting subtree refs from the latest full snapshot

### When to use snapshot

* Before writing a selector. Use snapshot to confirm the element structure rather than guessing.
* After a workflow step fails. Inspect the exact page state the failure left behind.
* When the page has rendered content that isn't obvious from the URL alone.
* When you need a scoped view of a large page. Run a full snapshot, then inspect relevant refs with `snapshot <ref>`.

<Tip>
  Use ref forms printed in the tree, such as `l16`. Numeric-suffix aliases such
  as `e16` also match `l16`.
</Tip>

### Configuration

`snapshot` does not require AI provider credentials or a configured model. The legacy `snapshotModel` config key is deprecated and ignored by the default snapshot flow.

### Examples

```bash theme={null}
# Basic snapshot
npx libretto snapshot --session debug-example

# Snapshot a specific page in a multi-page session
npx libretto snapshot --session debug-example --page <page-id>

# Inspect a subtree from the latest full snapshot
npx libretto snapshot <ref> --session debug-example
```

<CardGroup cols={2}>
  <Card title="exec" icon="code" href="/reference/cli/exec">
    Run Playwright code to prototype interactions after using snapshot to identify elements.
  </Card>

  <Card title="run & resume" icon="play" href="/reference/cli/run-and-resume">
    Run a full workflow file and use snapshot to debug failures.
  </Card>
</CardGroup>


---
title: exec
url: "https://libretto-docs.vercel.app/docs/reference/cli/exec.md"
description: "The exec command runs TypeScript Playwright code against a live browser session. Use it to validate selectors, inspect page data, prototype individual steps,..."
---

> Execute Playwright TypeScript in a persistent REPL for an open browser page.

The `exec` command runs TypeScript Playwright code against a live browser session. Use it to validate selectors, inspect page data, prototype individual steps, and experiment with interactions before encoding them in a workflow file.

`exec` evaluates code in a persistent REPL scoped to the browser session. Values come from expressions, top-level `await` is supported, and declarations remain available to later `exec` calls in the same session.

```bash theme={null}
npx libretto exec --session debug-example "await page.url()"
```

### Usage

<Tabs>
  <Tab title="Single-line">
    Pass the code as a quoted string argument:

    ```bash theme={null}
    npx libretto exec --session debug-example "await page.url()"
    npx libretto exec --session debug-example "await page.locator('button').count()"
    npx libretto exec --session debug-example "await page.locator('button:has-text(\"Continue\")').click()"
    ```
  </Tab>

  <Tab title="stdin (multi-line)">
    Pipe code into `exec -` for complex or multi-line scripts:

    ```bash theme={null}
    echo "await page.url()" | npx libretto exec - --session debug-example
    ```

    ```bash theme={null}
    cat <<'EOF' | npx libretto exec - --session debug-example
    async function tableRows(selector: string) {
      const rows = await page.locator(`${selector} tbody tr`).all();
      const data = [];
      for (const row of rows) {
        const cells = await row.locator('td').allTextContents();
        data.push(cells);
      }
      return data;
    }
    EOF

    npx libretto exec --session debug-example "await tableRows('table')"
    ```
  </Tab>
</Tabs>

### Available globals

Inside the code you pass to `exec`, these variables are available without any imports:

| Global          | Type             | Description                               |
| --------------- | ---------------- | ----------------------------------------- |
| `page`          | `Page`           | The active Playwright page                |
| `frame`         | `Frame`          | The active page's main frame              |
| `context`       | `BrowserContext` | The browser context for the session       |
| `browser`       | `Browser`        | The browser instance                      |
| `fetch`         | `typeof fetch`   | The global fetch function                 |
| `Buffer`        | `typeof Buffer`  | Node.js `Buffer`                          |
| `console`       | `Console`        | Standard console (`log`, `warn`, `error`) |
| `setTimeout`    | `function`       | Schedule a callback after a delay         |
| `setInterval`   | `function`       | Schedule a repeating callback             |
| `clearTimeout`  | `function`       | Cancel a scheduled `setTimeout`           |
| `clearInterval` | `function`       | Cancel a scheduled `setInterval`          |
| `URL`           | `typeof URL`     | The WHATWG `URL` constructor              |

### Persistent REPL

Each browser session has a persistent `exec` REPL. Define helper functions once, then reuse them in later calls:

```bash theme={null}
cat <<'EOF' | npx libretto exec - --session debug-example
async function textOf(selector: string) {
  return await page.locator(selector).textContent();
}
EOF

npx libretto exec --session debug-example "await textOf('h1')"
```

The `page` and `frame` globals are refreshed for each call, including calls that use `--page <page-id>`.

### Flags

<ParamField path="code" type="string" required>
  The Playwright TypeScript code to execute, passed as the first positional
  argument. Pass `-` to read from stdin.
</ParamField>

<ParamField path="--session" type="string" required>
  The session to execute against.
</ParamField>

<ParamField path="--page" type="string">
  Target a specific page ID within the session. Use `npx libretto pages --session <name>` to list page IDs when a popup or new tab is open.
</ParamField>

<ParamField path="--visualize" type="boolean">
  Enable ghost cursor and highlight visualization. Shows a visible cursor trace
  and element highlights as Playwright interacts with the page.
</ParamField>

<ParamField path="--diff-snapshot" type="boolean">
  After a successful `exec`, wait for the page to settle and print a compact
  accessibility-tree diff of what changed. Use this for exploratory mutations
  when you do not know what will change. Omit it when you already know what to
  check — return that value from `exec`, or run `snapshot` afterward.
</ParamField>

### Return values

If the code evaluates to a value, `exec` prints it to stdout:

* Strings are printed as-is.
* All other values are printed as pretty-printed JSON.
* If the code evaluates to `undefined`, `exec` prints `Executed successfully`.

```bash theme={null}
npx libretto exec --session debug-example "await page.title()"
# My Page Title

npx libretto exec --session debug-example "await page.locator('li').count()"
# 12
```

### Rules

<Warning>
  **Let failures throw.** Do not hide `exec` failures with `try/catch` or
  `.catch()`. Swallowed errors make debugging harder. Surface them so you can
  see exactly what failed.
</Warning>

<Warning>
  **Do not run multiple `exec` commands in parallel.** Each `exec` call drives
  the same browser page. Running them concurrently causes race conditions.
</Warning>

* Use `exec` for focused inspection and short-lived interaction experiments.
* Use `exec -` (stdin) for complex multi-line scripts.
* Validate a selector with `exec` before encoding it in a workflow file.
* Use `exec` to prototype a single step, then move the working code into your `.ts` workflow file.
* Do not use top-level `return`; write the expression you want to inspect.

### Examples

```bash theme={null}
# Print the current URL
npx libretto exec --session debug-example "await page.url()"

# Count how many buttons are on the page
npx libretto exec --session debug-example "await page.locator('button').count()"

# Click a button
npx libretto exec --session debug-example "await page.locator('button:has-text(\"Continue\")').click()"

# Read a value from an input field
npx libretto exec --session debug-example "await page.locator('input[name=\"email\"]').inputValue()"

# Run against a specific page in a session
npx libretto exec --session debug-example --page <page-id> "await page.url()"

# Pipe from stdin (multi-line, named session)
echo "await page.url()" | npx libretto exec - --session debug-example
```

<CardGroup cols={2}>
  <Card title="snapshot" icon="camera" href="/reference/cli/snapshot">
    Use snapshot to identify selectors before running exec.
  </Card>

  <Card title="run & resume" icon="play" href="/reference/cli/run-and-resume">
    Move validated exec code into a workflow file and run it end to end.
  </Card>
</CardGroup>


---
title: "run & resume"
url: "https://libretto-docs.vercel.app/docs/reference/cli/run-and-resume.md"
description: The run command executes the default export from a TypeScript workflow file against a live browser. Use it to verify a workflow after creating or editing it....
---

> Execute a workflow file and resume paused workflows.

### run

The `run` command executes the default export from a TypeScript workflow file against a live browser. Use it to verify a workflow after creating or editing it.

```bash theme={null}
npx libretto run ./integration.ts
npx libretto run ./integration.ts --headless
```

#### Syntax

```bash theme={null}
npx libretto run <file> [flags]
```

* `<file>`: path to the TypeScript workflow file. The file must have a default-exported `workflow()`.

#### Flags

<ParamField path="--headless" type="boolean">
  Run the browser in headless mode. Use this for the normal fix-and-verify loop.
</ParamField>

<ParamField path="--headed" type="boolean">
  Run the browser in headed (visible) mode. This is the default when neither
  flag is passed.
</ParamField>

<ParamField path="--session" type="string">
  Name for this run session. Auto-generated if omitted. Use an explicit name to
  target the session with `exec`, `snapshot`, or `resume` after a failure or
  pause.
</ParamField>

<ParamField path="--params" type="string">
  Inline JSON input for the workflow, passed as a quoted string. When the
  workflow uses Zod schemas, Libretto validates this input against
  `schemas.input` before the handler runs. Example:
  `--params '{"status":"open"}'`.
</ParamField>

<ParamField path="--params-file" type="string">
  Path to a JSON file to use as workflow input. Cannot be used together with
  `--params`. When the workflow uses Zod schemas, the file contents are
  validated against `schemas.input`.
</ParamField>

<ParamField path="--viewport" type="string">
  Viewport size in `WIDTHxHEIGHT` format, for example `1920x1080`. Falls back to
  `.libretto/config.json`, then `1366x768`.
</ParamField>

<ParamField path="--tsconfig" type="string">
  Path to a `tsconfig.json` file for module resolution during workflow
  compilation.
</ParamField>

<ParamField path="--no-visualize" type="boolean">
  Disable ghost cursor and element highlight visualization in headed mode.
</ParamField>

<ParamField path="--provider" type="string">
  Browser provider to use for this run. Must be `local`, `kernel`, `browserbase`,
  `steel`, or `libretto-cloud`. Overrides `LIBRETTO_PROVIDER` and the `provider` setting
  in `.libretto/config.json`. Cannot be combined with `--cdp`.
</ParamField>

<ParamField path="--cdp" type="string">
  Connect to an existing Chrome DevTools Protocol endpoint instead of launching a
  browser. Use this for Electron apps or Chrome started with
  `--remote-debugging-port`. Cannot be combined with `--provider`, `--headed`,
  `--headless`, or `--viewport`. Libretto attaches to the selected page without
  navigating to workflow `startUrl`, so the existing URL and authentication
  state stay intact. Closing the Libretto session disconnects from CDP and does
  not terminate the remote browser.
</ParamField>

<ParamField path="--page" type="string">
  When using `--cdp`, target a specific discovered page id such as `page-0` or
  `page-1`. Omit to use the default (last operational page). Requires `--cdp`.
</ParamField>

<Info>
  To keep local debugging and workflow runs on the same browser provider, set
  `provider` in `.libretto/config.json`. See [Alternative
  providers](/alternative-providers/overview) for Kernel, Browserbase, Steel, AWS, and
  GCP setup.
</Info>

#### Run against an external CDP browser

Use `--cdp` when the browser already exists (Electron, remote debugging Chrome, CI browser). Use interactive [`connect`](/reference/cli/open-and-connect#connect) to explore with `exec` / `snapshot`; use `run --cdp` to execute a workflow file against that same endpoint. Unlike a normal `run`, `--cdp` does not open workflow `startUrl` — put the browser on the page you want before the run, or navigate inside the handler.

```bash theme={null}
# Chrome or Electron started with --remote-debugging-port=9222
npx libretto run ./integration.ts --cdp http://127.0.0.1:9222

# Target a specific window/page (ids from `libretto pages` on a connect session)
npx libretto run ./integration.ts --cdp http://127.0.0.1:9222 --page page-1
```

#### Behavior on failure

When a workflow fails, Libretto keeps the browser open at the point of failure. You can then use `snapshot` and `exec` to inspect the live page state before editing the workflow code.

```bash theme={null}
# Workflow fails, browser stays open
npx libretto run ./integration.ts --session debug-flow --headed

# Inspect the failure state
npx libretto snapshot --session debug-flow

# Prototype a fix
npx libretto exec --session debug-flow "await page.locator('.error-message').textContent()"

# Re-run after fixing the code
npx libretto run ./integration.ts --headless
```

#### Validation loop

Prefer `run --headless` for the normal fix-and-verify loop. When the headless run passes, do a final headed run if you or the user wants to watch the finished workflow:

```bash theme={null}
# Fix/verify loop, fast, no visible browser
npx libretto run ./integration.ts --headless

# Final confirmation run, shows the browser
npx libretto run ./integration.ts --headed
```

#### Examples

```bash theme={null}
# Basic run
npx libretto run ./integration.ts

# Headless run with inline params
npx libretto run ./integration.ts --headless --params '{"status":"open"}'

# Run with a params file
npx libretto run ./integration.ts --params-file ./params.json

# Run with explicit session name for post-failure inspection
npx libretto run ./integration.ts --session debug-flow --headed

# Run against an external CDP / Electron browser
npx libretto run ./integration.ts --cdp http://127.0.0.1:9222
```

***

### resume

The `resume` command unpauses a workflow that has stopped at an `await pause(session)` call. Use it repeatedly until the workflow completes or pauses again at the next breakpoint.

```bash theme={null}
npx libretto resume --session debug-example
```

#### Flags

<ParamField path="--session" type="string" required>
  The session name of the paused workflow to resume.
</ParamField>

#### The `pause()` API

Insert `await pause(session)` calls in your workflow file to create interactive breakpoints, similar to debugger breakpoints in the browser flow. The workflow stops at each `pause()` call and waits for `resume` before continuing.

```typescript theme={null}
import { workflow, pause } from "libretto";
import { z } from "zod";

export default workflow(
  "myWorkflow",
  {
    input: z.object({}),
    output: z.object({
      done: z.boolean(),
    }),
  },
  async (ctx) => {
    const { session, page } = ctx;

    await page.goto("https://linkedin.com");

    // Pause here for inspection
    await pause(session);

    await page.locator("#submit").click();

    // Pause again before the final step
    await pause(session);

    return { done: true };
  },
);
```

<Note>
  `pause()` is a **no-op when `NODE_ENV === "production"`**. You can leave
  `pause()` calls in your workflow code during development and they will be
  silently skipped in production.
</Note>

#### Complete debugging flow

<Steps>
  <Step title="Run the workflow and let it fail (or pause)">
    ```bash theme={null}
    npx libretto run ./integration.ts --session debug-flow --headed
    ```

    If the workflow hits a `pause()`, it prints `Workflow paused.` and returns. The browser stays open.
  </Step>

  <Step title="Inspect the paused page state">
    ```bash theme={null}
    npx libretto snapshot --session debug-flow

    npx libretto exec --session debug-flow "await page.url()"
    ```
  </Step>

  <Step title="Resume the workflow">
    ```bash theme={null}
    npx libretto resume --session debug-flow
    ```

    Keep running `resume` until the workflow prints `Integration completed.` or fails with an error.
  </Step>

  <Step title="Fix the code and re-run">
    Edit the workflow file to fix any issues found during inspection, then re-run:

    ```bash theme={null}
    npx libretto run ./integration.ts --headless
    ```
  </Step>
</Steps>

<CardGroup cols={2}>
  <Card title="snapshot" icon="camera" href="/reference/cli/snapshot">
    Inspect page state after a failure or pause.
  </Card>

  <Card title="exec" icon="code" href="/reference/cli/exec">
    Prototype fixes against the paused browser before editing code.
  </Card>
</CardGroup>


---
title: session logs
url: "https://libretto-docs.vercel.app/docs/reference/cli/session-logs.md"
description: Libretto automatically records network requests and browser actions to JSONL log files during every session. Your agent reads these files directly to...
---

> Understand the JSONL log files Libretto captures during every session.

Libretto automatically records network requests and browser actions to JSONL log files during every session. Your agent reads these files directly to reverse-engineer APIs, reconstruct workflows from user behavior, and debug failures.

### Log file locations

```txt
.libretto/sessions/<session>/
  logs.jsonl       # CLI and system events
  network.jsonl    # HTTP requests and responses
  actions.jsonl    # User and agent browser actions
```

***

### network.jsonl

One JSON object per line, one entry per HTTP request/response captured while the session was open.

#### Key fields

| Field          | Description                                |
| -------------- | ------------------------------------------ |
| `ts`           | ISO timestamp of the request               |
| `method`       | HTTP method (`GET`, `POST`, `PUT`, etc.)   |
| `url`          | Full request URL                           |
| `status`       | HTTP response status code                  |
| `contentType`  | Response content type                      |
| `responseBody` | Response body string (may be `null`)       |
| `postData`     | Request body for POST/PUT requests         |
| `pageId`       | Playwright page ID that produced the entry |

#### Querying examples

```bash theme={null}
# All POST requests
jq 'select(.method == "POST")' .libretto/sessions/<session>/network.jsonl

# Find requests to a specific endpoint
jq 'select(.url | contains("/api/"))' .libretto/sessions/<session>/network.jsonl

# View JSON API responses
jq 'select(.contentType | contains("application/json")) | .responseBody' \
  .libretto/sessions/<session>/network.jsonl

# Count requests by HTTP method
jq -r '.method' .libretto/sessions/<session>/network.jsonl | sort | uniq -c
```

***

### actions.jsonl

One JSON object per line, one entry per browser interaction. Libretto records both actions you perform manually in the browser (`source: "user"`) and actions the agent performs programmatically (`source: "agent"`).

#### Key fields

| Field                  | Description                                                      |
| ---------------------- | ---------------------------------------------------------------- |
| `ts`                   | ISO timestamp                                                    |
| `source`               | `user` (manual DOM event) or `agent` (Playwright call)           |
| `action`               | Action name: `click`, `dblclick`, `fill`, `goto`, `reload`, etc. |
| `selector`             | Locator used by the agent                                        |
| `bestSemanticSelector` | Canonical selector from the user DOM event                       |
| `nearbyText`           | Visible text near the event target                               |
| `coordinates`          | `{x, y}` for pointer events                                      |
| `value`                | Typed, selected, or submitted value                              |
| `url`                  | Navigation target for goto-style actions                         |
| `duration`             | Elapsed time in milliseconds (agent entries)                     |
| `success`              | `true` if the action completed, `false` on failure               |
| `error`                | Error message when the action failed                             |

<Note>
  Agent entries describe what Playwright tried to do (`selector`, `duration`). User entries describe what DOM element was actually interacted with (`bestSemanticSelector`, `nearbyText`, `coordinates`).
</Note>

#### Querying examples

```bash theme={null}
# Last 20 actions
tail -n 20 .libretto/sessions/<session>/actions.jsonl | jq .

# Show only user-initiated actions
jq 'select(.source == "user")' .libretto/sessions/<session>/actions.jsonl

# Find all failed actions
jq 'select(.success == false)' .libretto/sessions/<session>/actions.jsonl

# Extract selector and action for agent entries
jq 'select(.source == "agent") | {action: .action, selector: .selector}' \
  .libretto/sessions/<session>/actions.jsonl
```

***

### logs.jsonl

Structured CLI and system log. Contains debug output from Libretto itself, including command execution, errors, and internal events.

```bash theme={null}
# View the last 20 log entries
tail -n 20 .libretto/sessions/<session>/logs.jsonl | jq .
```


---
title: pages
url: "https://libretto-docs.vercel.app/docs/reference/cli/pages.md"
description: "The pages command lists all open pages (tabs) in a session and prints their IDs and URLs. After running pages, pass the target page ID to other commands with..."
---

> List open browser tabs in a session and target specific pages.

### pages

The `pages` command lists all open pages (tabs) in a session and prints their IDs and URLs.

```bash theme={null}
npx libretto pages --session debug-example
```

#### When to use `pages`

* A popup or new tab opened during navigation or interaction.
* `exec` or `snapshot` fails because more than one page is open in the session.
* You are not sure which page holds the relevant state.

After running `pages`, pass the target page ID to other commands with `--page <page-id>`:

```bash theme={null}
# List pages
npx libretto pages --session debug-example

# Target a specific page
npx libretto exec --session debug-example --page <page-id> "await page.url()"
npx libretto snapshot --session debug-example --page <page-id>
```

#### Flags

<ParamField path="--session" type="string" required>
  The session to list pages for.
</ParamField>


---
title: Workflow
url: "https://libretto-docs.vercel.app/docs/reference/runtime/workflow.md"
description: "The 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..."
---

> Define and run reusable browser automation workflows.

The `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()`

Creates a named `LibrettoWorkflow` from input/output Zod schemas and a handler function.

```typescript theme={null}
function 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>;
```

The handler receives a `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`.

```typescript theme={null}
type LibrettoWorkflowHandler<Input, Output> = (
  ctx: LibrettoWorkflowContext,
  input: Input,
) => Promise<Output>;
```

#### `LibrettoWorkflowContext`

<ResponseField name="session" type="string">
  The session identifier for this workflow run. Use it to correlate logs and
  state files.
</ResponseField>

<ResponseField name="page" type="Page">
  A Playwright `Page` instance ready for navigation and interaction.
</ResponseField>

#### Full example

```typescript theme={null}
import { 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;
  },
);
```

#### Running with the CLI

Pass the file path to `npx libretto run`. The file must have a default-exported `workflow()`:

```bash theme={null}
npx libretto run ./my-workflow.ts
```

The CLI compiles the file with `tsx`, launches a Chromium browser, and calls your handler with the session context it constructs.

#### Browser launch options

Always declare `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`.

```typescript theme={null}
export 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.
  },
);
```

<ParamField path="startUrl" type="string">
  Entry URL for the workflow. Declare this on every workflow. When Libretto
  launches the browser, it opens this URL before the handler runs. On Kernel,
  Libretto sends it as `start_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.
</ParamField>

<ParamField path="gpu" type="boolean">
  Request a GPU-accelerated browser when the provider supports it. Prefer
  declaring this per workflow; GPU is expensive to leave on by default.
</ParamField>

<ParamField path="viewport" type="{ width: number; height: number }">
  Browser viewport for the session. Hosted jobs and Kernel provider runs use
  this when you do not pass `--viewport` on the CLI.
</ParamField>

These fields are stored in deploy metadata. Prefer declaring them on the
workflow rather than repeating flags on every job. Until the hosted executor
reads that metadata, pass the same values with
`libretto cloud jobs create --start-url ... --gpu --viewport WxH` (or the
matching schedule flags) when a hosted run needs them.

***

### `launchBrowser()`

Launches a Playwright Chromium browser and returns a ready-to-use `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.

```typescript theme={null}
async function launchBrowser(args: LaunchBrowserArgs): Promise<BrowserSession>;
```

#### `LaunchBrowserArgs`

<ParamField path="sessionName" type="string" required>
  A unique identifier for this browser session. Used to name the session state
  file written under `.libretto/sessions/`.
</ParamField>

<ParamField path="headless" type="boolean">
  Whether to run Chromium in headless mode. Defaults to `false` (visible
  window).
</ParamField>

<ParamField path="viewport" type="{ width: number; height: number }">
  The browser viewport size. Defaults to `{ width: 1366, height: 768 }`.
</ParamField>

<ParamField path="storageStatePath" type="string">
  Path to a Playwright storage state JSON file (cookies, localStorage). Useful
  for resuming authenticated sessions.
</ParamField>

#### `BrowserSession` return value

<ResponseField name="browser" type="Browser">
  The underlying Playwright `Browser` instance.
</ResponseField>

<ResponseField name="context" type="BrowserContext">
  The Playwright `BrowserContext` created for this session.
</ResponseField>

<ResponseField name="page" type="Page">
  The initial `Page` opened in the context. Pass this to your workflow handler.
</ResponseField>

<ResponseField name="debugPort" type="number">
  The remote debugging port Chromium is listening on.
</ResponseField>

<ResponseField name="metadataPath" type="string">
  Absolute path to the session state JSON file written by `launchBrowser`.
</ResponseField>

<ResponseField name="close" type="() => Promise<void>">
  Closes the browser and releases all resources.
</ResponseField>

#### Programmatic usage

```typescript theme={null}
import { 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()`

Pauses a running workflow so you can inspect browser state interactively, then resume from the CLI.

```typescript theme={null}
async function pause(session: string): Promise<void>;
```

<ParamField path="session" type="string" required>
  The session identifier. Must match the session name used when the workflow was
  started.
</ParamField>

<Note>
  `pause()` 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.
</Note>

When called in a non-production environment, `pause()` writes a `.paused` signal file and polls for a `.resume` signal. Use the Libretto CLI to send the resume signal:

```bash theme={null}
npx libretto resume --session <session-name>
```

#### Example

```typescript theme={null}
import { 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");
  },
);
```


---
title: Authentication
url: "https://libretto-docs.vercel.app/docs/reference/runtime/authentication.md"
description: "librettoAuthenticate is the runtime helper for workflows that combine an auth profile with scripted sign-in logic. After signIn runs, librettoAuthenticate..."
---

> Check whether a session is signed in and run scripted sign-in logic when it is not.

### `librettoAuthenticate()`

`librettoAuthenticate` is the runtime helper for workflows that combine an auth profile with scripted sign-in logic.

```typescript theme={null}
async function librettoAuthenticate(
  ctx: LibrettoWorkflowContext,
  options: LibrettoAuthenticateOptions,
): Promise<{ usedProfile: boolean }>;
```

#### Options

<ParamField path="isSignedIn" type="(ctx: LibrettoWorkflowContext) => Promise<boolean> | boolean" required>
  Checks whether the current browser page is already signed in. Return `true`
  only when the workflow can safely continue.
</ParamField>

<ParamField path="signIn" type="(ctx: LibrettoWorkflowContext, credentials: Record<string, string>) => Promise<void> | void" required>
  Performs the sign-in flow when `isSignedIn` returns `false`.
</ParamField>

<ParamField path="credentials" type="Record<string, unknown>">
  Credential values to pass to `signIn`. In workflows, pass
  `input.credentials` from declared workflow credentials.
</ParamField>

<ParamField path="envPrefix" type="string">
  Optional environment variable prefix used only when `credentials` is omitted.
  Workflow code should normally pass `input.credentials` instead.
</ParamField>

#### Return value

<ResponseField name="usedProfile" type="boolean">
  `true` when the initial `isSignedIn` check passed, meaning the saved profile
  was already signed in. `false` when Libretto ran the `signIn` step.
</ResponseField>

#### Example

```typescript theme={null}
import { librettoAuthenticate, workflow } from "libretto";

export default workflow("portalReport", {
  startUrl: "https://portal.example.com",
  credentials: ["username", "password"],
  authProfile: {
    name: "portal",
    refresh: true,
  },
  async handler(ctx, input) {
    const { page } = ctx;

    await librettoAuthenticate(ctx, {
      credentials: input.credentials,
      isSignedIn: async ({ page }) =>
        await page
          .getByRole("button", { name: /account|sign out/i })
          .isVisible()
          .catch(() => false),
      signIn: async ({ page }, credentials) => {
        await page.goto("https://portal.example.com/login");
        await page.getByLabel("Email").fill(credentials.username);
        await page.getByLabel("Password").fill(credentials.password);
        await page.getByRole("button", { name: /log in/i }).click();
      },
    });

    // Continue with authenticated workflow steps.
  },
});
```

After `signIn` runs, `librettoAuthenticate` calls `isSignedIn` again. If the session is still not signed in, it throws an error instead of letting the workflow continue in a logged-out state.

<Note>
  Pair `librettoAuthenticate` with `authProfile: { name, refresh: true }` when
  a successful sign-in should update the profile for future runs.
</Note>

When sign-in needs a portal SMS one-time code, use [`claimSmsOtp`](/reference/runtime/sms-otp) inside `signIn`.


---
title: SMS OTP
url: "https://libretto-docs.vercel.app/docs/reference/runtime/sms-otp.md"
description: "claimSmsOtp locks a tenant SMS inbox number, then returns a handle whose wait() method polls until the inbound code arrives. Use it when a portal texts a..."
---

> Claim a Libretto Cloud SMS inbox, then wait for an inbound one-time code during a portal login.

### `claimSmsOtp()`

`claimSmsOtp` locks a tenant SMS inbox number, then returns a handle whose `wait()` method polls until the inbound code arrives. Use it when a portal texts a one-time code. Prefer authenticator TOTP (`*_totp_secret`) when the portal supports it.

Provision inbox numbers outside the workflow (CLI or dashboard), register the full phone number on the portal MFA settings, then call `claimSmsOtp` in the sign-in path. See [SMS OTP on Libretto Cloud](/libretto-cloud-api/sms-otp) for pool APIs and isolation rules.

```typescript theme={null}
async function claimSmsOtp(options: ClaimSmsOtpOptions): Promise<SmsOtpClaim>;
```

Await the claim before clicking send-code so the lock is held when the SMS arrives. Call `otp.wait()` after send-code.

Auth is automatic — do not put an API key in workflow source:

* Local runs: set `LIBRETTO_API_KEY` (same key as deploy and CLI).
* Hosted Cloud jobs: dispatch injects a short-lived job token; `claimSmsOtp` picks it up with no extra args.

#### Options

Pass exactly one of `phoneNumberId`, `phoneNumberLabel`, or `phoneNumber` to select the inbox.

<ParamField path="phoneNumberId" type="string">
  Pool phone-number id from `/v1/smsNumbers`.
</ParamField>

<ParamField path="phoneNumberLabel" type="string">
  Label set when the inbox was provisioned (for example `uhc`). Prefer one
  number per portal.
</ParamField>

<ParamField path="phoneNumber" type="string">
  Full phone number with country code (for example `+15551234567`) from the
  tenant pool.
</ParamField>

<ParamField path="timeoutMs" type="number">
  How long to wait for the inbound SMS after the claim opens. Also sets the
  inbox claim lock (clamped to 30–300 seconds on the API). Defaults to
  `120000`.
</ParamField>

<ParamField path="pollIntervalMs" type="number">
  Poll interval while waiting for the code. Defaults to `1500`.
</ParamField>

<ParamField path="apiKey" type="string">
  Optional override. Prefer omitting this so local runs use
  `LIBRETTO_API_KEY` and hosted jobs use the injected job token.
</ParamField>

<ParamField path="apiUrl" type="string">
  Libretto Cloud API base URL. Defaults to `LIBRETTO_API_URL` or
  `https://api.libretto.sh`.
</ParamField>

#### Return value

<ResponseField name="phoneNumber" type="string">
  The claimed inbox number (with country code).
</ResponseField>

<ResponseField name="claimId" type="string">
  Id of the open claim.
</ResponseField>

<ResponseField name="phoneNumberId" type="string">
  Pool phone-number id for the claimed inbox.
</ResponseField>

<ResponseField name="wait" type="() => Promise<SmsOtpCode>">
  Polls until the claim is fulfilled, then returns `{ code, phoneNumber,
    claimId }` once. Expires the claim if the wait times out or errors.
</ResponseField>

#### Example

```typescript theme={null}
import { claimSmsOtp, workflow } from "libretto";

export default workflow("portal-login", {
  credentials: ["username", "password"],
  async handler(ctx, input) {
    // ... fill username/password, then:
    const otp = await claimSmsOtp({ phoneNumberLabel: "uhc" });
    // Inbox is locked. otp.phoneNumber is the claimed number.

    await ctx.page.click('button:has-text("Send code")');
    const { code } = await otp.wait();
    await ctx.page.fill('input[name="otp"]', code);
  },
});
```

<Note>
  Do not run concurrent SMS OTP claims on the same inbox number. A claim locks
  the inbox for up to 5 minutes; consuming the code unlocks it.
</Note>


---
title: Network requests
url: "https://libretto-docs.vercel.app/docs/reference/runtime/network-requests.md"
description: "Executes a fetch() call inside the browser context via page.evaluate(). Because the request runs inside the real browser process, it uses the browser's TLS..."
---

> Execute typed HTTP requests inside the browser context.

### `pageRequest()`

Executes a `fetch()` call inside the browser context via `page.evaluate()`. Because the request runs inside the real browser process, it uses the browser's TLS fingerprint, cookies, and session. It still looks like a fetch/XHR request, so use it for endpoints the site normally calls with fetch or XHR.

<Note>
  `pageRequest()` is not a replacement for every browser request. For page HTML,
  navigate with `page.goto()` or click a link. For images, scripts, stylesheets,
  and iframes, let the DOM load the resource naturally when possible.
</Note>

```typescript theme={null}
async function pageRequest<T extends z.ZodType | undefined = undefined>(
  page: Page,
  config: RequestConfig,
  options?: PageRequestOptions<T>,
): Promise<T extends z.ZodType ? z.infer<T> : any>;
```

Returns `z.infer<T>` when a `schema` is provided in `options`, or `any` otherwise.

<Note>
  Non-2xx responses cause `pageRequest` to throw an error. The error message
  includes the HTTP method, URL, and status code.
</Note>

#### `RequestConfig`

<ParamField path="url" type="string" required>
  The URL to fetch. Relative URLs are resolved in the browser context.
</ParamField>

<ParamField path="method" type="&#x22;GET&#x22; | &#x22;POST&#x22; | &#x22;PUT&#x22; | &#x22;DELETE&#x22; | &#x22;PATCH&#x22;">
  HTTP method. Defaults to `"GET"`.
</ParamField>

<ParamField path="headers" type="Record<string, string>">
  Additional request headers to merge with any that `bodyType` adds
  automatically.
</ParamField>

<ParamField path="body" type="Record<string, any> | string">
  Request body. When `bodyType` is `"json"` (the default), objects are
  serialized with `JSON.stringify`. When `bodyType` is `"form"`, objects are
  encoded as `application/x-www-form-urlencoded`.
</ParamField>

<ParamField path="bodyType" type="&#x22;json&#x22; | &#x22;form&#x22;">
  How to serialize the `body`. Defaults to `"json"`. Setting this to `"form"`
  also sets `Content-Type: application/x-www-form-urlencoded` automatically.
</ParamField>

<ParamField path="responseType" type="&#x22;json&#x22; | &#x22;text&#x22; | &#x22;xml&#x22;">
  How to parse the response. Defaults to `"json"`. Both `"text"` and `"xml"`
  return the raw response text.
</ParamField>

#### `PageRequestOptions<T>`

<ParamField path="logger" type="MinimalLogger">
  Optional logger. When provided, request details (method, URL, status,
  duration) are logged at `info` level, and failures at `warn`.
</ParamField>

<ParamField path="schema" type="T extends z.ZodType">
  Optional Zod schema. When provided, the parsed response body is validated with `schema.parse()` before being returned. The return type of `pageRequest` becomes `z.infer<T>`.
</ParamField>

#### Example

```typescript theme={null}
import { pageRequest } from "libretto";
import { z } from "zod";

const ResultSchema = z.object({
  items: z.array(z.object({ id: z.string(), name: z.string() })),
  total: z.number(),
});

const data = await pageRequest(
  page,
  {
    url: "/api/search",
    method: "POST",
    body: { query: "example", page: 1 },
  },
  { schema: ResultSchema },
);
// data is typed as { items: Array<{ id: string; name: string }>; total: number }
```


---
title: File downloads
url: "https://libretto-docs.vercel.app/docs/reference/runtime/file-downloads.md"
description: "When a click triggers a file download in the browser, Playwright can intercept it before the browser handles it natively. This is especially useful for files..."
---

> Intercept file downloads triggered by clicking DOM elements.

When a click triggers a file download in the browser, Playwright can intercept it before the browser handles it natively. This is especially useful for files like PDFs that the browser would otherwise open in a new tab instead of downloading. `downloadViaClick` captures the file contents as a buffer so your workflow can process the file directly without relying on browser rendering behavior.

### `downloadViaClick()`

Clicks a DOM element and intercepts the resulting download using Playwright's download event. The download listener is registered **before** the click so the event is never missed.

```typescript theme={null}
async function downloadViaClick(
  page: Page,
  selector: string,
  options?: DownloadViaClickOptions,
): Promise<DownloadResult>;
```

#### Parameters

<ParamField path="page" type="Page" required>
  The Playwright `Page` to operate on.
</ParamField>

<ParamField path="selector" type="string" required>
  CSS selector for the element that triggers the download when clicked.
</ParamField>

<ParamField path="options" type="DownloadViaClickOptions">
  <Expandable title="properties">
    <ParamField path="logger" type="MinimalLogger">
      Optional logger. Logs download filename, size, and duration on completion.
    </ParamField>

    <ParamField path="timeout" type="number">
      Milliseconds to wait for the download event. Defaults to `30000`.
    </ParamField>
  </Expandable>
</ParamField>

#### `DownloadResult`

<ResponseField name="buffer" type="Buffer">
  The raw file contents.
</ResponseField>

<ResponseField name="filename" type="string">
  The filename suggested by the server via the `Content-Disposition` header or
  the URL.
</ResponseField>

#### Example

```typescript theme={null}
import { downloadViaClick } from "libretto";

const { buffer, filename } = await downloadViaClick(page, "#export-btn", {
  logger,
  timeout: 60_000,
});

console.log(`Downloaded ${filename} (${buffer.length} bytes)`);
```


---
title: Runtime recovery actions
url: "https://libretto-docs.vercel.app/docs/reference/runtime/page-fallbacks.md"
description: "recoveryAction lets a workflow recover from a supported Playwright Page or Locator failure. When a supported action fails, Libretto runs the recovery action..."
---

`recoveryAction` lets a workflow recover from a supported Playwright Page or Locator failure. When a supported action fails, Libretto runs the recovery action and retries the original action once.

```typescript theme={null}
export default workflow("reviewOrder", {
  input,
  output,
  recoveryAction,
  handler: async ({ page }, params) => {
    await page.goto(params.url);
    await page.locator("#review").click();
    return { complete: true };
  },
});
```

## computerUseRecoveryAction

`computerUseRecoveryAction()` runs a small vision agent with your instruction. It screenshots the viewport, asks the model for a browser action, executes it with Playwright coordinates, and stops when the model returns `done` or `maxSteps` is reached.

One step is one screenshot, one model decision, and one browser action. The default `maxSteps` is `3`, which covers the common popup flow of close, confirm, then done.

```typescript theme={null}
import { computerUseRecoveryAction } from "libretto";

const recoveryAction = computerUseRecoveryAction({
  provider: "openai",
  apiKey: process.env.OPENAI_API_KEY!,
  model: "gpt-5.5",
  instruction: "Close any visible blocker that prevents submitting the form.",
  maxSteps: 3,
});
```

Supported provider shortcuts:

```typescript theme={null}
{ provider: "openai", apiKey, model?: "gpt-5.5" }
{ provider: "anthropic", apiKey, model?: "claude-sonnet-4-6" }
```

Advanced callers can pass a preconfigured AI SDK `LanguageModel` with `languageModel`.

## popupRecoveryAction

`popupRecoveryAction()` is a preset for the common popup case. It uses Libretto's default instruction for closing popups, cookie banners, modals, overlays, and similar blockers.

```typescript theme={null}
import { popupRecoveryAction } from "libretto";

const recoveryAction = popupRecoveryAction({
  provider: "anthropic",
  apiKey: process.env.ANTHROPIC_API_KEY!,
  model: "claude-sonnet-4-6",
  maxSteps: 3,
});
```

## Custom recovery logic

Use a custom `RecoveryAction` when the site needs deterministic recovery logic, or when it needs to combine `popupRecoveryAction()` with other steps such as reloading the page.

```typescript theme={null}
import {
  popupRecoveryAction,
  type RecoveryAction,
} from "libretto";

const popupRecoveryAgent = popupRecoveryAction({
  provider: "openai",
  apiKey: process.env.OPENAI_API_KEY!,
  model: "gpt-5.5",
});

const recoveryAction: RecoveryAction = async (context) => {
  const result = await popupRecoveryAgent(context);
  if (result.status === "incomplete") {
    await context.page.reload();
  }
  return result;
};
```

If the recovery action returns without throwing, Libretto retries the original failed method once.


---
title: Playwright debugging agent
url: "https://libretto-docs.vercel.app/docs/reference/runtime/playwright-debugger.md"
description: Investigate failures and automatically open pull requests to fix Playwright scripts as they break.
---

The Playwright debugging agent investigates failed browser automations and
automatically opens pull requests that fix broken scripts.

`libretto-playwright-debugger` lets an existing Playwright script keep its current
browser provider, retries, logging, and fallback logic while adding one agent
call in the failure path.

Install it in the project that runs the Playwright automation:

```bash
pnpm add libretto-playwright-debugger
```

```typescript
import { createPlaywrightDebugger } from "libretto-playwright-debugger";

const playwrightDebugger = createPlaywrightDebugger({
  github: {
    owner: "acme",
    repo: "automations",
    baseBranch: "main",
  },
  agent: {
    model: "openai/gpt-5.4",
  },
});
```

The `agent.model` value uses Libretto's `provider/model-id` convention. For
example, `openai/gpt-5.4` resolves to provider `openai` and model id `gpt-5.4`.
The first version supports `openai/...` and `anthropic/...`.

Initialize this debugger once at module scope. At the existing failure point,
await `debugFailure()` with the error and the live `Page` before Playwright
teardown runs:

```typescript
try {
  await runAutomation(page);
} catch (error) {
  await playwrightDebugger.debugFailure(error, page);
  // Existing fallback and logging stay here.
  throw error;
}
```

Keep the existing automation, retry, fallback, logging, and rethrow behavior in
place around the new call.

## What It Captures

`debugFailure()` captures the error message, stack trace, page URL, page title,
screenshot, and DOM snapshot from the failed page. It uses the stack frames —
plus any paths you pass in `includeFiles` — to read the relevant source files
from GitHub at `baseBranch`.

```typescript
await playwrightDebugger.debugFailure(error, page, {
  includeFiles: ["src/workflows/acme.ts"],
});
```

`includeFiles` is optional. When the error stack references files inside the
repository, the debugger discovers them automatically; pass `includeFiles` when
the stack does not cover the file that needs the fix (for example bundled or
minified stacks, or a cross-file change).

## How It Investigates

The debugger does not guess a fix from a static snapshot. It runs an agent loop
built on the [`libretto-browser-tools`](https://www.npmjs.com/package/libretto-browser-tools)
SDK: the model is given the failure context and source files, then drives a real
browser to confirm the root cause before proposing a change. It stays attached
to the supplied failed `Page`, preserving its authentication, storage, routes,
open tabs, and in-memory state. It can:

* `browser_status` the existing pages in the failed browser context,
* `browser_snapshot` the live accessibility tree (and a screenshot), and
* `browser_exec` Playwright code against the page — for example counting a
  selector's matches to check whether it actually resolves.

Only once the live page confirms the cause does the agent submit full-file
replacements. The debugger writes those files to a new branch with the GitHub
Git API and opens a pull request whose body cites the observed evidence. If the
evidence is insufficient for a safe fix, it opens no PR and returns
`status: "no_changes"`.

The debugger does not launch or close a second browser. Debugger infrastructure
failures resolve with `status: "debugger_failed"` so the caller's existing
fallback and original error handling can continue.

## Model Keys

Set the provider API key in the environment where the Playwright script runs:

```bash
export OPENAI_API_KEY=...
# or
export ANTHROPIC_API_KEY=...
```

You can also pass `agent.apiKey` directly when the key is provided by your
secrets manager.

## GitHub Authentication

The debugger uses Libretto's public GitHub App through Libretto Cloud.

First use the [Libretto setup flow](https://libretto.sh/setup) to connect the
repository. The flow verifies the GitHub user can access the installed Libretto
GitHub App and stores the tenant/repository link. Then provide:

```bash
export LIBRETTO_API_KEY=...
```

With that setup, the debugger asks Libretto Cloud for a short-lived GitHub
installation token scoped to the configured `owner/repo` and only the
permissions needed to read and write contents and open pull requests.


---
title: Quickstart
url: "https://libretto-docs.vercel.app/docs/browser-tools/quickstart.md"
description: "libretto-browser-tools is an SDK that gives any agent framework tools to open a real browser (local or cloud), inspect pages via accessibility snapshots, and..."
---

> Install `libretto-browser-tools` and run a first agent browser loop.

`libretto-browser-tools` is an SDK that gives any agent framework tools to open a real browser (local or cloud), inspect pages via accessibility snapshots, and drive them with Playwright code. Pick a [provider](/browser-tools/providers/overview), use the [AI SDK](/browser-tools/adapters/ai-sdk), [Pi](/browser-tools/adapters/pi), or [MCP](/browser-tools/adapters/mcp) adapter (or use the framework-agnostic factory), and call `dispose()` when the loop ends.

It is not the Libretto CLI and does not run workflow files. For CLI workflows, see [Quickstart](/get-started/quickstart).

## Agent prompt

Use this prompt with a coding agent to set up the Browser Tools SDK:

```text
Install libretto-browser-tools with the Vercel AI SDK adapter and LocalBrowserProvider. Wire createAiSdkBrowserTools into a generateText (or ToolLoopAgent) loop, install Playwright Chromium for local sessions, run a short task that opens a page and answers a question about it, and always call dispose() when the loop ends.
```

## Manual

Install the package into the Node.js project that will run your agent. Use your package manager's equivalent of:

```bash theme={null}
pnpm add libretto-browser-tools ai @ai-sdk/anthropic
npx playwright install chromium
```

Local sessions need a Chromium binary (`LocalBrowserProvider`). Cloud providers attach over CDP and do not need a local browser download.

`ai` is an optional peer dependency. Install it when you use the [AI SDK adapter](/browser-tools/adapters/ai-sdk).

<Steps>
  <Step title="Create the browser toolkit">
    ```typescript theme={null}
    import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
    import { LocalBrowserProvider } from "libretto-browser-tools";

    const { tools, dispose } = createAiSdkBrowserTools(
      new LocalBrowserProvider({ headless: true }),
    );
    ```

    The agent can call `browser_open`, `browser_connect`, `browser_exec`, `browser_snapshot`, `browser_status`, and `browser_close`.
  </Step>

  <Step title="Run a short agent loop">
    ```typescript theme={null}
    import { anthropic } from "@ai-sdk/anthropic";
    import { generateText, stepCountIs } from "ai";

    try {
      const result = await generateText({
        model: anthropic("claude-sonnet-4-5"),
        tools,
        stopWhen: stepCountIs(20),
        prompt: "Go to news.ycombinator.com and tell me the top story",
      });
      console.log(result.text);
    } finally {
      await dispose(); // close any sessions the agent left open
    }
    ```

    Always call `dispose()` when the loop ends. Prefer a `finally` block so sessions close after failures too.
  </Step>
</Steps>

<Card title="Next: Tools" icon="arrow-right" href="/browser-tools/tools">
  Learn the inputs and outputs for each browser tool.
</Card>

## What to try next

* Swap `LocalBrowserProvider` for a [cloud provider](/browser-tools/providers/overview).
* Reuse logins across sessions with [auth profiles](/browser-tools/auth-profiles).
* Restrict navigation with [domain policy](/browser-tools/advanced#domain-policy).


---
title: Tools
url: "https://libretto-docs.vercel.app/docs/browser-tools/tools.md"
description: "Every tool returns the same envelope: { ok: true, ...payload } on success, or { ok: false, error } for model-fixable failures (unknown session ID, thrown..."
---

> Reference for the six browser tools agents call.

Every tool returns the same envelope: `{ ok: true, ...payload }` on success, or `{ ok: false, error }` for model-fixable failures (unknown session ID, thrown Playwright code). Errors include a next step when possible. Host misconfiguration (missing API keys) and [domain policy](/browser-tools/advanced#domain-policy) violations throw instead of returning `{ ok: false }`.

## browser\_open

Open a new browser session through the configured provider. Optionally opens `url` at session create (providers that support preload) or after connect. Returns a `sessionId` for later tools.

<ParamField path="url" type="string">
  Optional start URL for the session. Use a full `https://` URL, or omit `url` and navigate with `browser_exec` via `await page.goto(...)`. Kernel and Libretto Cloud can open this before CDP attach; other providers navigate after connect.
</ParamField>

<ParamField path="authProfile" type="string | false">
  Named browser state for signed-in sessions. On supported providers, omit to use `default`, or pass `false` for a session with no saved state. See [Auth profiles](/browser-tools/auth-profiles).
</ParamField>

Success: `{ ok: true, sessionId }`.

If navigation fails, the session is closed and the tool returns `{ ok: false, error }` so the agent can open again.

## browser\_connect

Attach to an already-running browser via its CDP websocket URL. Returns a session ID like `browser_open`, but `browser_close` detaches without killing the externally managed browser.

<ParamField path="cdpUrl" type="string" required>
  CDP websocket URL, for example `ws://127.0.0.1:9222/devtools/browser/...`.
</ParamField>

Success: `{ ok: true, sessionId }`.

Use this for Electron apps or any Chromium already started with remote debugging. See [CDP attach](/browser-tools/advanced#cdp-attach).

## browser\_exec

Run Playwright code against an open session. The code runs as the body of a fresh async function — use `return` to produce a result.

Nothing persists between calls (no variables, no imports). The browser is the only state. In scope: `page`, `context`, `browser`. TypeScript is fine (stripped with Sucrase). `console.log` / `console.error` are captured as `stdout` / `stderr`.

Results are JSON-serialized when possible; values that cannot be serialized become strings.

<ParamField path="sessionId" type="string" required>
  Session ID from `browser_open` or `browser_connect`.
</ParamField>

<ParamField path="code" type="string" required>
  Playwright/TypeScript body to run. Example: `return await page.title()`.
</ParamField>

<ParamField path="pageId" type="string">
  Optional page ID from `browser_status`. Defaults to the most recently opened tab.
</ParamField>

<ParamField path="diffSnapshot" type="boolean">
  When `true`, capture a compact accessibility-tree baseline before the exec,
  wait for the page to settle after success, then return `snapshotDiff` against
  a fresh after-tree. Use for exploratory mutations when you do not know what
  will change. Defaults to off.
</ParamField>

<ParamField path="timeoutMs" type="number">
  Max wall-clock time to wait for this exec in milliseconds. Defaults to
  `10000` (10 seconds). A timeout returns `{ ok: false, error }` and stops
  waiting — it does not cancel in-flight Playwright work. Raise `timeoutMs`,
  simplify the code, or call `browser_close` then `browser_open` if the
  session may still be running the timed-out work.
</ParamField>

Success:

```typescript theme={null}
{
  ok: true,
  result: unknown,
  stdout: string,
  stderr: string,
  snapshotDiff: string, // set when diffSnapshot is true; empty when off or unchanged
}
```

Code-level failures return `{ ok: false, error, stdout?, stderr? }` — fix the code and retry.

### Snapshot vs snapshotDiff

* Call `browser_snapshot` to orient on a full accessibility tree before interacting, or to verify state after a change.
* Pass `diffSnapshot: true` on `browser_exec` when you want a page-change diff after an exploratory mutation. Prefer that before taking another full snapshot when you do not know what changed.

## browser\_snapshot

Capture the current page as a compact text accessibility tree. Nodes include `ref` handles (for example `l7`) so the agent can refer to structure while writing Playwright locators in `browser_exec`.

<ParamField path="sessionId" type="string" required>
  Session ID from `browser_open` or `browser_connect`.
</ParamField>

<ParamField path="screenshot" type="boolean">
  When `true`, also return a PNG as `{ base64, mimeType: "image/png" }`. Framework adapters (AI SDK, Pi) can surface that image to the model.
</ParamField>

<ParamField path="pageId" type="string">
  Optional page ID from `browser_status`. Defaults to the most recently opened tab.
</ParamField>

Success: `{ ok: true, tree, screenshot? }`.

## browser\_status

Inspect open sessions and pages. Start here when the agent is unsure which tab to target.

| Args                   | Returns                                                                                                    |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| (none)                 | `{ ok: true, sessions }` — each session lists `sessionId`, `provider`, `pages`, and `authProfile` when set |
| `sessionId`            | `{ ok: true, pages }` for that session                                                                     |
| `sessionId` + `pageId` | Page detail: `url`, `title`, `viewport`, `active`, `readyState`                                            |

<ParamField path="sessionId" type="string">
  Session ID from `browser_open` or `browser_connect`.
</ParamField>

<ParamField path="pageId" type="string">
  Page ID from a prior `browser_status` call. Requires `sessionId`.
</ParamField>

## browser\_close

Close a session by ID. For provider-owned sessions this releases the remote browser. For `browser_connect` sessions it detaches without killing the external browser.

<ParamField path="sessionId" type="string" required>
  Session ID to close.
</ParamField>

Success: `{ ok: true }`.

Calling `dispose()` on the toolkit closes every session that toolkit still owns.


---
title: Auth profiles
url: "https://libretto-docs.vercel.app/docs/browser-tools/auth-profiles.md"
description: "Auth profiles are named browser state for Browser Tools sessions. Pass a profile name to browser_open, sign in once, close cleanly, and later sessions with the..."
---

> Preserve signed-in browser state across agent sessions.

Auth profiles are named browser state for Browser Tools sessions. Pass a profile name to [`browser_open`](/browser-tools/tools#browser_open), sign in once, close cleanly, and later sessions with the same name start already logged in.

```text theme={null}
# First run — agent opens a profiled session; human or agent completes login
browser_open({ url: "https://portal.example.com", authProfile: "work" })
# → { ok: true, sessionId: "ses-1" }

browser_close({ sessionId: "ses-1" })
# → profile "work" saved

# Later run — same profile, already signed in
browser_open({ url: "https://portal.example.com", authProfile: "work" })
# → { ok: true, sessionId: "ses-2" }
```

## Defaults

On providers that support profiles:

| `authProfile`                 | Behavior                              |
| ----------------------------- | ------------------------------------- |
| omitted                       | Uses `default`                        |
| `"work"` (any non-empty name) | Loads or creates that named profile   |
| `false`                       | Opens without saved state (incognito) |

[`browser_status`](/browser-tools/tools#browser_status) includes `authProfile` on sessions that have one.

## Sign in once

1. Open a headed session with a profile name (`LocalBrowserProvider` defaults to headed).
2. Complete login in the browser window, or with [`browser_exec`](/browser-tools/tools#browser_exec).
3. Call `browser_close` or toolkit `dispose()` so the provider saves profile changes.
4. Open again later with the same name — the session starts from that saved state.

Profile changes persist only after a clean close. A crash does not write half-updated state.

## Provider support

| Provider                                                  | Named auth profiles |
| --------------------------------------------------------- | ------------------- |
| [Local](/browser-tools/providers/local)                   | Supported           |
| [Libretto Cloud](/browser-tools/providers/libretto-cloud) | Supported           |
| [Kernel](/browser-tools/providers/kernel)                 | Supported           |
| [Browser Use](/browser-tools/providers/browser-use)       | Supported           |
| [Browserbase](/browser-tools/providers/browserbase)       | Unsupported         |
| [Steel](/browser-tools/providers/steel)                   | Unsupported         |

Browserbase and Steel expose provider IDs, but not durable names. On unsupported providers, omit `authProfile` or pass `false`. Passing a profile name returns `{ ok: false, error }` with a next step.

## Local storage

Local profiles live under `~/.libretto/browser-tools/profiles/<name>/` by default. Pass `authProfileDirectory` to `LocalBrowserProvider` to change the root:

```typescript theme={null}
new LocalBrowserProvider({
  authProfileDirectory: "/path/to/profiles",
});
```

Cloud providers that support profiles keep provider-native state behind the same `authProfile` string.

## Caveats

* Profiles hold signed-in browser state. Local profiles are full Chromium user data directories and can include cookies, local storage, IndexedDB, saved credentials, extensions, and settings.
* Do not commit profile directories or share them across untrusted users.
* Do not open concurrent writable sessions against the same profile.
* This is the Browser Tools harness surface (`browser_open` / MCP). For Libretto workflow `authProfile`, see [Website authentication](/understand-libretto/website-authentication).


---
title: Advanced
url: "https://libretto-docs.vercel.app/docs/browser-tools/advanced.md"
description: Pass allowedDomains and/or blockedDomains into the toolkit factory to restrict http(s) navigations in the provider-managed browser context. Matching is...
---

> Domain policy, borrowed pages, and CDP attach.

## Domain policy

Pass `allowedDomains` and/or `blockedDomains` into the toolkit factory to restrict http(s) navigations in the provider-managed browser context.

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { LocalBrowserProvider } from "libretto-browser-tools";

const { tools, dispose } = createAiSdkBrowserTools(new LocalBrowserProvider(), {
  allowedDomains: ["example.com", "*.example.com"],
  blockedDomains: ["ads.example.com"],
});
```

Matching is hostname-based. Patterns may be exact hosts or `*.suffix` wildcards. Non-http(s) URLs are not filtered.

Blocked navigations throw `DomainPolicyRestricted` (not `{ ok: false }`) so the host can inspect `attemptedNavigationUrl` and the configured policy.

Domain policy is not a sandbox for `browser_exec` code and does not cover caller-created browser contexts outside the toolkit.

## Borrowed pages

When you already have a Playwright `Page` (for example inside a failing automation), attach tools without owning the browser lifecycle:

```typescript theme={null}
import { createBrowserToolsForPage } from "libretto-browser-tools";
// or: createAiSdkBrowserToolsForPage from "libretto-browser-tools/ai-sdk"

const { sessionId, tools, dispose } = createBrowserToolsForPage(page);
// tools: browser_exec, browser_snapshot, browser_status only
await dispose(); // detaches tools; does not close page / context / browser
```

`libretto-playwright-debugger` uses this pattern so a debugging agent can inspect and drive the live failed page. See [Playwright debugging agent](/reference/runtime/playwright-debugger).

## CDP attach

Use `browser_connect` when Chromium (or an Electron app) is already running with a remote-debugging endpoint:

```text theme={null}
# Agent tool call
browser_connect({
  cdpUrl: "ws://127.0.0.1:9222/devtools/browser/<id>",
})
# → { ok: true, sessionId: "ses-1" }
```

Closing that session detaches Playwright and leaves the external browser running. Prefer `browser_open` when the provider should create and tear down the browser for you.

For a provider that allocates browsers for you, see [Custom providers](/browser-tools/providers/custom).


---
title: Vercel AI SDK
url: "https://libretto-docs.vercel.app/docs/browser-tools/adapters/ai-sdk.md"
description: Import from libretto-browser-tools/ai-sdk. Install the optional peer dependency ai (v6+) alongside a model provider package. Wraps the six browser tools as AI...
---

> Use browser tools with the Vercel AI SDK.

Import from `libretto-browser-tools/ai-sdk`. Install the optional peer dependency `ai` (v6+) alongside a model provider package.

```bash theme={null}
pnpm add libretto-browser-tools ai @ai-sdk/anthropic
npx playwright install chromium
```

## createAiSdkBrowserTools

Wraps the six browser tools as AI SDK tools for `generateText` / `streamText` / `ToolLoopAgent`.

```typescript theme={null}
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, stepCountIs } from "ai";
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { LocalBrowserProvider } from "libretto-browser-tools";

const { tools, dispose } = createAiSdkBrowserTools(
  new LocalBrowserProvider({ headless: true }),
  // optional: { allowedDomains: ["example.com"] }
);

try {
  const result = await generateText({
    model: anthropic("claude-sonnet-4-5"),
    tools,
    stopWhen: stepCountIs(20),
    prompt: "Go to example.com and tell me the page title",
  });
  console.log(result.text);
} finally {
  await dispose();
}
```

`tools` is a `ToolSet` keyed by tool name (`browser_open`, `browser_exec`, and the rest). Pass domain policy options as the second argument — see [Advanced](/browser-tools/advanced#domain-policy).

Swap `LocalBrowserProvider` for any [provider](/browser-tools/providers/overview).

## Screenshots

When the agent calls `browser_snapshot` with `screenshot: true`, the adapter uses `toModelOutput` so the model receives the accessibility tree as text plus the PNG as image content. Without `screenshot: true`, only the tree text is returned.

## Borrowed pages

Attach tools to an existing Playwright `Page` without owning browser lifecycle:

```typescript theme={null}
import { createAiSdkBrowserToolsForPage } from "libretto-browser-tools/ai-sdk";

const { sessionId, tools, dispose } = createAiSdkBrowserToolsForPage(page);
// tools: browser_exec, browser_snapshot, browser_status
await dispose(); // detaches tools; does not close the page
```

Used by [libretto-playwright-debugger](/reference/runtime/playwright-debugger).

## See also

* [Tools](/browser-tools/tools) — inputs and outputs for each tool
* [Custom adapters](/browser-tools/adapters/custom) — wrap `createBrowserTools` for another framework


---
title: Pi
url: "https://libretto-docs.vercel.app/docs/browser-tools/adapters/pi.md"
description: "Import from libretto-browser-tools/pi. Install the optional peer dependency @earendil-works/pi-coding-agent. Wraps the six browser tools as Pi ToolDefinitions...."
---

> Use browser tools with the Pi coding agent.

Import from `libretto-browser-tools/pi`. Install the optional peer dependency `@earendil-works/pi-coding-agent`.

```bash theme={null}
pnpm add libretto-browser-tools @earendil-works/pi-coding-agent
npx playwright install chromium
```

## createPiBrowserTools

Wraps the six browser tools as Pi `ToolDefinition`s. Pass them to `createAgentSession` as `customTools`.

```typescript theme={null}
import {
  AuthStorage,
  createAgentSession,
  ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import { createPiBrowserTools } from "libretto-browser-tools/pi";
import { LocalBrowserProvider } from "libretto-browser-tools";

const { tools, dispose } = createPiBrowserTools(
  new LocalBrowserProvider({ headless: true }),
  // optional: { allowedDomains: ["example.com"] }
);

const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const model = modelRegistry.find("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Model not found");

try {
  const { session } = await createAgentSession({
    model,
    authStorage,
    modelRegistry,
    customTools: tools,
  });

  await session.prompt("Go to example.com and tell me the page title");
} finally {
  await dispose();
}
```

`tools` is an array of Pi tool definitions (not a keyed object). Tool execution is sequential. Pass domain policy options as the second argument — see [Advanced](/browser-tools/advanced#domain-policy).

Swap `LocalBrowserProvider` for any [provider](/browser-tools/providers/overview).

## Screenshots

When the agent calls `browser_snapshot` with `screenshot: true`, the adapter returns JSON text for the tool result plus a Pi image content part (`type: "image"`) with the PNG bytes. Without a screenshot, only JSON text is returned.

## See also

* [Tools](/browser-tools/tools) — inputs and outputs for each tool
* [Custom adapters](/browser-tools/adapters/custom) — wrap `createBrowserTools` for another framework


---
title: Model Context Protocol
url: "https://libretto-docs.vercel.app/docs/browser-tools/adapters/mcp.md"
description: "Install Chromium once, then point any MCP client at the package binary: Equivalent forms: npx -y libretto-browser-tools mcp, npx -y libretto-browser-tools..."
---

> Expose browser tools to MCP clients and gateways.

## Quickstart (stdio binary)

Install Chromium once, then point any MCP client at the package binary:

```bash theme={null}
npx playwright install chromium
```

```json theme={null}
{
  "mcpServers": {
    "libretto-browser-tools": {
      "command": "npx",
      "args": ["-y", "libretto-browser-tools"]
    }
  }
}
```

Equivalent forms: `npx -y libretto-browser-tools mcp`, `npx -y libretto-browser-tools --headed` for a visible local browser, or `npx -y libretto-browser-tools --provider kernel` for a cloud provider.

Flags:

| Flag                      | Effect                                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
| `--provider <name>`       | Browser provider: `local` (default), `kernel`, `browserbase`, `browser-use`, `steel`, `libretto-cloud` |
| `--headed`                | Show the browser window (default is headless). Applies to `local`, `kernel`, and `libretto-cloud`      |
| `--allowed-domain <host>` | Restrict http(s) navigations to this host (repeatable)                                                 |
| `--blocked-domain <host>` | Block http(s) navigations to this host (repeatable)                                                    |

Cloud providers read API keys from the MCP server environment (for example `KERNEL_API_KEY`, `BROWSERBASE_API_KEY`, `BROWSER_USE_API_KEY`, `STEEL_API_KEY`, `LIBRETTO_API_KEY`). See each [provider](/browser-tools/providers/overview) page for required variables. Local Chromium (`npx playwright install chromium`) is only required for `--provider local`.

The server exposes `browser_open`, `browser_connect`, `browser_exec`, `browser_snapshot`, `browser_status`, and `browser_close`.

Host-specific setup:

* [Hermes Agent](/browser-tools/agents/hermes)
* [OpenClaw](/browser-tools/agents/openclaw)

## Library: registerMcpBrowserTools

Import from `libretto-browser-tools/mcp` when you own the MCP server and transport. Install the package (the MCP SDK is included as a dependency).

```bash theme={null}
pnpm add libretto-browser-tools
npx playwright install chromium
```

```typescript theme={null}
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { LocalBrowserProvider } from "libretto-browser-tools";
import { registerMcpBrowserTools } from "libretto-browser-tools/mcp";

const server = new McpServer({
  name: "libretto-browser-tools",
  version: "1.0.0",
});
const { dispose } = registerMcpBrowserTools(
  server,
  new LocalBrowserProvider({ headless: true }),
  // optional: { allowedDomains: ["example.com"] }
);

await server.connect(new StdioServerTransport());

async function shutdown() {
  await server.close();
  await dispose();
}

process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
```

Pass domain policy options as the third argument — see [Advanced](/browser-tools/advanced#domain-policy). Swap `LocalBrowserProvider` for any [provider](/browser-tools/providers/overview).

## Connect a gateway

MCP gateways such as [Executor](https://executor.sh/docs/mcp-proxy) need a Streamable HTTP MCP URL. Mount a caller-owned server on your HTTP host, register the browser tools on it, then add that URL as an MCP integration.

Hosted gateways cannot reach a server bound to `localhost`. Deploy the MCP server on a reachable host, or run the gateway and server on the same network.

Create one browser toolkit per authenticated user or connection. A shared toolkit also shares its browser session registry, including sessions returned by `browser_status`.

## Tool results

Tool failures set MCP `isError: true` and keep the actionable Libretto error in text content. A `browser_snapshot` call with `screenshot: true` returns the accessibility tree as text and the PNG as MCP image content.

The adapter publishes safety annotations for each tool. `browser_exec` is marked destructive and open-world because one Playwright script can read or change any page the browser can reach. Enforce access with host policies and [domain policy](/browser-tools/advanced#domain-policy); annotations are hints, not a sandbox.

## See also

* [Tools](/browser-tools/tools) — inputs and outputs for each tool
* [Hermes](/browser-tools/agents/hermes) — Hermes Agent install
* [OpenClaw](/browser-tools/agents/openclaw) — OpenClaw install
* [Custom adapters](/browser-tools/adapters/custom) — wrap `createBrowserTools` for another framework


---
title: Custom
url: "https://libretto-docs.vercel.app/docs/browser-tools/adapters/custom.md"
description: "Built-in adapters wrap the framework-agnostic tools from createBrowserTools. For another framework, call that factory and map each BrowserTool into your..."
---

> Adapt browser tools to a framework without a built-in adapter.

Built-in adapters wrap the framework-agnostic tools from `createBrowserTools`. For another framework, call that factory and map each `BrowserTool` into your framework's tool shape.

```typescript theme={null}
import { createBrowserTools } from "libretto-browser-tools";
import { LocalBrowserProvider } from "libretto-browser-tools";

const { tools, dispose } = createBrowserTools(new LocalBrowserProvider());

// Each entry is a BrowserTool: { name, description, inputSchema, execute }
const { browser_open, browser_exec, browser_snapshot, browser_status, browser_close, browser_connect } =
  tools;

// Map into your framework, for example:
// myFramework.tool({
//   name: browser_open.name,
//   description: browser_open.description,
//   parameters: browser_open.inputSchema, // Standard Schema / zod
//   execute: (input) => browser_open.execute(input),
// });
```

`execute` returns `{ ok: true, ...payload }` or `{ ok: false, error }`. Host misconfiguration and domain-policy violations throw — surface those to the host, not as model-fixable tool errors.

`inputSchema` is a Standard Schema (authored in zod). Pass it through when your framework accepts zod or Standard Schema; otherwise convert it.

Always expose `dispose()` from the host so leftover sessions close when the agent loop ends. For snapshot screenshots, the base tool returns `{ base64, mimeType: "image/png" }` — convert that into your framework's multimodal content type if needed (see the [AI SDK](/browser-tools/adapters/ai-sdk), [Pi](/browser-tools/adapters/pi), and [MCP](/browser-tools/adapters/mcp) adapters).

Borrowed-page toolkits use `createBrowserToolsForPage(page)` and only include `browser_exec`, `browser_snapshot`, and `browser_status`.


---
title: Hermes Agent
url: "https://libretto-docs.vercel.app/docs/browser-tools/agents/hermes.md"
description: "Point Hermes at the Browser Tools stdio binary to replace Hermes' built-in browser_* stack with six Playwright tools (browser_open, browser_connect,..."
---

> Use Browser Tools with [Hermes Agent](https://hermes-agent.nousresearch.com/docs) over MCP.

Point Hermes at the Browser Tools stdio binary to replace Hermes' built-in `browser_*` stack with six Playwright tools (`browser_open`, `browser_connect`, `browser_exec`, `browser_snapshot`, `browser_status`, `browser_close`). Compact accessibility snapshots and a smaller tool surface usually use fewer tokens than Hermes' native browser tools.

## Requirements

The MCP binary runs a [local Chromium](/browser-tools/providers/local) session on the machine that launches the server (the Hermes host).

* Node.js 18+ with `npx`
* Playwright Chromium once per machine:

```bash theme={null}
npx playwright install chromium
```

On Linux hosts missing browser system libraries, use:

```bash theme={null}
npx playwright install --with-deps chromium
```

Headless is the default. `--headed` needs a graphical display on that same host — it does not open a window on your laptop when Hermes runs remotely.

## Install and configure

Add the server in `~/.hermes/config.yaml`:

```yaml theme={null}
mcp_servers:
  libretto:
    command: npx
    args: ["-y", "libretto-browser-tools"]

agent:
  disabled_toolsets:
    - browser
```

Or:

```bash theme={null}
hermes mcp add libretto --command npx --args -y --args libretto-browser-tools
hermes tools disable browser
```

Reload MCP after changing config (`/reload-mcp` in a Hermes session, or restart Hermes). Confirm the server with `hermes mcp test libretto`.

Pass flags in `args` for a visible browser or [domain policy](/browser-tools/advanced):

```yaml theme={null}
mcp_servers:
  libretto:
    command: npx
    args:
      - "-y"
      - "libretto-browser-tools"
      - "--headed"
      - "--allowed-domain"
      - "example.com"
```

See [Tools](/browser-tools/tools) for inputs and outputs, and [MCP](/browser-tools/adapters/mcp) for flags and the library API.

## Disable Hermes' built-in browser

Disable the `browser` toolset so Hermes does not keep its native `browser_navigate` / `browser_click` / … tools alongside Browser Tools.

Leave Hermes' separate `web` toolset enabled (the default on `hermes-cli`). It provides `web_search` and `web_extract`, so disabling `browser` does not turn off search.

## Auth profiles

Local sessions keep signed-in browser state in [auth profiles](/browser-tools/auth-profiles) under `~/.libretto/browser-tools/profiles/` on the Hermes host.

To sign in once, run the MCP server with `--headed`, ask the agent to open a browser session for the site, complete login in the window yourself, then ask it to close the session. Later headless runs reuse the cookies. Profile changes save only on a clean close; do not share profile directories across untrusted users.

## Security

`browser_exec` runs Playwright and JavaScript in the MCP process — not inside a browser sandbox. Treat the Hermes host as the trust boundary: domain allow/block flags limit http(s) requests in the managed browser context, but they do not confine `browser_exec` itself.

## Cloud providers

Pass `--provider` to use a [cloud browser](/browser-tools/providers/overview) instead of local Chromium:

```yaml theme={null}
mcp_servers:
  libretto:
    command: npx
    args: ["-y", "libretto-browser-tools", "--provider", "kernel"]
```

Put the provider API key in Hermes' environment (for example `KERNEL_API_KEY` in `~/.hermes/.env`). Supported names: `local`, `kernel`, `browserbase`, `browser-use`, `steel`, `libretto-cloud`.

## Troubleshooting

| Symptom                                  | What to try                                                                                   |
| ---------------------------------------- | --------------------------------------------------------------------------------------------- |
| Server fails to start / Chromium missing | For `--provider local`, run `npx playwright install chromium` (add `--with-deps` on Linux).   |
| Missing cloud API key                    | Set the provider key in Hermes env (for example `KERNEL_API_KEY`), or use `--provider local`. |
| Headed browser errors with no display    | Drop `--headed`, or run Hermes where a display is available.                                  |
| Native browser tools still win           | Confirm `agent.disabled_toolsets` includes `browser`, then reload MCP.                        |
| Browser sessions stop working after idle | Hermes may recycle the MCP process; ask the agent to open a fresh session.                    |
| Login state missing after a crash        | Profiles save on graceful close only; sign in again with `--headed`.                          |

## See also

* [MCP adapter](/browser-tools/adapters/mcp) — stdio binary and library registration
* [Local provider](/browser-tools/providers/local) — Chromium install and options
* [OpenClaw](/browser-tools/agents/openclaw) — same binary on OpenClaw
* [Hermes MCP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp)
* [Hermes toolsets](https://hermes-agent.nousresearch.com/docs/reference/toolsets-reference)


---
title: OpenClaw
url: "https://libretto-docs.vercel.app/docs/browser-tools/agents/openclaw.md"
description: "Point OpenClaw at the Browser Tools stdio binary to replace OpenClaw's bundled browser stack with six Playwright tools (browser_open, browser_connect,..."
---

> Use Browser Tools with [OpenClaw](https://docs.openclaw.ai) over MCP.

Point OpenClaw at the Browser Tools stdio binary to replace OpenClaw's bundled browser stack with six Playwright tools (`browser_open`, `browser_connect`, `browser_exec`, `browser_snapshot`, `browser_status`, `browser_close`). Compact accessibility snapshots and a smaller tool surface usually use fewer tokens than a large native browser tool set.

Browser Tools launches an agent-owned browser session. It does not replace OpenClaw's Chrome extension / `user` profile path for driving signed-in tabs in your personal browser.

## Requirements

The MCP binary runs on the machine that launches the server (usually the OpenClaw Gateway host).

* Node.js 18+ with `npx` (OpenClaw itself needs a newer Node; see [OpenClaw install](https://docs.openclaw.ai/install))
* Playwright Chromium once per machine when using the local provider:

```bash theme={null}
npx playwright install chromium
```

On Linux hosts missing browser system libraries, use:

```bash theme={null}
npx playwright install --with-deps chromium
```

Headless is the default. `--headed` needs a graphical display on that same host — it does not open a window on your laptop when the Gateway runs remotely.

## Install and configure

The simplest path is OpenClaw's MCP CLI:

```bash theme={null}
openclaw mcp add libretto --command npx --arg -y --arg libretto-browser-tools
openclaw mcp doctor libretto --probe
```

That saves the server under `mcp.servers` in `~/.openclaw/openclaw.json` and proves it starts and exposes the six tools. You can also add the server in the Control UI under MCP settings (`/settings/mcp`).

Pass more `--arg` flags for a visible browser or [domain policy](/browser-tools/advanced):

```bash theme={null}
openclaw mcp add libretto \
  --command npx \
  --arg -y \
  --arg libretto-browser-tools \
  --arg --headed \
  --arg --allowed-domain \
  --arg example.com
```

Reload or restart the Gateway process that should see the new server (`openclaw mcp reload` only affects the current CLI process).

See [Tools](/browser-tools/tools) for inputs and outputs, and [MCP](/browser-tools/adapters/mcp) for flags and the library API.

## Disable OpenClaw's bundled browser

Disable the bundled browser plugin so OpenClaw does not keep its native `browser` tool alongside Browser Tools:

```json5 theme={null}
{
  plugins: {
    entries: {
      browser: {
        enabled: false,
      },
    },
  },
}
```

Restart the Gateway after changing plugin config. See [OpenClaw browser docs](https://docs.openclaw.ai/tools/browser).

Keep the bundled browser enabled when you still need OpenClaw's signed-in `user` / Chrome-extension attach paths.

## Auth profiles

Local sessions keep signed-in browser state in [auth profiles](/browser-tools/auth-profiles) under `~/.libretto/browser-tools/profiles/` on the Gateway host.

To sign in once, run the MCP server with `--headed`, ask the agent to open a browser session for the site, complete login in the window yourself, then ask it to close the session. Later headless runs reuse the cookies. Profile changes save only on a clean close; do not share profile directories across untrusted users.

## OpenAI models and MCP tools

OpenAI agent models default to the Codex runtime, which may not project custom MCP tools. For Browser Tools with an OpenAI API key, pin the embedded OpenClaw runtime on that model:

```json5 theme={null}
{
  agents: {
    defaults: {
      model: "openai/gpt-5.4-mini",
      models: {
        "openai/gpt-5.4-mini": {
          agentRuntime: { id: "openclaw" },
        },
      },
    },
  },
}
```

See [OpenClaw agent runtimes](https://docs.openclaw.ai/concepts/agent-runtimes).

## Security

`browser_exec` runs Playwright and JavaScript in the MCP process — not inside a browser sandbox. Treat the Gateway host as the trust boundary: domain allow/block flags limit http(s) requests in the managed browser context, but they do not confine `browser_exec` itself.

## Cloud providers

Pass `--provider` to use a [cloud browser](/browser-tools/providers/overview) instead of local Chromium:

```bash theme={null}
openclaw mcp add libretto \
  --command npx \
  --arg -y \
  --arg libretto-browser-tools \
  --arg --provider \
  --arg kernel \
  --env KERNEL_API_KEY=$KERNEL_API_KEY
```

Put the provider API key in the MCP server environment (`--env` on `mcp add`, or the Gateway process env). Supported names: `local`, `kernel`, `browserbase`, `browser-use`, `steel`, `libretto-cloud`.

## Troubleshooting

| Symptom                                  | What to try                                                                                    |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Server fails to start / Chromium missing | For `--provider local`, run `npx playwright install chromium` (add `--with-deps` on Linux).    |
| Missing cloud API key                    | Pass `--env PROVIDER_API_KEY=…` on `mcp add`, or set it on the Gateway host.                   |
| Headed browser errors with no display    | Drop `--headed`, or run the Gateway where a display is available.                              |
| Native browser tools still win           | Set `plugins.entries.browser.enabled` to `false`, then restart the Gateway.                    |
| MCP tools missing on OpenAI models       | Pin `agentRuntime.id: "openclaw"` on that model (see above).                                   |
| Server saved but agent cannot see tools  | Run `openclaw mcp doctor libretto --probe`, then restart the Gateway (not only `mcp reload`).  |
| Browser sessions stop working after idle | One-shot agent runs tear down MCP children when the turn ends; open a fresh session next turn. |
| Login state missing after a crash        | Profiles save on graceful close only; sign in again with `--headed`.                           |

## See also

* [MCP adapter](/browser-tools/adapters/mcp) — stdio binary and library registration
* [Local provider](/browser-tools/providers/local) — Chromium install and options
* [Hermes](/browser-tools/agents/hermes) — same binary on Hermes Agent
* [OpenClaw MCP CLI](https://docs.openclaw.ai/cli/mcp)
* [OpenClaw browser](https://docs.openclaw.ai/tools/browser)


---
title: Providers
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/overview.md"
description: "A provider implements BrowserProvider: it creates a CDP endpoint for each session and tears it down on close. Pass any provider into createBrowserTools,..."
---

> Choose where agent browser sessions run — local Chromium or a managed cloud provider.

A provider implements `BrowserProvider`: it creates a CDP endpoint for each session and tears it down on close. Pass any provider into `createBrowserTools`, `createAiSdkBrowserTools`, or `createPiBrowserTools`.

Provider credentials and defaults live on the constructor. Per-session launch options (`startUrl`, `gpu`, `viewport`, `authProfile`) go on `createSession` — `browser_open({ url, authProfile })` passes those through. See [Auth profiles](/browser-tools/auth-profiles) for named login state across sessions.

This is the SDK surface. For Libretto CLI `open` / `run` provider config, see [Alternative providers](/alternative-providers/overview).

## Built-in providers

| Provider                                                  | Import                                  | Required env                                     |
| --------------------------------------------------------- | --------------------------------------- | ------------------------------------------------ |
| [Local](/browser-tools/providers/local)                   | `libretto-browser-tools`                | (Chromium via `npx playwright install chromium`) |
| [Libretto Cloud](/browser-tools/providers/libretto-cloud) | `libretto-browser-tools/libretto-cloud` | `LIBRETTO_API_KEY`                               |
| [Kernel](/browser-tools/providers/kernel)                 | `libretto-browser-tools/kernel`         | `KERNEL_API_KEY`                                 |
| [Browserbase](/browser-tools/providers/browserbase)       | `libretto-browser-tools/browserbase`    | `BROWSERBASE_API_KEY`                            |
| [Browser Use](/browser-tools/providers/browser-use)       | `libretto-browser-tools/browser-use`    | `BROWSER_USE_API_KEY`                            |
| [Steel](/browser-tools/providers/steel)                   | `libretto-browser-tools/steel`          | `STEEL_API_KEY`                                  |
| [Custom](/browser-tools/providers/custom)                 | implement `BrowserProvider`             | (your credentials)                               |

Cloud providers attach over CDP and do not need a local Chromium download. Only [Local](/browser-tools/providers/local) requires `npx playwright install chromium`.

## Usage

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { KernelBrowserProvider } from "libretto-browser-tools/kernel";

const { tools, dispose } = createAiSdkBrowserTools(
  new KernelBrowserProvider(), // reads KERNEL_API_KEY from the environment
);
```

Missing API keys throw from the provider constructor (host-level errors), not as `{ ok: false }` tool results.


---
title: Local
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/local.md"
description: LocalBrowserProvider launches Chromium with an ephemeral remote-debugging port and returns its CDP websocket. This is the only built-in provider that needs a...
---

> Run agent browser sessions on local Chromium.

`LocalBrowserProvider` launches Chromium with an ephemeral remote-debugging port and returns its CDP websocket. This is the only built-in provider that needs a local browser binary.

```bash theme={null}
npx playwright install chromium
```

Import from the package root (not a subpath):

```typescript theme={null}
import { LocalBrowserProvider } from "libretto-browser-tools";
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";

const { tools, dispose } = createAiSdkBrowserTools(
  new LocalBrowserProvider({ headless: true }),
);
```

## Options

| Option                 | Default                              | Description                                                       |
| ---------------------- | ------------------------------------ | ----------------------------------------------------------------- |
| `headless`             | `false`                              | Run Chromium headless. Use `true` on servers without a display.   |
| `channel`              | (Playwright Chromium)                | Optional Playwright browser channel, for example `"chrome"`.      |
| `authProfileDirectory` | `~/.libretto/browser-tools/profiles` | Root directory for [auth profiles](/browser-tools/auth-profiles). |

## Example

```typescript theme={null}
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, stepCountIs } from "ai";
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { LocalBrowserProvider } from "libretto-browser-tools";

const { tools, dispose } = createAiSdkBrowserTools(
  new LocalBrowserProvider({ headless: true }),
);

try {
  const result = await generateText({
    model: anthropic("claude-sonnet-4-5"),
    tools,
    stopWhen: stepCountIs(20),
    prompt: "Go to example.com and tell me the page title",
  });
  console.log(result.text);
} finally {
  await dispose();
}
```

For managed browsers with stealth or live view, use a [cloud provider](/browser-tools/providers/overview).


---
title: Libretto Cloud
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/libretto-cloud.md"
description: "Import from libretto-browser-tools/libretto-cloud. This is the SDK provider for hosted browsers — not the Libretto CLI --provider flag. For CLI hosting, see..."
---

> Run agent browser sessions on Libretto Cloud.

Import from `libretto-browser-tools/libretto-cloud`. This is the SDK provider for hosted browsers — not the Libretto CLI `--provider` flag. For CLI hosting, see [Libretto Cloud Hosting](/libretto-cloud-hosting/overview).

## Configure

```dotenv theme={null}
LIBRETTO_API_KEY=<libretto-api-key>
# optional
# LIBRETTO_API_URL=https://api.libretto.sh
# LIBRETTO_TIMEOUT_SECONDS=3600
```

## Provider options

Constructor / env settings for the provider itself:

| Option           | Env                        | Default                   | Description                        |
| ---------------- | -------------------------- | ------------------------- | ---------------------------------- |
| `apiKey`         | `LIBRETTO_API_KEY`         | (required)                | API key                            |
| `apiUrl`         | `LIBRETTO_API_URL`         | `https://api.libretto.sh` | API base URL                       |
| `timeoutSeconds` | `LIBRETTO_TIMEOUT_SECONDS` | `3600`                    | Browser session TTL at create time |
| `headless`       | —                          | `true`                    | Request a headless session         |

## Session create options

Pass these to `provider.createSession({ ... })` (or via `browser_open({ url })` for `startUrl`):

| Option     | Description                       |
| ---------- | --------------------------------- |
| `startUrl` | URL opened before CDP is returned |
| `gpu`      | Request GPU acceleration          |
| `viewport` | `{ width, height }` window size   |

Sessions may queue briefly until capacity is available (up to 10 minutes). Ready sessions expose `liveViewUrl`. Closing a session may return `replayUrl` when a recording is available. When `startUrl` is sent, the session returns `startUrlPreloaded: true`.

## Example

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { LibrettoCloudBrowserProvider } from "libretto-browser-tools/libretto-cloud";

const { tools, dispose } = createAiSdkBrowserTools(
  new LibrettoCloudBrowserProvider(),
);

try {
  // agent loop using tools…
} finally {
  await dispose();
}
```


---
title: Kernel
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/kernel.md"
description: "Import from libretto-browser-tools/kernel. For Libretto CLI open / run on Kernel, see Alternative providers: Kernel. Constructor / env settings for the..."
---

> Run agent browser sessions on Kernel.

Import from `libretto-browser-tools/kernel`. For Libretto CLI `open` / `run` on Kernel, see [Alternative providers: Kernel](/alternative-providers/kernel).

## Configure

```dotenv theme={null}
KERNEL_API_KEY=<kernel-api-key>
# optional
# KERNEL_HEADLESS=true
# KERNEL_STEALTH=false
# KERNEL_TIMEOUT_SECONDS=300
# KERNEL_PROXY_ID=
# KERNEL_ENABLE_RECORDING=false
# KERNEL_API_ENDPOINT=https://api.onkernel.com
```

## Provider options

Constructor / env settings for the provider itself:

| Option            | Env                       | Default    | Description                                |
| ----------------- | ------------------------- | ---------- | ------------------------------------------ |
| `apiKey`          | `KERNEL_API_KEY`          | (required) | API key                                    |
| `headless`        | `KERNEL_HEADLESS`         | `true`     | Headless browser                           |
| `stealth`         | `KERNEL_STEALTH`          | `false`    | Kernel stealth mode                        |
| `proxyId`         | `KERNEL_PROXY_ID`         | —          | Optional Kernel proxy id                   |
| `timeoutSeconds`  | `KERNEL_TIMEOUT_SECONDS`  | `300`      | Session timeout in seconds                 |
| `enableRecording` | `KERNEL_ENABLE_RECORDING` | `false`    | Start a replay; requires `headless: false` |

Created sessions may include `liveViewUrl` and, when recording is enabled, `recordingUrl`. Closing returns `replayUrl` when a replay was started.

## Session create options

Pass these to `provider.createSession({ ... })` (or via `browser_open({ url })` for `startUrl`):

| Option     | Description                        |
| ---------- | ---------------------------------- |
| `startUrl` | URL Kernel opens before CDP attach |
| `gpu`      | Enable Kernel GPU acceleration     |
| `viewport` | `{ width, height }` window size    |

Pass `startUrl` when the site blocks Playwright attach followed by `page.goto()`. Kernel navigates during browser create, before CDP is available, and returns `startUrlPreloaded: true` so callers skip a second navigation.

## Example

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { KernelBrowserProvider } from "libretto-browser-tools/kernel";

const { tools, dispose } = createAiSdkBrowserTools(
  new KernelBrowserProvider({ stealth: true }),
);

try {
  // Prefer browser_open({ url }) so Kernel can preload startUrl.
  // agent loop using tools…
} finally {
  await dispose();
}
```


---
title: Browserbase
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/browserbase.md"
description: "Import from libretto-browser-tools/browserbase. For Libretto CLI open / run on Browserbase, see Alternative providers: Browserbase. Constructor / env settings..."
---

> Run agent browser sessions on Browserbase.

Import from `libretto-browser-tools/browserbase`. For Libretto CLI `open` / `run` on Browserbase, see [Alternative providers: Browserbase](/alternative-providers/browserbase).

## Configure

```dotenv theme={null}
BROWSERBASE_API_KEY=<browserbase-api-key>
BROWSERBASE_PROJECT_ID=<browserbase-project-id>
# optional
# BROWSERBASE_ENDPOINT=https://api.browserbase.com
```

## Provider options

Constructor / env settings for the provider itself:

| Option           | Env                      | Default                       | Description                            |
| ---------------- | ------------------------ | ----------------------------- | -------------------------------------- |
| `apiKey`         | `BROWSERBASE_API_KEY`    | (required)                    | API key                                |
| `projectId`      | `BROWSERBASE_PROJECT_ID` | —                             | Browserbase project id (recommended)   |
| `endpoint`       | `BROWSERBASE_ENDPOINT`   | `https://api.browserbase.com` | API base URL                           |
| `proxies`        | —                        | —                             | Enable Browserbase proxies when `true` |
| `solveCaptchas`  | —                        | —                             | Enable captcha solving when `true`     |
| `timeoutSeconds` | —                        | —                             | Session timeout in seconds             |

## Session create options

Pass these to `provider.createSession({ ... })` (or via `browser_open({ url })` for `startUrl`):

| Option     | Description                                                                                       |
| ---------- | ------------------------------------------------------------------------------------------------- |
| `viewport` | `{ width, height }` via Browserbase `browserSettings.viewport`                                    |
| `startUrl` | Accepted for API symmetry; Browserbase cannot preload it — `browser_open` navigates after connect |

## Example

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { BrowserbaseBrowserProvider } from "libretto-browser-tools/browserbase";

const { tools, dispose } = createAiSdkBrowserTools(
  new BrowserbaseBrowserProvider({ proxies: true }),
);

try {
  // agent loop using tools…
} finally {
  await dispose();
}
```


---
title: Browser Use
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/browser-use.md"
description: "Import from libretto-browser-tools/browser-use. Created sessions may include liveViewUrl. If the API returns no CDP URL, stop the session in the Browser Use..."
---

> Run agent browser sessions on Browser Use.

Import from `libretto-browser-tools/browser-use`.

## Configure

```dotenv theme={null}
BROWSER_USE_API_KEY=<browser-use-api-key>
# optional
# BROWSER_USE_BASE_URL=https://api.browser-use.com/api/v3
```

## Options

| Option             | Env                    | Default                              | Description                                 |
| ------------------ | ---------------------- | ------------------------------------ | ------------------------------------------- |
| `apiKey`           | `BROWSER_USE_API_KEY`  | (required)                           | API key                                     |
| `endpoint`         | `BROWSER_USE_BASE_URL` | `https://api.browser-use.com/api/v3` | API base URL                                |
| `proxyCountryCode` | —                      | —                                    | Optional proxy country code (`null` clears) |
| `timeoutMinutes`   | —                      | —                                    | Session timeout in minutes                  |

Created sessions may include `liveViewUrl`. If the API returns no CDP URL, stop the session in the Browser Use dashboard and create a fresh one.

## Example

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { BrowserUseBrowserProvider } from "libretto-browser-tools/browser-use";

const { tools, dispose } = createAiSdkBrowserTools(
  new BrowserUseBrowserProvider(),
);

try {
  // agent loop using tools…
} finally {
  await dispose();
}
```


---
title: Steel
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/steel.md"
description: "Import from libretto-browser-tools/steel. For Libretto CLI open / run on Steel, see Alternative providers: Steel. Constructor / env settings for the provider..."
---

> Run agent browser sessions on Steel.

Import from `libretto-browser-tools/steel`. For Libretto CLI `open` / `run` on Steel, see [Alternative providers: Steel](/alternative-providers/steel).

## Configure

```dotenv theme={null}
STEEL_API_KEY=<steel-api-key>
# optional
# STEEL_BASE_URL=https://api.steel.dev
# STEEL_CONNECT_URL=wss://connect.steel.dev
```

## Provider options

Constructor / env settings for the provider itself:

| Option                | Env                 | Default                   | Description                        |
| --------------------- | ------------------- | ------------------------- | ---------------------------------- |
| `apiKey`              | `STEEL_API_KEY`     | (required)                | API key                            |
| `endpoint`            | `STEEL_BASE_URL`    | `https://api.steel.dev`   | REST API base URL                  |
| `connectEndpoint`     | `STEEL_CONNECT_URL` | `wss://connect.steel.dev` | CDP connect websocket base         |
| `useProxy`            | —                   | —                         | Enable Steel proxy when `true`     |
| `solveCaptcha`        | —                   | —                         | Enable captcha solving when `true` |
| `timeoutMs`           | —                   | —                         | Session timeout in milliseconds    |
| `inactivityTimeoutMs` | —                   | —                         | Inactivity timeout in milliseconds |

## Session create options

Pass these to `provider.createSession({ ... })` (or via `browser_open({ url })` for `startUrl`):

| Option     | Description                                                                                 |
| ---------- | ------------------------------------------------------------------------------------------- |
| `viewport` | `{ width, height }` via Steel `dimensions`                                                  |
| `startUrl` | Accepted for API symmetry; Steel cannot preload it — `browser_open` navigates after connect |

Created sessions may include `liveViewUrl` (Steel session viewer).

## Example

```typescript theme={null}
import { createAiSdkBrowserTools } from "libretto-browser-tools/ai-sdk";
import { SteelBrowserProvider } from "libretto-browser-tools/steel";

const { tools, dispose } = createAiSdkBrowserTools(
  new SteelBrowserProvider({ useProxy: true }),
);

try {
  // agent loop using tools…
} finally {
  await dispose();
}
```


---
title: Custom
url: "https://libretto-docs.vercel.app/docs/browser-tools/providers/custom.md"
description: "Every provider implements BrowserProvider: produce a CDP websocket endpoint for a new session, and close it later. Pass new MyProvider() into..."
---

> Implement your own browser provider.

Every provider implements `BrowserProvider`: produce a CDP websocket endpoint for a new session, and close it later.

```typescript theme={null}
import type {
  BrowserProvider,
  ProviderSession,
  ProviderSessionClosed,
  ProviderSessionCreateOptions,
} from "libretto-browser-tools";

class MyProvider implements BrowserProvider {
  readonly name = "my-provider";

  async createSession(
    options: ProviderSessionCreateOptions = {},
  ): Promise<ProviderSession> {
    // Start or allocate a browser, then return its CDP endpoint.
    // Per-session launch options (startUrl, gpu, viewport) arrive here.
    return {
      sessionId: "my-1",
      cdpEndpoint: "ws://127.0.0.1:9222/devtools/browser/...",
      // optional: liveViewUrl, recordingUrl, startUrlPreloaded
    };
  }

  async closeSession(sessionId: string): Promise<ProviderSessionClosed> {
    // Tear down the provider-owned session.
    return {}; // optional: replayUrl
  }
}
```

Pass `new MyProvider()` into `createBrowserTools` or any framework adapter. Provider credentials and defaults belong on the constructor (with env-var fallbacks). Per-session launch options belong on `createSession`.

Built-in providers live under import subpaths such as `libretto-browser-tools/kernel`. See [Providers](/browser-tools/providers/overview).


---
title: Libretto Cloud Hosting
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/overview.md"
description: "Libretto Cloud is the easiest way to host and run workflows. Sign up, run one deploy command from the CLI, and your workflow is live behind a managed API. If..."
---

> The hosted platform for Libretto. Deploy workflows, run them through a managed API, schedule workflow runs, and investigate failures.

Libretto Cloud is the easiest way to host and run workflows. Sign up, run one deploy command from the CLI, and your workflow is live behind a managed API. If you'd rather use a different browser or infrastructure provider, see [alternative providers](/alternative-providers/overview). The Libretto Cloud API URL is `https://api.libretto.sh`.

### Why use Libretto Cloud

Use Libretto Cloud when you want to ship and run workflows without owning the runtime infrastructure yourself.

* Libretto Cloud runs the browser layer for you, so you do not have to manage proxies, browser sessions, or CAPTCHA handling yourself. For location-sensitive workflows, you can still choose the hosted browser's residential proxy location.
* By default, when a run fails, Libretto Cloud automatically analyzes the failure and emails you a summary, along with the recordings, screenshots, logs, and other debug context you need to understand what broke.
* Deployments can also enable auto-repair, which routes failed jobs to the autofix agent instead of only sending debug context.
* You can build, debug, and deploy workflow packages from the same CLI instead of stitching together separate tools for local development, browsers, and hosting.

### Set up Libretto Cloud

<Steps>
  <Step title="Create a user account and organization">
    Create a new Libretto Cloud account with:

    ```bash theme={null}
    npx libretto cloud auth signup
    ```

    If you are joining an existing organization, open the invitation link from your email instead.

    See [Create a new user account and organization](/libretto-cloud-hosting/authentication#create-a-new-user-account-and-organization).
  </Step>

  <Step title="Create an API key">
    Issue an API key for your project or environment:

    ```bash theme={null}
    npx libretto cloud auth api-key issue --label laptop-dev
    ```

    Store the printed key as `LIBRETTO_API_KEY` in your `.env` file:

    ```dotenv theme={null}
    # .env
    LIBRETTO_API_KEY=<issued-key>
    ```

    See [Issue an API key](/libretto-cloud-hosting/authentication#issue-an-api-key).
  </Step>

  <Step title="Set up a webhook">
    If you want your system to receive results and failure responses from your browser automations, create a webhook endpoint so Libretto Cloud can send `job.completed` and `job.failed` events back to you:

    ```bash theme={null}
    curl -X POST "https://api.libretto.sh/v1/webhooks/create" \
      -H "x-api-key: $LIBRETTO_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "json": {
          "url": "https://example.com/libretto/webhooks",
          "signing_secret": "replace-with-your-secret",
          "description": "production job webhooks",
          "active": true
        }
      }'
    ```

    Stored tenant webhooks are best for long-lived destinations. Per-job or per-schedule callbacks are better for one-off or dev endpoints.

    See [Create a stored webhook endpoint](/libretto-cloud-api/webhooks#create-a-stored-webhook-endpoint).
  </Step>

  <Step title="Deploy a workflow">
    Change into the workflow package directory before deploying. The directory you pass to `deploy` is the package directory that gets bundled and uploaded, and it must contain `package.json`:

    ```text
    my-automations/
    ├── package.json
    ├── index.ts
    └── src/
        └── workflows/
            └── check-eligibility.ts
    ```

    Then run deploy from that directory:

    ```bash theme={null}
    cd my-automations
    npx libretto cloud deploy .
    ```

    See [Expected deploy directory](/libretto-cloud-hosting/deployments#expected-deploy-directory) and [Deploy workflows](/libretto-cloud-hosting/deployments#deploy-workflows).
  </Step>

  <Step title="Run the workflow">
    After deploy, call the Libretto Cloud jobs API with the workflow name declared in your code:

    ```bash theme={null}
    curl -X POST "https://api.libretto.sh/v1/jobs/create" \
      -H "x-api-key: $LIBRETTO_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "json": {
          "workflow": "check-eligibility",
          "params": {
            "memberId": "12345"
          },
          "timeout_seconds": 300
        }
      }'
    ```

    Then fetch the result with the returned job id:

    ```bash theme={null}
    curl -X POST "https://api.libretto.sh/v1/jobs/get" \
      -H "x-api-key: $LIBRETTO_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "json": {
          "id": "<job-id>"
        }
      }'
    ```

    Read `result` on the completed job. That object is whatever the workflow handler returned. See [Get the workflow output](/libretto-cloud-api/jobs-and-logs#get-the-workflow-output).

    See [Run a deployed workflow](/libretto-cloud-hosting/deployments#run-a-deployed-workflow) and [Jobs](/libretto-cloud-api/jobs-and-logs#jobs).
  </Step>
</Steps>

### Libretto Cloud CLI

<CardGroup cols={3}>
  <Card title="Authentication" icon="key" href="/libretto-cloud-hosting/authentication">
    Sign in, invite teammates, and issue API keys.
  </Card>

  <Card title="Deployments" icon="rocket" href="/libretto-cloud-hosting/deployments">
    Deploy workflow bundles to Libretto Cloud.
  </Card>

  <Card title="Sharing externally" icon="share" href="/libretto-cloud-hosting/sharing-workflows-externally">
    Share a hosted public run API with reviewed, visible source code.
  </Card>

  <Card title="Observability and Debugging" icon="bug" href="/libretto-cloud-hosting/observability-and-debugging">
    Debug workflow failures and investigate hosted workflow runs.
  </Card>

  <Card title="Billing" icon="credit-card" href="/libretto-cloud-hosting/billing">
    Check plan status and manage payment details.
  </Card>

  <Card title="Data retention" icon="clock" href="/libretto-cloud-hosting/data-retention">
    How long Libretto Cloud keeps job results, logs, and debug artifacts.
  </Card>
</CardGroup>

### Libretto Cloud API

* [Overview](/libretto-cloud-api/overview)
* [Sessions](/libretto-cloud-api/sessions)
* [Deployments and Workflows](/libretto-cloud-api/deployments-and-workflows)
* [Jobs and Logs](/libretto-cloud-api/jobs-and-logs)
* [Credentials](/libretto-cloud-api/credentials)
* [Webhooks](/libretto-cloud-api/webhooks)
* [Schedules](/libretto-cloud-api/schedules)

To share a workflow outside your workspace from the dashboard, see [Sharing workflows externally](/libretto-cloud-hosting/sharing-workflows-externally).


---
title: Signing up
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/authentication.md"
description: "This page is about authenticating the CLI and API client to Libretto Cloud. For signing your workflows into target websites, see Website authentication. The..."
---

> Create or join a Libretto Cloud organization, store a local session, and issue API keys for cloud runs.

This page is about authenticating the CLI and API client to Libretto Cloud. For signing your workflows into target websites, see [Website authentication](/libretto-cloud-hosting/website-authentication).

### Before you start

The examples below assume Libretto is installed in your project and runnable with `npx libretto`.

### Create a new user account and organization

Use `auth signup` when you are creating a brand new Libretto Cloud organization:

```bash theme={null}
npx libretto cloud auth signup
```

The CLI opens the signup page in your browser and waits while you create the account. You can sign up with Google or with email and password. If you use email and password, open the verification link in the same browser so the website can finish setup. After the website approves the signup, the CLI stores the Libretto Cloud session in `~/.libretto/auth.json`.

<Note>
  A Libretto user can only belong to one organization at a time. If your team already has an organization, use an invite instead of creating a second account.
</Note>

If the organization slug is already taken, choose a different slug in the website signup form.

### Log in to an existing account

Use `auth login` when the account already exists:

```bash theme={null}
npx libretto cloud auth login
```

The CLI opens the login page in your browser and waits while you sign in with Google or email and password. After the website approves the login, the CLI stores the Libretto Cloud session in `~/.libretto/auth.json`.

### Reset a forgotten password

Open the Libretto Cloud login page and use the forgot-password action to request a password reset email. After you choose a new password from the reset link, run `auth login` again to save a fresh local session.

To clear local credentials:

```bash theme={null}
npx libretto cloud auth logout
```

### Check the active identity

Use `auth whoami` to confirm which Libretto Cloud account the CLI is using:

```bash theme={null}
npx libretto cloud auth whoami
```

If `LIBRETTO_API_KEY` is set, the CLI prefers that key over the stored session cookie.

### Invite a teammate

Use `auth invite` from an account that can manage members in the active organization:

```bash theme={null}
npx libretto cloud auth invite teammate@example.com --role member
```

The command sends an invitation email to the teammate and prints the invite link for manual testing or support.

### Accept an invite

Open the invitation link from the email. The website guides you through signing in or creating the invited account, then accepts the invitation. If you create the invited account with email and password, verify email from the same browser; after verification, the website returns to the invite and accepts it.

If you are already signed in, invite acceptance only works when that account is not already a member of another organization.

### Issue an API key

Use API keys for project `.env` files, CI, or other non-interactive Libretto Cloud runs:

```bash theme={null}
npx libretto cloud auth api-key issue --label laptop-dev
```

The command prints the raw key once. The CLI does not store that key on disk for you.

Add it to your project `.env` file:

```dotenv theme={null}
LIBRETTO_API_KEY=<issued-key>
```

To inspect existing keys:

```bash theme={null}
npx libretto cloud auth api-key list
```

To revoke a key:

```bash theme={null}
npx libretto cloud auth api-key revoke <key-id>
```

### Troubleshooting

* If signup fails because the email already exists, log in to that account or delete the existing Libretto Cloud account before retrying.
* If an invite fails for a logged-in user, make sure that user is not already a member of another Libretto organization.

<CardGroup cols={2}>
  <Card title="Deployments" icon="rocket" href="/libretto-cloud-hosting/deployments">
    Deploy workflows to Libretto Cloud and investigate cloud runs.
  </Card>

  <Card title="Billing" icon="credit-card" href="/libretto-cloud-hosting/billing">
    Open the plans page and manage your subscription.
  </Card>
</CardGroup>


---
title: Deployments
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/deployments.md"
description: "Libretto Cloud commands and API requests use https://api.libretto.sh. Issue an API key, then add it to your project .env file: cloud deploy uploads one package..."
---

> Deploy workflow bundles to Libretto Cloud, then use the failure emails and debug tools when runs break.

### Before you deploy

Libretto Cloud commands and API requests use `https://api.libretto.sh`. Issue an API key, then add it to your project `.env` file:

```dotenv theme={null}
LIBRETTO_API_KEY=<issued-api-key>
```

### Expected deploy directory

`cloud deploy` uploads one package directory at a time. That directory must contain:

* A `package.json`
* An entry file for workflow discovery
* The workflow files you want to deploy

By default, Libretto looks for `index.ts` inside the directory you pass to `deploy`.

Recommended layout:

```text
my-automations/
├── package.json
├── index.ts
└── src/
    └── workflows/
        ├── check-eligibility.ts
        └── submit-prior-auth.ts
```

Each workflow file should default-export a `workflow(...)`. Then `index.ts` should export the workflows you want Libretto Cloud to discover:

```ts theme={null}
export { default as checkEligibility } from "./src/workflows/check-eligibility";
export { default as submitPriorAuth } from "./src/workflows/submit-prior-auth";
```

The API uses the workflow name from the `workflow("...")` call inside the file, not the filename or the export alias from `index.ts`.

If your workflows live in a package inside a monorepo, run `cloud deploy` against that package directory instead of the monorepo root.

The deploy command has two separate path concepts:

* The positional argument is the package directory to bundle and upload. That directory must contain `package.json`.
* `--entry-point` is the file inside that package directory that Libretto should use for workflow discovery.

For example:

```bash theme={null}
npx libretto cloud deploy my-automations --entry-point src/workflows/check-eligibility.ts
```

In that command:

* `my-automations` is the package directory
* `src/workflows/check-eligibility.ts` is the entry file relative to `my-automations`

### Deploy workflows

Use `cloud deploy` to upload a workflow bundle to Libretto Cloud:

```bash theme={null}
cd my-automations
npx libretto cloud deploy .
```

That command expects:

* `.` to contain `package.json`
* `./index.ts` to be the deploy entry point
* `index.ts` to export the workflows that should be deployed

The positional argument to `deploy` is the source directory to package and upload. It is not the path to a workflow file.

If you want to deploy from a different entry file, keep the source directory as the package root and use `--entry-point` relative to that directory:

```bash theme={null}
npx libretto cloud deploy my-automations \
  --description "payer portal workflows" \
  --entry-point src/workflows/check-eligibility.ts
```

The CLI bundles the deploy artifact, uploads it to Libretto Cloud, then waits for the build to finish. When the build is ready, it prints the deployment id and the discovered workflow names.

Use `--auto-repair` when failed jobs from this deployment should route to the autofix agent instead of the readonly debug agent.

### Run a deployed workflow

After the deployment finishes, call the hosted jobs endpoint with the workflow name from your code. For example, if your workflow file looks like this:

```ts theme={null}
// src/workflows/check-eligibility.ts
import { workflow } from "libretto";
import { z } from "zod";

export default workflow(
  "check-eligibility",
  {
    input: z.object({
      memberId: z.string(),
    }),
    output: z.object({
      status: z.string(),
    }),
    startUrl: "https://example.com/eligibility",
  },
  async ({ page }, input) => {
    return {
      status: "eligible",
    };
  },
);
```

then create the hosted job with `workflow: "check-eligibility"`. The `params` object must match the workflow input schema:

```bash theme={null}
npx libretto cloud jobs create check-eligibility \
  --params '{"memberId":"12345"}' \
  --timeout-seconds 300
```

That command returns a `job_id` immediately. The equivalent API request is:

```bash theme={null}
curl -X POST "https://api.libretto.sh/v1/jobs/create" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "workflow": "check-eligibility",
      "params": {
        "memberId": "12345"
      },
      "timeout_seconds": 300
    }
  }'
```

Use the returned id to fetch status or the final result:

```bash theme={null}
curl -X POST "https://api.libretto.sh/v1/jobs/get" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "<job-id>"
    }
  }'
```

Read `result` on the completed job. That object is whatever the workflow handler returned. See [Get the workflow output](/libretto-cloud-api/jobs-and-logs#get-the-workflow-output).

The `workflow` value must match the workflow name declared in code and discovered during deploy, not the deployment id, filename, or export alias.

### Schedule a deployed workflow

Use `cloud schedules create` for recurring runs. The cron expression is standard 5-field cron, and the timezone defaults to `UTC`:

```bash theme={null}
npx libretto cloud schedules create check-eligibility \
  --cron "0 14 * * 1-5" \
  --timezone America/Los_Angeles \
  --params '{"memberId":"12345"}'
```

The command prints the schedule id and next fire time. Use `--params-file` instead of `--params` when the workflow input is too large to keep inline.

### Hosting options

Libretto Cloud is the managed path. If you would rather use a different browser or infrastructure provider, use the alternative providers docs instead:

* [Alternative providers overview](/alternative-providers/overview)
* [Kernel](/alternative-providers/kernel)
* [Browserbase](/alternative-providers/browserbase)
* [Steel](/alternative-providers/steel)
* [Deploy on GCP](/alternative-providers/gcp)
* [Deploy on AWS](/alternative-providers/aws)

<Note>
  This page covers the managed hosted-platform flow. The alternative provider
  pages are the right reference when you want a different managed browser
  provider or want to own the runtime, scheduler, and secrets infrastructure
  yourself.
</Note>

<CardGroup cols={3}>
  <Card title="Overview" icon="book-open" href="/libretto-cloud-hosting/overview">
    Start with the high-level cloud workflow.
  </Card>

  <Card title="Authentication" icon="key" href="/libretto-cloud-hosting/authentication">
    Create accounts, join organizations, and issue API keys.
  </Card>

  <Card title="Billing" icon="credit-card" href="/libretto-cloud-hosting/billing">
    Manage plans and open the billing portal.
  </Card>

  <Card title="Observability and Debugging" icon="bug" href="/libretto-cloud-hosting/observability-and-debugging">
    Debug workflow failures and investigate hosted workflow runs.
  </Card>
</CardGroup>


---
title: Sharing workflows externally
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/sharing-workflows-externally.md"
description: Deploying a workflow makes it available inside your organization. Sharing it publicly creates a public run API and shares the reviewed source so others can...
---

> Share a deployed workflow publicly as a hosted API with visible source code. Workflows stay private until you share them.

Deploying a workflow makes it available inside your organization. Sharing it publicly creates a public run API and shares the reviewed source so others can inspect or adapt it.

### What publication includes

Every published workflow has one public page with:

* A hosted run endpoint that callers use with their own Libretto API key and credentials
* Input and output types
* Required credential names
* Source files that visitors can inspect or adapt

Libretto reviews the source for secrets and other sensitive information before sharing. Blocked findings must be fixed; warnings require explicit acknowledgement. Workspace sharing must also be on in organization settings.

### Share from the dashboard

1. Sign in at [libretto.sh](https://libretto.sh) and open your workspace dashboard.
2. Deploy the workflow so it is ready in Cloud.
3. Open the workflow and expand Share workflow outside this workspace.
4. Add an optional description and select Share publicly.
5. Resolve any blocked privacy findings or acknowledge warnings after reviewing them.
6. If sharing is off, enable it in [Settings](https://libretto.sh/dashboard/settings), then share again.

You can also share from a terminal:

```bash
libretto cloud share myWorkflow
```

Use `--acknowledge-warnings` only after reviewing the findings printed by the command. Remove both the public API and source with `libretto cloud unshare myWorkflow`.

### What stays private

* Credentials and secret values
* Your organization's run history and job logs
* The publisher's and callers' inputs, outputs, logs, and run history


---
title: Observability and Debugging
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/observability-and-debugging.md"
description: "Hosted workflow runs capture a browser recording unless you create them with headless: true or the run fails before a browser starts. Use the recording with..."
---

> Debug workflow failures and investigate hosted workflow runs.

### Recordings and debug context

Hosted workflow runs capture a browser recording unless you create them with `headless: true` or the run fails before a browser starts. Use the recording with job state, logs, screenshots, and failure reports to understand what happened during the run.

Use `/v1/jobs/get` for status, params, timestamps, result or error, and mapped stack.

Use `/v1/recordings/get` with a `job_id` or `session_id` when you need a recording URL. Recording URLs are temporary; call the same endpoint again to generate a fresh URL after one expires.

Use `/v1/jobs/debugReport` for failed-run diagnosis, fix instructions, artifact links, job error details, and auto-repair fields when available.

Use `/v1/logs/list` for workflow logs and Libretto executor logs from the job, filtered by `jobId`, workflow, or severity.

See [Jobs and Logs](/libretto-cloud-api/jobs-and-logs) for the full API reference.

Job results stay for the life of your workspace. Screenshots, logs, and recordings have shorter windows. See [Data retention](/libretto-cloud-hosting/data-retention).

### Failure emails

**By default**, when a Libretto Cloud workflow job fails, Libretto Cloud starts a debug agent to analyze the failed run. The agent emails the configured debug recipient with:

* **A short diagnosis.** What failed and why.
* **A handoff prompt for your local coding agent.** Includes the diagnosis and instructions for fixing the workflow.
* **Links to relevant debug context.** Includes recordings, screenshots, and logs when available.

The email is designed to move the fix back into a local coding workflow quickly. After you receive it, use the local repo and Libretto tooling to inspect the failure and patch the workflow.

For the local debugging flow, see [Debugging workflows](/guides/debugging-workflows).

### Auto-repair

Auto-repair lets Libretto Cloud try to fix failed workflows for you. When a job fails, an autofix agent analyzes the run, edits the deployed workflow code, and publishes a replacement deployment when it has a fix.

Use `--auto-repair` when deploying from the CLI:

```bash theme={null}
npx libretto cloud deploy my-automations --auto-repair
```

### Next steps

<CardGroup cols={3}>
  <Card title="Deployments" icon="rocket" href="/libretto-cloud-hosting/deployments">
    Deploy workflow bundles and enable auto-repair.
  </Card>

  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Invoke workflows and inspect job state.
  </Card>

  <Card title="Debugging workflows" icon="bug" href="/guides/debugging-workflows">
    Use local tools to inspect and patch failed workflows.
  </Card>
</CardGroup>


---
title: Data retention
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/data-retention.md"
description: "These are the default retention periods for hosted Libretto Cloud. Custom retention periods can be negotiated. Email team@libretto.sh if you need a different..."
---

> How long Libretto Cloud keeps job results, debug artifacts, and logs.

These are the default retention periods for hosted Libretto Cloud. Custom retention periods can be negotiated. Email `team@libretto.sh` if you need a different window.

See also the [privacy policy](https://libretto.sh/privacy).

### Defaults

| Data                              | Default retention                                  |
| --------------------------------- | -------------------------------------------------- |
| Job results and run history       | For the life of your workspace                     |
| Saved credentials                 | Until you delete them, or the workspace is deleted |
| Deployed workflow code            | Until you delete the workflow                      |
| Failure screenshots and page HTML | 90 days                                            |
| Executor and workflow logs        | 30 days                                            |
| Session recordings                | 30 days, for runs that captured one                |

Job results are the `result` payload on `POST /v1/jobs/get` and on `job.completed` webhooks. Libretto Cloud does not automatically delete job history. Failed and cancelled runs keep their error and stack instead of a result.

Failure screenshots and page HTML are captured when your workflow code throws or times out, so a run that fails before your code starts may have neither. Signed download links are short-lived; request a new one when a link expires. The files themselves follow the 90-day window.

Executor and workflow logs are the records returned by `POST /v1/logs/list`. When a run fails and Libretto Cloud prepares a debug report, the logs it collected are stored alongside the other debug artifacts for that run and follow the 90-day window instead.

Session recordings are available through `POST /v1/recordings/get`. Headful runs, the default, are recorded; runs created with `headless: true` are not, and neither are runs that fail before a browser starts. A recording is kept for 30 days, and viewing links can expire sooner than that, so request a new link if one stops working while the recording still exists.

### Custom retention

If you need a longer or shorter retention period, email `team@libretto.sh`. Custom retention can be negotiated for job results, logs, and debug artifacts.

### Deleting data

Delete saved credentials with `POST /v1/credentials/delete` and workflows with `POST /v1/workflows/delete`.

Owners can delete their account from the dashboard, under account options on the users page. If you are the only member of the workspace, this also deletes the workspace, its job history, credentials, workflows, and API keys. In a shared workspace it removes your own access and leaves workspace data in place, and a sole owner has to promote another owner first. Debug artifacts already in object storage age out on the schedules above rather than being purged at the moment of deletion.

To delete job history without deleting the workspace, email `team@libretto.sh`.


---
title: Billing
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/billing.md"
description: "Only organization owners can view billing status or open billing-management links. Use billing portal to print a short-lived link to Libretto's plans page: The..."
---

> View your current cloud plan, open the Libretto plans page, and manage payment details through Stripe.

Only organization owners can view billing status or open billing-management links.

<Warning>
  Sign up or log in to your Libretto Cloud account before using these commands.
</Warning>

### Open the plans page

Use `billing portal` to print a short-lived link to Libretto's plans page:

```bash theme={null}
npx libretto cloud billing portal
```

The page shows:

* Your current plan.
* The currently available upgrade or downgrade options.
* A link for payment methods and invoices.
* A contact path for BAA and Enterprise requests.

### Check the current plan and usage

Use `billing status` to inspect the current plan, subscription status, usage, and period end:

```bash theme={null}
npx libretto cloud billing status
```

Example output:

```text
Plan:    Team (active)
Usage:   26.40 / 400 browser hours this period
Period:  ends 2026-05-21
```

If cancellation is already scheduled, the command prints that too.

### Cloud usage limits

These limits apply to Libretto Cloud. Local browser sessions on your own machine do not count toward cloud billing.

#### What counts toward usage

Browser hours accrue when Libretto Cloud provisions a browser session for:

* A browser session opened through Libretto Cloud.
* A workflow job that needs a browser session.

#### How usage is measured

Hosted usage is measured per browser session, from the time a session starts until Libretto records it as closed.

Each session is billed by the second and rounds up to the next whole second, with a one-second minimum per session. Browser seconds are priced at **$0.000069444 per second** (**$0.25 per browser hour**), which is reflected in the included hours on each plan.

#### Plans and pricing

| Plan       | Monthly price | Browser hours included |
| ---------- | ------------- | ---------------------- |
| Free       | $0            | 1                      |
| Pro        | $20           | 80                     |
| Team       | $100          | 400                    |
| Enterprise | Custom        | Custom                 |

Run `billing portal` to compare and switch plans.

If your organization exceeds its plan limit, Libretto Cloud will not spin up new browser sessions until you move to a plan with more capacity.

Job results stay for the life of your workspace. See [Data retention](/libretto-cloud-hosting/data-retention) for screenshots, logs, recordings, and custom retention.

### Change plans, payment methods, invoices, and cancellation

Run `billing portal`, open the printed plans page, and use it to change plans or open Stripe's customer portal for payment details, invoices, and cancellation. After any plan change, run `billing status` again in your terminal to confirm the updated plan.

### BAA and Enterprise

The hosted Libretto Cloud platform is fully HIPAA compliant. If you need a BAA
(which does not require an Enterprise plan), a custom data retention period,
or an Enterprise plan, please reach out to us at `team@libretto.sh`.

### Troubleshooting

* If the plans link says it is expired or invalid, run `billing portal` again to get a fresh link.
* If billing commands fail with an auth error, log in again or set `LIBRETTO_API_KEY`.
* If you can authenticate but still cannot access billing, confirm that your user is an organization owner.

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/libretto-cloud-hosting/authentication">
    Manage cloud login state, invites, and API keys.
  </Card>

  <Card title="Deployments" icon="rocket" href="/libretto-cloud-hosting/deployments">
    Deploy workflows and invoke them on Libretto Cloud.
  </Card>
</CardGroup>


---
title: Stealth
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/stealth.md"
description: "Every Libretto Cloud browser runs in stealth mode. Hosted runs use residential proxies and managed browser fingerprints, so sessions look more like normal user..."
---

> Libretto's stealth browsers help hosted runs avoid bot detection.

Every Libretto Cloud browser runs in stealth mode. Hosted runs use residential proxies and managed browser fingerprints, so sessions look more like normal user traffic than bare automation.

Stealth also includes automatic CAPTCHA handling. Libretto Cloud cannot guarantee every challenge will clear, but common CAPTCHA and bot-check pages can resolve without a manual handoff.

## Residential proxy locations

By default, Libretto Cloud uses a managed US residential proxy for each hosted browser. If the target site is location-sensitive, pass `residential_proxy` when you create a job, browser session, or schedule.

`country` is required and must be a two-letter ISO country code. You can refine US locations with `state` or `zip`, target a city with `city`, or target an ISP/network with `asn`.

```bash theme={null}
curl -X POST "https://api.libretto.sh/v1/jobs/create" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "workflow": "check-eligibility",
      "params": {
        "memberId": "12345"
      },
      "residential_proxy": {
        "country": "US",
        "state": "CA",
        "city": "san-francisco"
      }
    }
  }'
```

Supported fields:

* `country`: two-letter country code, such as `US`.
* `state`: two-letter US state code. Only supported with `country: "US"`.
* `city`: city slug without spaces.
* `zip`: five-digit US ZIP code. Only supported with `country: "US"`; cannot be combined with `city` or `state`.
* `asn`: network identifier such as `AS7922`; cannot be combined with `city` or `state`.

For recurring workflows, pass the same object to `residential_proxy` on `/v1/schedules/create` or `/v1/schedules/update`. To clear a schedule's configured proxy location, set `residential_proxy` to `null` on `/v1/schedules/update`.

## Disable default proxy (direct egress)

Some sites fail through Kernel's default residential stealth proxy with errors like `ERR_TUNNEL_CONNECTION_FAILED`. For those targets, pass `disable_default_proxy: true` when you create a job, browser session, or schedule. Libretto Cloud flips the Kernel session to direct egress before any `start_url` navigation.

`disable_default_proxy` is Kernel-only and mutually exclusive with `residential_proxy`.

```bash theme={null}
npx libretto cloud jobs create check-eligibility \
  --start-url 'https://example.com/dashboard' \
  --disable-default-proxy
```

## Workflow behavior

On Libretto Cloud, if a workflow reaches a CAPTCHA, Cloudflare check, or similar interstitial, wait up to 2 minutes before treating it as blocked. That gives the hosted browser time to finish the provider-side solve and continue to the page your workflow expects.

After the wait, assert on the real page state with a URL or locator. This keeps failures clear when a challenge is not solved.

```typescript theme={null}
// Wait for hosted CAPTCHA solving; see https://libretto.sh/docs/libretto-cloud-hosting/stealth.
await page.waitForTimeout(120_000);
await page.getByRole("heading", { name: "Dashboard" }).waitFor();
```

## Local and self-hosted runs

Stealth is included with Libretto Cloud and is also available through some other hosted browser providers. Local Chromium and self-hosted infrastructure do not include managed residential proxies or automatic CAPTCHA solving by default.

For self-hosted runs, set up your own external proxy and CAPTCHA-solving services, or solve challenges manually in a visible browser and continue from that state.


---
title: Website authentication
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-hosting/website-authentication.md"
description: "This page is about signing into the websites your workflows automate. For signing into Libretto Cloud itself and issuing API keys, see Libretto Cloud..."
---

> Authenticate into target websites during hosted Libretto Cloud runs.

This page is about signing into the websites your workflows automate. For signing into Libretto Cloud itself and issuing API keys, see [Libretto Cloud authentication](/libretto-cloud-hosting/authentication).

Hosted website authentication uses the same workflow code as local runs:

* `credentials` declares which secrets the workflow can use for login.
* `authProfile` declares the provider-native browser profile to reuse.
* `librettoAuthenticate` checks whether the profile is signed in and runs sign-in if needed.
* `claimSmsOtp` waits for a portal SMS one-time code on a tenant inbox number when the login texts MFA codes.

```typescript theme={null}
import { librettoAuthenticate, workflow } from "libretto";

export default workflow("portalReport", {
  startUrl: "https://portal.example.com",
  credentials: ["username", "password"],
  authProfile: {
    name: "portal",
    refresh: true,
  },
  async handler(ctx, input) {
    await librettoAuthenticate(ctx, {
      credentials: input.credentials,
      isSignedIn: async ({ page }) =>
        await page
          .getByRole("button", { name: /account|sign out/i })
          .isVisible()
          .catch(() => false),
      signIn: async ({ page }, credentials) => {
        await page.goto("https://portal.example.com/login");
        await page.getByLabel("Email").fill(credentials.username);
        await page.getByLabel("Password").fill(credentials.password);
        await page.getByRole("button", { name: /log in/i }).click();
      },
    });
  },
});
```

### Credentials in hosted runs

Hosted runs resolve declared workflow credentials from Libretto Cloud's encrypted credential store. They do not read credential values from the executor environment.

For local development, put matching `LIBRETTO_CLOUD_` variables in `.env`:

```dotenv theme={null}
LIBRETTO_CLOUD_USERNAME=alice@example.com
LIBRETTO_CLOUD_PASSWORD=secret
```

Push non-empty `LIBRETTO_CLOUD_` variables to Libretto Cloud:

```bash theme={null}
npx libretto cloud credentials push
```

See [Libretto Cloud credentials](/libretto-cloud-api/credentials).

### Profiles in hosted runs

Hosted profiles are provider-native browser profiles. They are not local `.libretto/profiles/<name>.json` files, and Libretto does not upload local profile files during deploy.

When you deploy a workflow that declares `authProfile`, Libretto Cloud registers the profile name if it does not already exist. When a hosted run starts, Libretto Cloud passes that profile name to the active browser provider.

If `refresh: true` is set, the hosted provider should persist browser state changes made during the run back to that profile. This lets a successful sign-in repair an expired profile for future runs.

### Local profiles versus hosted profiles

Local profiles are Playwright storage-state JSON files under `.libretto/profiles/`. They are useful for local runs and local development.

Hosted profiles are managed by the browser provider selected by Libretto Cloud. They are useful for deployed jobs and local CLI runs that use `--provider libretto-cloud`.

The two are intentionally separate. To seed a hosted profile, run the workflow with Libretto Cloud and let sign-in create or refresh the provider-native profile.

### SMS one-time codes

When a portal texts a one-time code, use Libretto Cloud SMS OTP instead of scraping an inbox or putting an API key in workflow source.

1. Provision a tenant inbox number (`libretto cloud sms-numbers provision --label <portal>` or the dashboard Phone numbers page).
2. Register that full phone number (with country code) as the MFA phone on the portal account. Prefer one number per portal.
3. In the workflow, await `claimSmsOtp` before clicking send-code, then `otp.wait()` for the code.

Hosted jobs inject a short-lived job token automatically. Locally set `LIBRETTO_API_KEY` (same key as deploy and CLI).

```typescript theme={null}
import { claimSmsOtp, librettoAuthenticate, workflow } from "libretto";

export default workflow("portalReport", {
  startUrl: "https://portal.example.com",
  credentials: ["username", "password"],
  authProfile: { name: "portal", refresh: true },
  async handler(ctx, input) {
    await librettoAuthenticate(ctx, {
      credentials: input.credentials,
      isSignedIn: async ({ page }) =>
        await page
          .getByRole("button", { name: /account|sign out/i })
          .isVisible()
          .catch(() => false),
      signIn: async ({ page }, credentials) => {
        await page.goto("https://portal.example.com/login");
        await page.getByLabel("Email").fill(credentials.username);
        await page.getByLabel("Password").fill(credentials.password);
        await page.getByRole("button", { name: /log in/i }).click();

        const otp = await claimSmsOtp({ phoneNumberLabel: "uhc" });
        await page.getByRole("button", { name: /send code/i }).click();
        const { code } = await otp.wait();
        await page.getByLabel("Code").fill(code);
        await page.getByRole("button", { name: /verify/i }).click();
      },
    });
  },
});
```

See [`claimSmsOtp`](/reference/runtime/sms-otp).


---
title: Alternative providers
url: "https://libretto-docs.vercel.app/docs/alternative-providers/overview.md"
description: "Libretto Cloud Hosting is the managed Libretto platform. Alternative providers are useful when you want to keep Libretto's CLI and workflow runtime, but choose..."
---

> Run Libretto workflows somewhere other than Libretto Cloud Hosting.

Libretto Cloud Hosting is the managed Libretto platform. Alternative providers are useful when you want to keep Libretto's CLI and workflow runtime, but choose a different browser or infrastructure layer.

There are two common patterns:

<CardGroup cols={2}>
  <Card title="Managed browser providers" icon="cloud" href="/alternative-providers/kernel">
    Kernel, Browserbase, and Steel host the browser session. Libretto connects to that browser over CDP and runs the same local commands and workflow code.
  </Card>

  <Card title="Self-hosted cloud infrastructure" icon="server" href="/alternative-providers/gcp">
    GCP and AWS run your workflow package as a container job. You own the runtime, scheduler, network, secrets, and artifacts.
  </Card>
</CardGroup>

## Providers

<CardGroup cols={2}>
  <Card title="Kernel" href="/alternative-providers/kernel">
    Use Kernel as the managed browser provider for `open` and `run`.
  </Card>

  <Card title="Browserbase" href="/alternative-providers/browserbase">
    Use Browserbase as the managed browser provider for `open` and `run`.
  </Card>

  <Card title="Steel" href="/alternative-providers/steel">
    Use Steel as the managed browser provider for `open` and `run`.
  </Card>

  <Card title="GCP" icon="google" href="/alternative-providers/gcp">
    Run workflows as Cloud Run Jobs with Artifact Registry, Cloud Build, and Secret Manager.
  </Card>

  <Card title="AWS" icon="aws" href="/alternative-providers/aws">
    Run workflows as ECS Fargate tasks with ECR, CodeBuild, and Secrets Manager.
  </Card>
</CardGroup>

## How provider selection works

Libretto chooses a browser provider in this order:

1. The `--provider` flag on `open` or `run`
2. `LIBRETTO_PROVIDER` in the environment
3. `provider` in `.libretto/config.json`
4. `local`

Put provider credentials in the repo root `.env` file. Libretto loads that file automatically before running CLI commands. Put the persistent provider choice in `.libretto/config.json` so `open` and `run` use the same browser provider without repeating `--provider`.

## Choosing an option

Use Kernel, Browserbase, or Steel when you only want to move browser execution to a managed provider. Use GCP or AWS when you want to own the full workflow hosting stack.

If you want Libretto to manage hosting, workflow deployment, job APIs, schedules, and failure diagnostics for you, use [Libretto Cloud Hosting](/libretto-cloud-hosting/overview).


---
title: Kernel
url: "https://libretto-docs.vercel.app/docs/alternative-providers/kernel.md"
description: "Kernel is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but you do not want to run Chromium on your own machine..."
---

> Run Libretto sessions on Kernel's managed browser infrastructure.

Kernel is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but you do not want to run Chromium on your own machine or cloud infrastructure.

## Configure Kernel

Create a Kernel API key, then add it to the repo root `.env` file. Libretto loads `.env` automatically before running CLI commands.

```dotenv theme={null}
# inside .env
KERNEL_API_KEY=<kernel-api-key>
```

Optional Kernel settings can go in the same file:

```dotenv theme={null}
# inside .env
KERNEL_HEADLESS=true
KERNEL_STEALTH=false
KERNEL_TIMEOUT_SECONDS=300
```

`KERNEL_ENDPOINT` is only needed if Kernel gives you a custom API endpoint.

Then set Kernel as the workspace's default browser provider in `.libretto/config.json`:

```json theme={null}
{
  "version": 1,
  "provider": "kernel"
}
```

You can also use `--provider kernel` on a single command instead of changing the config file.

## Open a session

Open a page with the configured Kernel provider:

```bash theme={null}
npx libretto open https://example.com --session kernel-demo
```

If you did not set `provider` in `.libretto/config.json`, pass it for this command instead:

```bash theme={null}
npx libretto open https://example.com --provider kernel --session kernel-demo
```

After the session is open, use the normal Libretto commands:

```bash theme={null}
npx libretto snapshot --session kernel-demo
npx libretto exec --session kernel-demo "await page.title()"
npx libretto close --session kernel-demo
```

## Run a workflow

Run workflows normally when `provider` is set in `.libretto/config.json`:

```bash theme={null}
npx libretto run ./workflow.ts --headless
```

Or pass the provider for a single run:

```bash theme={null}
npx libretto run ./workflow.ts --provider kernel --headless
```

The workflow code does not need to change. Libretto creates the Kernel browser session, connects to it over CDP, runs the workflow, and closes the provider session when the run finishes.

When the workflow declares `startUrl`, Libretto passes it to Kernel as `start_url` at create time and does not call `page.goto` after CDP attach. Libretto Cloud does the same for hosted sessions. Browserbase and Steel do not support create-time start URLs, so Libretto opens `startUrl` with Playwright right after CDP connect. Declare `gpu` and `viewport` on the workflow the same way when those sites need them. See [Workflow browser launch options](/reference/runtime/workflow#browser-launch-options).

## When to use Kernel

Choose Kernel when you want a managed browser runtime with minimal infrastructure work. If you need to own the container runtime, scheduler, network, and secrets layer yourself, use [GCP](/alternative-providers/gcp) or [AWS](/alternative-providers/aws) instead.


---
title: Browserbase
url: "https://libretto-docs.vercel.app/docs/alternative-providers/browserbase.md"
description: "Browserbase is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but want Browserbase to host the browser session...."
---

> Run Libretto sessions on Browserbase's managed browser infrastructure.

Browserbase is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but want Browserbase to host the browser session.

## Configure Browserbase

Create a Browserbase API key and project, then add both values to the repo root `.env` file. Libretto loads `.env` automatically before running CLI commands.

```dotenv theme={null}
# inside .env
BROWSERBASE_API_KEY=<browserbase-api-key>
BROWSERBASE_PROJECT_ID=<browserbase-project-id>
```

`BROWSERBASE_ENDPOINT` is only needed if Browserbase gives you a custom API endpoint.

Then set Browserbase as the workspace's default browser provider in `.libretto/config.json`:

```json theme={null}
{
  "version": 1,
  "provider": "browserbase"
}
```

You can also use `--provider browserbase` on a single command instead of changing the config file.

## Open a session

Open a page with the configured Browserbase provider:

```bash theme={null}
npx libretto open https://example.com --session browserbase-demo
```

If you did not set `provider` in `.libretto/config.json`, pass it for this command instead:

```bash theme={null}
npx libretto open https://example.com --provider browserbase --session browserbase-demo
```

After the session is open, use the normal Libretto commands:

```bash theme={null}
npx libretto snapshot --session browserbase-demo
npx libretto exec --session browserbase-demo "await page.title()"
npx libretto close --session browserbase-demo
```

## Run a workflow

Run workflows normally when `provider` is set in `.libretto/config.json`:

```bash theme={null}
npx libretto run ./workflow.ts --headless
```

Or pass the provider for a single run:

```bash theme={null}
npx libretto run ./workflow.ts --provider browserbase --headless
```

The workflow code does not need to change. Libretto creates the Browserbase browser session, connects to it over CDP, runs the workflow, and releases the provider session when the run finishes.

## When to use Browserbase

Choose Browserbase when you want a managed browser runtime and already use Browserbase for browser sessions. If you need to own the container runtime, scheduler, network, and secrets layer yourself, use [GCP](/alternative-providers/gcp) or [AWS](/alternative-providers/aws) instead.


---
title: Steel
url: "https://libretto-docs.vercel.app/docs/alternative-providers/steel.md"
description: "Steel is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but want Steel to host the browser session. Create a Steel..."
---

> Run Libretto sessions on Steel's managed browser infrastructure.

Steel is a managed browser provider. Use it when you want Libretto's local CLI and workflow runtime, but want Steel to host the browser session.

## Configure Steel

Create a Steel API key, then add it to the repo root `.env` file. Libretto loads `.env` automatically before running CLI commands.

```dotenv theme={null}
# inside .env
STEEL_API_KEY=<steel-api-key>
```

`STEEL_BASE_URL` is only needed if you use a custom Steel API endpoint. `STEEL_CONNECT_URL` is only needed if Steel gives you a custom CDP connection endpoint.

Then set Steel as the workspace's default browser provider in `.libretto/config.json`:

```json theme={null}
{
  "version": 1,
  "provider": "steel"
}
```

You can also use `--provider steel` on a single command instead of changing the config file.

## Open a session

Open a page with the configured Steel provider:

```bash theme={null}
npx libretto open https://example.com --session steel-demo
```

If you did not set `provider` in `.libretto/config.json`, pass it for this command instead:

```bash theme={null}
npx libretto open https://example.com --provider steel --session steel-demo
```

After the session is open, use the normal Libretto commands:

```bash theme={null}
npx libretto snapshot --session steel-demo
npx libretto exec --session steel-demo "await page.title()"
npx libretto close --session steel-demo
```

## Run a workflow

Run workflows normally when `provider` is set in `.libretto/config.json`:

```bash theme={null}
npx libretto run ./workflow.ts --headless
```

Or pass the provider for a single run:

```bash theme={null}
npx libretto run ./workflow.ts --provider steel --headless
```

The workflow code does not need to change. Libretto creates the Steel browser session, connects to it over CDP, runs the workflow, and releases the provider session when the run finishes.

## When to use Steel

Choose Steel when you want a managed browser runtime and already use Steel for browser sessions. If you need to own the container runtime, scheduler, network, and secrets layer yourself, use [GCP](/alternative-providers/gcp) or [AWS](/alternative-providers/aws) instead.


---
title: Deploy on GCP
url: "https://libretto-docs.vercel.app/docs/alternative-providers/gcp.md"
description: "Cloud Run Jobs are GCP's managed container runtime for one-shot tasks. Each invocation spins up a fresh container, runs your workflow to completion, and exits...."
---

> Run Libretto workflows as Cloud Run Jobs, triggered from your API or a scheduler.

Cloud Run Jobs are GCP's managed container runtime for one-shot tasks. Each invocation spins up a fresh container, runs your workflow to completion, and exits. That's a good fit for browser automations that take minutes rather than seconds.

### Prerequisites

* A GCP project with billing enabled.
* Artifact Registry, Cloud Run, Cloud Build, and Secret Manager APIs enabled.
* A service account with access to the secrets your workflows need.
* `gcloud` installed and authenticated locally.

<Steps>
  <Step title="Write a dispatcher entry point">
    A single image should be able to run any workflow in your package. The simplest way is a small `src/main.ts` that reads two env vars (which workflow to run, and its JSON input) then calls into the workflow directly:

    ```typescript theme={null}
    // src/main.ts
    import { pullReferrals } from "./workflows/pull-referrals";
    import { submitPriorAuth } from "./workflows/submit-prior-auth";

    const workflows = {
      "pull-referrals": pullReferrals,
      "submit-prior-auth": submitPriorAuth,
    } as const;

    type WorkflowName = keyof typeof workflows;

    async function main() {
      const name = process.env.LIBRETTO_WORKFLOW as WorkflowName | undefined;
      const inputJson = process.env.LIBRETTO_INPUT ?? "{}";

      if (!name || !(name in workflows)) {
        throw new Error(
          `Set LIBRETTO_WORKFLOW to one of: ${Object.keys(workflows).join(", ")}`,
        );
      }

      const input = JSON.parse(inputJson);
      await workflows[name](input);
    }

    void main();
    ```

    Wire it up in `package.json`:

    ```json theme={null}
    {
      "scripts": {
        "start": "tsx src/main.ts",
        "cloud:build": "gcloud builds submit --config cloudbuild.yaml .",
        "cloud:deploy": "gcloud run jobs replace browser-agent-job.yaml --region=us-central1"
      }
    }
    ```
  </Step>

  <Step title="Write the Dockerfile">
    Start from the official Playwright image so Chromium and all its dependencies are already present. The rest installs `pnpm`, copies your source, and hands off to the dispatcher on start:

    ```dockerfile theme={null}
    # Dockerfile
    FROM mcr.microsoft.com/playwright:v1.58.2-noble

    RUN apt-get update && apt-get install -y git \
      && corepack enable \
      && corepack prepare pnpm@latest --activate

    WORKDIR /app

    # Playwright browsers need to be in the root user's cache
    RUN mkdir -p /root/.cache && cp -a /ms-playwright /root/.cache/

    COPY . .
    RUN pnpm install --frozen-lockfile

    ENV NODE_ENV=production

    # The dispatcher reads LIBRETTO_WORKFLOW / LIBRETTO_INPUT from env
    ENTRYPOINT ["pnpm", "start"]
    ```

    Add a `.dockerignore` at the repo root so local state doesn't leak into the image:

    ```gitignore theme={null}
    node_modules
    */*/node_modules
    .git
    .gitignore
    dist
    build
    coverage
    .env
    .env.*
    *.log
    tmp/
    .DS_Store
    .libretto/sessions
    .libretto/profiles
    ```
  </Step>

  <Step title="Define the Cloud Run Job">
    Describe the job declaratively so you can version it alongside your code. The shape below matches what `gcloud run jobs replace` emits after you create a job through the console or CLI:

    ```yaml theme={null}
    # browser-agent-job.yaml
    apiVersion: run.googleapis.com/v1
    kind: Job
    metadata:
      name: browser-agent-job
    spec:
      template:
        spec:
          taskCount: 1
          template:
            spec:
              containers:
                - image: us-central1-docker.pkg.dev/PROJECT_ID/browser-agent/browser-agent:latest
                  resources:
                    limits:
                      cpu: 1000m
                      memory: 2Gi
              maxRetries: 0
              serviceAccountName: browser-agent-sa@PROJECT_ID.iam.gserviceaccount.com
              timeoutSeconds: "3600"
    ```

    The `serviceAccountName` is what grants the job permission to read secrets, write to GCS, and so on. Scope it tightly.
  </Step>

  <Step title="Build and deploy via Cloud Build">
    ```yaml theme={null}
    # cloudbuild.yaml
    steps:
      - name: gcr.io/cloud-builders/docker
        args:
          [
            "build",
            "-f", "Dockerfile",
            "-t", "us-central1-docker.pkg.dev/$PROJECT_ID/browser-agent/browser-agent:$BUILD_ID",
            "-t", "us-central1-docker.pkg.dev/$PROJECT_ID/browser-agent/browser-agent:latest",
            ".",
          ]
      - name: gcr.io/cloud-builders/docker
        args: ["push", "us-central1-docker.pkg.dev/$PROJECT_ID/browser-agent/browser-agent:$BUILD_ID"]
      - name: gcr.io/cloud-builders/docker
        args: ["push", "us-central1-docker.pkg.dev/$PROJECT_ID/browser-agent/browser-agent:latest"]
      - name: gcr.io/google.com/cloudsdktool/cloud-sdk
        entrypoint: gcloud
        args: ["run", "jobs", "replace", "browser-agent-job.yaml", "--region=us-central1"]

    options:
      logging: CLOUD_LOGGING_ONLY
      machineType: E2_HIGHCPU_32
    timeout: "1200s"
    ```

    Trigger a build and deploy:

    ```bash theme={null}
    pnpm cloud:build    # builds + pushes image, replaces job spec
    ```
  </Step>

  <Step title="Inject credentials at runtime">
    Never bake credentials into the image. Read them from Secret Manager inside your workflow dispatcher:

    ```typescript theme={null}
    import { SecretManagerServiceClient } from "@google-cloud/secret-manager";

    const sm = new SecretManagerServiceClient();

    export async function getSecret(name: string): Promise<string> {
      const [version] = await sm.accessSecretVersion({
        name: `projects/${process.env.GCP_PROJECT}/secrets/${name}/versions/latest`,
      });
      const payload = version.payload?.data?.toString();
      if (!payload) throw new Error(`Secret ${name} is empty`);
      return payload;
    }
    ```

    Grant the job's service account `roles/secretmanager.secretAccessor` on each secret it needs:

    ```bash theme={null}
    gcloud secrets add-iam-policy-binding MY_SECRET \
      --member="serviceAccount:browser-agent-sa@PROJECT_ID.iam.gserviceaccount.com" \
      --role="roles/secretmanager.secretAccessor"
    ```
  </Step>

  <Step title="Trigger the job">
    From your API (service-to-service), pass the workflow name and input as env var overrides so the dispatcher picks them up:

    ```typescript theme={null}
    import { JobsClient } from "@google-cloud/run";

    const client = new JobsClient();

    await client.runJob({
      name: "projects/PROJECT_ID/locations/us-central1/jobs/browser-agent-job",
      overrides: {
        containerOverrides: [
          {
            env: [
              { name: "LIBRETTO_WORKFLOW", value: "pull-referrals" },
              { name: "LIBRETTO_INPUT", value: JSON.stringify(input) },
            ],
          },
        ],
      },
    });
    ```

    From the CLI for ad-hoc runs:

    ```bash theme={null}
    gcloud run jobs execute browser-agent-job \
      --region=us-central1 \
      --update-env-vars LIBRETTO_WORKFLOW=pull-referrals,LIBRETTO_INPUT='{"limit":50}'
    ```
  </Step>

  <Step title="Observability">
    <ul>
      <li><strong>Logs:</strong> <code>gcloud logs read browser-agent-job --region=us-central1</code></li>
      <li><strong>Execution history:</strong> <code>gcloud run jobs executions list --job=browser-agent-job --region=us-central1</code></li>
      <li><strong>Session logs:</strong> upload <code>.libretto/sessions/\<name>/logs.jsonl</code> to GCS at the end of each run if you want to keep them beyond the job's lifetime.</li>
    </ul>
  </Step>
</Steps>

<Tip>
  Libretto writes detailed network, action, and snapshot logs under `.libretto/sessions/<name>/`. Pushing that directory to GCS on failure gives you a reproducible record for debugging. See [debugging workflows](/guides/debugging-workflows) for what to look at.
</Tip>


---
title: Deploy on AWS
url: "https://libretto-docs.vercel.app/docs/alternative-providers/aws.md"
description: "ECS on Fargate is the closest AWS analog to Cloud Run Jobs: you register a task definition, then invoke it on demand and Fargate starts a fresh container, runs..."
---

> Run Libretto workflows as ECS Fargate tasks, triggered from your API or EventBridge.

ECS on Fargate is the closest AWS analog to Cloud Run Jobs: you register a task definition, then invoke it on demand and Fargate starts a fresh container, runs the task, and exits. Libretto workflows map cleanly to this model.

### Prerequisites

* An AWS account with a VPC (default VPC is fine to start).
* ECR, ECS, CodeBuild, and Secrets Manager available in your target region.
* A task execution role and a task role scoped to the secrets your workflows need.
* AWS CLI installed and authenticated locally.

<Steps>
  <Step title="Write the dispatcher and Dockerfile">
    Use the same env-var dispatcher pattern as the GCP guide; the image is portable across clouds:

    ```typescript theme={null}
    // src/main.ts
    import { pullReferrals } from "./workflows/pull-referrals";
    import { submitPriorAuth } from "./workflows/submit-prior-auth";

    const workflows = {
      "pull-referrals": pullReferrals,
      "submit-prior-auth": submitPriorAuth,
    } as const;

    type WorkflowName = keyof typeof workflows;

    async function main() {
      const name = process.env.LIBRETTO_WORKFLOW as WorkflowName | undefined;
      const inputJson = process.env.LIBRETTO_INPUT ?? "{}";

      if (!name || !(name in workflows)) {
        throw new Error(
          `Set LIBRETTO_WORKFLOW to one of: ${Object.keys(workflows).join(", ")}`,
        );
      }
      await workflows[name](JSON.parse(inputJson));
    }

    void main();
    ```

    ```dockerfile theme={null}
    # Dockerfile
    FROM mcr.microsoft.com/playwright:v1.58.2-noble

    RUN apt-get update && apt-get install -y git \
      && corepack enable \
      && corepack prepare pnpm@latest --activate

    WORKDIR /app

    RUN mkdir -p /root/.cache && cp -a /ms-playwright /root/.cache/

    COPY . .
    RUN pnpm install --frozen-lockfile

    ENV NODE_ENV=production
    ENTRYPOINT ["pnpm", "start"]
    ```
  </Step>

  <Step title="Push the image to ECR">
    ```bash theme={null}
    aws ecr create-repository --repository-name browser-agent --region us-east-1

    aws ecr get-login-password --region us-east-1 | \
      docker login --username AWS --password-stdin \
      123456789012.dkr.ecr.us-east-1.amazonaws.com

    docker build -f apps/browser-agent/Dockerfile \
      -t 123456789012.dkr.ecr.us-east-1.amazonaws.com/browser-agent:latest .

    docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/browser-agent:latest
    ```

    For CI, wire the same steps up in CodeBuild with a `buildspec.yml` that tags both `:latest` and `:$CODEBUILD_RESOLVED_SOURCE_VERSION`.
  </Step>

  <Step title="Register the task definition">
    ```json theme={null}
    {
      "family": "browser-agent-task",
      "requiresCompatibilities": ["FARGATE"],
      "networkMode": "awsvpc",
      "cpu": "1024",
      "memory": "2048",
      "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
      "taskRoleArn": "arn:aws:iam::123456789012:role/browserAgentTaskRole",
      "containerDefinitions": [
        {
          "name": "browser-agent",
          "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/browser-agent:latest",
          "essential": true,
          "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
              "awslogs-group": "/ecs/browser-agent",
              "awslogs-region": "us-east-1",
              "awslogs-stream-prefix": "task"
            }
          }
        }
      ]
    }
    ```

    Register it:

    ```bash theme={null}
    aws ecs register-task-definition \
      --cli-input-json file://browser-agent-task.json \
      --region us-east-1
    ```
  </Step>

  <Step title="Inject credentials at runtime">
    Pull secrets from Secrets Manager inside your dispatcher, not the container environment, so they never appear in the task definition:

    ```typescript theme={null}
    import {
      SecretsManagerClient,
      GetSecretValueCommand,
    } from "@aws-sdk/client-secrets-manager";

    const client = new SecretsManagerClient({ region: process.env.AWS_REGION });

    export async function getSecret(name: string): Promise<string> {
      const { SecretString } = await client.send(
        new GetSecretValueCommand({ SecretId: name }),
      );
      if (!SecretString) throw new Error(`Secret ${name} is empty`);
      return SecretString;
    }
    ```

    Attach an IAM policy to `browserAgentTaskRole` that allows `secretsmanager:GetSecretValue` on the specific ARNs the workflows use.
  </Step>

  <Step title="Trigger the task">
    From your API, using the AWS SDK:

    ```typescript theme={null}
    import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";

    const ecs = new ECSClient({ region: "us-east-1" });

    await ecs.send(
      new RunTaskCommand({
        cluster: "browser-agent-cluster",
        launchType: "FARGATE",
        taskDefinition: "browser-agent-task",
        networkConfiguration: {
          awsvpcConfiguration: {
            subnets: ["subnet-abc123"],
            assignPublicIp: "ENABLED",
          },
        },
        overrides: {
          containerOverrides: [
            {
              name: "browser-agent",
              environment: [
                { name: "LIBRETTO_WORKFLOW", value: "pull-referrals" },
                { name: "LIBRETTO_INPUT", value: JSON.stringify(input) },
              ],
            },
          ],
        },
      }),
    );
    ```

    For scheduled runs, point an EventBridge rule at the task definition on a cron schedule.
  </Step>

  <Step title="Observability">
    <ul>
      <li><strong>Logs:</strong> Container stdout/stderr streams to CloudWatch Logs under the group declared in the task definition (<code>/ecs/browser-agent</code>).</li>
      <li><strong>Task history:</strong> <code>aws ecs list-tasks --cluster browser-agent-cluster --desired-status STOPPED</code>.</li>
      <li><strong>Session artifacts:</strong> upload <code>.libretto/sessions/\<name>/</code> to S3 at the end of each run for post-hoc debugging.</li>
    </ul>
  </Step>
</Steps>

<Tip>
  A failed task that uploads its session directory to S3 gives you everything you need to reproduce the failure locally: the network log, actions log, and snapshots. See [debugging workflows](/guides/debugging-workflows) for the diagnosis flow.
</Tip>


---
title: Libretto Cloud API
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/overview.md"
description: "The Libretto Cloud API base URL is https://api.libretto.sh. Most Libretto Cloud API routes in this section are POST requests. Published workflow catalogue and..."
---

> Use the Libretto Cloud HTTP API to create accounts, manage deployments, run workflows, and inspect results.

The Libretto Cloud API base URL is `https://api.libretto.sh`.

### Request format

Most Libretto Cloud API routes in this section are `POST` requests. Published
workflow catalogue and bookmark routes also use `GET` and `DELETE`.

Authenticated routes use:

* `x-api-key: <api-key>`
* `Content-Type: application/json`
* JSON request bodies for state-changing requests

The RPC routes documented in this section wrap input as `{ "json": { ... } }`.
Catalogue routes use direct JSON bodies as shown on the
[Workflow catalogue](/libretto-cloud-api/catalogue) page.

For example:

```bash
curl -X POST "https://api.libretto.sh/v1/jobs/get" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "<job-id>"
    }
  }'
```

### Authentication rules

* `POST /v1/auth/*` routes are public and do not require `x-api-key`.
* All other `POST /v1/*` routes require `x-api-key`.
* The API key must belong to a verified user account.

Use the [Libretto Cloud authentication](/libretto-cloud-hosting/authentication) page to sign up, log in, invite users, and issue API keys.

### Included routes

This section documents the public `/v1/*` routes exposed by Libretto Cloud in this checkout. Billing is managed through the plans flow and does not currently expose public `/v1/billing/*` routes here.

<CardGroup cols={2}>
  <Card title="Sessions" icon="lucide:monitor" href="/libretto-cloud-api/sessions">
    Create and close cloud browser sessions.
  </Card>

  <Card title="Recordings" icon="video" href="/libretto-cloud-api/recordings">
    Generate temporary recording URLs.
  </Card>

  <Card title="Deployments and Workflows" icon="rocket" href="/libretto-cloud-api/deployments-and-workflows">
    Upload bundles, build workflows, and inspect workflow state.
  </Card>

  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Start jobs, poll results, inspect debug reports, and fetch logs.
  </Card>

  <Card title="Workflow catalogue" icon="lucide:library" href="/libretto-cloud-api/catalogue">
    Search, run, and save workflows shared by other tenants.
  </Card>

  <Card title="Credentials" icon="lock" href="/libretto-cloud-api/credentials">
    Store and manage cloud credentials.
  </Card>

  <Card title="SMS OTP" icon="lucide:message-square" href="/libretto-cloud-api/sms-otp">
    Provision SMS inbox numbers and wait for portal OTP codes in workflows.
  </Card>

  <Card title="Webhooks" icon="lucide:webhook" href="/libretto-cloud-api/webhooks">
    Configure tenant-level job result webhooks.
  </Card>

  <Card title="Schedules" icon="clock" href="/libretto-cloud-api/schedules">
    Run workflows on a recurring cron-based interval.
  </Card>
</CardGroup>


---
title: Sessions
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/sessions.md"
description: "All routes on this page require x-api-key. Start a hosted browser session. Request fields: Prefer start_url over navigating with Playwright immediately after..."
---

> Use the sessions API to create, inspect, and close hosted browser sessions directly.

All routes on this page require `x-api-key`.

### POST `/v1/sessions/create`

Start a hosted browser session.

Request fields:

* `timeout_seconds`: optional session timeout, default `3600`, max `7200`
* `start_url`: optional URL opened before CDP is returned
* `gpu`: optional boolean GPU request
* `viewport`: optional `{ width, height }` viewport
* `headless`: optional boolean browser mode
* `residential_proxy`: optional residential proxy location object
* `disable_default_proxy`: optional boolean; Kernel only. Disables Kernel's default residential stealth proxy so traffic uses direct egress. Mutually exclusive with `residential_proxy`.

Prefer `start_url` over navigating with Playwright immediately after connect. Use `residential_proxy` for location-sensitive sessions that need traffic to originate from a specific residential proxy country, US state, city, ZIP code, or ASN. See [Residential proxy locations](/libretto-cloud-hosting/stealth#residential-proxy-locations) for the supported fields and combination rules.

Use `disable_default_proxy` when the default managed proxy cannot reach the target (for example `ERR_TUNNEL_CONNECTION_FAILED`). It cannot be combined with `residential_proxy`. When both `start_url` and `disable_default_proxy` are set, Libretto Cloud still opens `start_url` after switching to direct egress, so clients should treat the start URL as preloaded.

Response fields:

* `success`
* `session_id`
* `status`
* `cdp_url`
* `live_view_url`

Example:

```bash
curl -X POST "https://api.libretto.sh/v1/sessions/create" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "timeout_seconds": 1800
    }
  }'
```

### POST `/v1/sessions/get`

Inspect a hosted browser session.

Request fields:

* `session_id`: session id returned from `sessions/create`

Response fields:

* `session_id`
* `status`
* `cdp_url`
* `live_view_url`

Use this route when `sessions/create` returned a queued or starting session and you need to poll for the CDP URL or live view URL.

### POST `/v1/sessions/close`

Close a hosted browser session by session id.

Request fields:

* `session_id`: session id returned from `sessions/create`

Response fields:

* `success`
* `message`

Example:

```bash
curl -X POST "https://api.libretto.sh/v1/sessions/close" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "session_id": "<session-id>"
    }
  }'
```

<CardGroup cols={3}>
  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>

  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Run workflows and inspect results.
  </Card>

  <Card title="Recordings" icon="video" href="/libretto-cloud-api/recordings">
    Generate recording URLs for jobs and browser sessions.
  </Card>

  <Card title="Deployments and Workflows" icon="rocket" href="/libretto-cloud-api/deployments-and-workflows">
    Deploy workflows before invoking them.
  </Card>
</CardGroup>


---
title: Recordings
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/recordings.md"
description: "All routes on this page require x-api-key. Generate a recording URL for a job or browser session. Request fields: Provide exactly one of job_id or session_id...."
---

> Use the recordings API to generate temporary browser links for hosted browser recordings.

All routes on this page require `x-api-key`.

### POST `/v1/recordings/get`

Generate a recording URL for a job or browser session.

Request fields:

* `job_id`: optional job id
* `session_id`: optional browser session id

Provide exactly one of `job_id` or `session_id`.

Response fields:

* `recording_url`
* `recording_url_expires_at`

`recording_url` is `null` when no recording is available for the job or session. Runs created with `headless: true` are not recorded, and neither are runs that fail before a browser starts. When Libretto returns a URL, treat it as an opaque browser link.

Treat `recording_url` as temporary and refetch it rather than storing it. `recording_url_expires_at` is `null` when the link has no fixed expiry. If a URL stops working, call this route again with the same `job_id` or `session_id` to get a fresh one; the recording itself is not regenerated or deleted when a URL expires. Recordings remain available for 30 days after the run. See [Data retention](/libretto-cloud-hosting/data-retention).

Example:

```bash
curl -X POST "https://api.libretto.sh/v1/recordings/get" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "job_id": "<job-id>"
    }
  }'
```

<CardGroup cols={3}>
  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Run workflows and inspect job state.
  </Card>

  <Card title="Sessions" icon="lucide:monitor" href="/libretto-cloud-api/sessions">
    Create and close hosted browser sessions.
  </Card>

  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>
</CardGroup>


---
title: Deployments and workflows
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/deployments-and-workflows.md"
description: "All routes on this page require x-api-key. Upload a base64-encoded gzip tarball and create a hosted deployment. Request fields: Response fields: List..."
---

> Use the deployments API when you already have a workflow bundle, and use the workflows API to inspect deployed or AI-generated workflows.

All routes on this page require `x-api-key`.

### Deployments

#### POST `/v1/deployments/create`

Upload a base64-encoded gzip tarball and create a hosted deployment.

Request fields:

* `description`: optional deployment description
* `source`: base64-encoded gzip tarball
* `entry_point`: optional entry file, default `index.ts`
* `auto_repair`: optional boolean, default `false`. When `true`, failed jobs for this deployment route to the autofix agent instead of the readonly debug agent.

Response fields:

* `deployment_id`
* `status`: always `building`
* `created_at`

#### POST `/v1/deployments/list`

List deployments for the current tenant.

Send an empty request body:

```json
{ "json": {} }
```

Response fields for each deployment:

* `id`
* `description`
* `status`
* `source_path`
* `bundle_path`
* `build_error`
* `entry_point`
* `built_at`
* `created_at`
* `workflows`

#### POST `/v1/deployments/sync`

Poll a deployment until the build finishes and workflow discovery lands.

Request fields:

* `id`: deployment id

Response fields:

* `id`
* `description`
* `status`
* `source_path`
* `bundle_path`
* `build_error`
* `entry_point`
* `built_at`
* `created_at`
* `workflows`

### Workflows

#### POST `/v1/workflows/list`

List deployed workflows and in-progress workflow builds.

Response fields:

* `deployed_workflows`
* `in_progress_builds`

Each `deployed_workflows` entry includes:

* `name`
* `deployment_id`
* `deployment_status`
* `created_at`
* `updated_at`

Each `in_progress_builds` entry includes:

* `build_id`
* `status`
* `workflow_name`
* `summary`
* `error`
* `created_at`
* `updated_at`

#### POST `/v1/workflows/get`

Return the deployment backing a single workflow name, plus the JSON Schemas derived from the workflow's Zod input/output schemas.

Request fields:

* `workflow`: workflow name

Response fields:

* `workflow`
* `deployment_id`
* `deployment_status`
* `deployment_created_at`
* `deployment_built_at`
* `created_at`
* `updated_at`
* `input_schema`: JSON Schema (Draft 2020-12) serialized from the workflow's Zod input schema at build time. `null` for legacy deployments built before Zod-schema workflows were supported.
* `output_schema`: JSON Schema serialized from the workflow's Zod output schema. `null` for legacy deployments.

Example response for a Zod-schema workflow:

```json
{
  "json": {
    "workflow": "getExampleTitleAndUrl",
    "deployment_id": "062874fc-4714-4bd5-932f-0ae61ed8d0f6",
    "deployment_status": "ready",
    "deployment_created_at": "2026-05-17T01:15:26.670Z",
    "deployment_built_at": "2026-05-17T01:17:08.730Z",
    "created_at": "2026-05-17T01:15:28.167Z",
    "updated_at": "2026-05-17T01:17:31.314Z",
    "input_schema": {
      "type": "object",
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "properties": {
        "url": { "type": "string", "format": "uri" }
      },
      "additionalProperties": false
    },
    "output_schema": {
      "type": "object",
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "required": ["title", "currentUrl"],
      "properties": {
        "title": { "type": "string" },
        "currentUrl": { "type": "string", "format": "uri" }
      },
      "additionalProperties": false
    }
  }
}
```

#### POST `/v1/workflows/delete`

Delete a workflow by name.

Request fields:

* `workflow`: workflow name

Response fields:

* `success`
* `message`

### AI workflow build routes

#### POST `/v1/workflows/build`

Create an AI-driven workflow build from a natural-language description.

Request fields:

* `descriptions`: one or more natural-language descriptions
* `initial_url`: URL where the build should start
* `params`: optional workflow params schema example
* `network_log`: optional captured network log
* `action_log`: optional captured action log
* `entry_point`: optional entry file, default `index.ts`

Response fields:

* `build_id`
* `status`: `pending` or `agent_running`

#### POST `/v1/workflows/buildStatus`

Poll an AI workflow build.

Request fields:

* `build_id`

Response fields:

* `build_id`
* `status`
* `deployment_id`
* `workflow_name`
* `summary`
* `error`
* `details`
* `attempted_steps`
* `created_at`
* `updated_at`

### Example deploy poll

```bash
curl -X POST "https://api.libretto.sh/v1/deployments/sync" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "<deployment-id>"
    }
  }'
```

<CardGroup cols={3}>
  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Invoke workflows and inspect job state.
  </Card>

  <Card title="Sessions" icon="lucide:monitor" href="/libretto-cloud-api/sessions">
    Create browser sessions directly.
  </Card>

  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>
</CardGroup>


---
title: Workflow catalogue
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/catalogue.md"
description: "Catalogue commands require LIBRETTO_API_KEY. The API key's tenant owns each run and bookmark. Publishers do not receive access to the caller's tenant,..."
---

> Search, run, and save public workflows shared by other Libretto Cloud tenants.

Catalogue commands require `LIBRETTO_API_KEY`. The API key's tenant owns each
run and bookmark. Publishers do not receive access to the caller's tenant,
credentials, or results.

Each catalogue workflow also exposes reviewed source code on its public page.
See [Sharing workflows externally](/libretto-cloud-hosting/sharing-workflows-externally)
for the publisher flow.

### CLI

Search the public catalogue and inspect a workflow before running it:

```bash
libretto cloud catalogue search eligibility
libretto cloud catalogue get acme/check-eligibility
```

Start a run with input parameters and references to credentials already stored
in your Libretto Cloud tenant:

```bash
libretto cloud catalogue run acme/check-eligibility \
  --params '{"member_id":"123"}' \
  --credentials '{"portal_login":"payer-portal"}'
```

The command returns a job ID. Poll once or wait until it reaches a terminal
state:

```bash
libretto cloud catalogue status <job-id>
libretto cloud catalogue status <job-id> --watch
```

Save a workflow with an optional alias, notes, and non-secret defaults:

```bash
libretto cloud catalogue save acme/check-eligibility \
  --alias eligibility \
  --notes "Default west region" \
  --defaults '{"region":"west"}'

libretto cloud catalogue saved
libretto cloud catalogue run eligibility --params '{"member_id":"123"}'
libretto cloud catalogue remove <bookmark-id>
```

Explicit run parameters override saved defaults. Use `--json` on any command
for machine-readable output. Commands that accept JSON also accept the matching
file option, such as `--params-file`, `--credentials-file`, or
`--defaults-file`.

Do not put secret values in params or bookmark defaults. The `--credentials`
map contains stored credential IDs or names, not raw secrets.

### HTTP routes

All routes require `x-api-key`.

| Method   | Route                                    | Purpose                    |
| -------- | ---------------------------------------- | -------------------------- |
| `GET`    | `/v1/catalogue?q=<query>&limit=<limit>`  | Search the catalogue.      |
| `GET`    | `/v1/catalogue/:publisher/:workflow`     | Inspect one workflow.      |
| `POST`   | `/v1/catalogue/run/:publisher/:workflow` | Queue a run.               |
| `GET`    | `/v1/catalogue/bookmarks`                | List tenant bookmarks.     |
| `POST`   | `/v1/catalogue/bookmarks`                | Save or update a bookmark. |
| `DELETE` | `/v1/catalogue/bookmarks/:id`            | Remove a bookmark.         |

Poll run results with `POST /v1/jobs/get`. See
[Jobs and logs](/libretto-cloud-api/jobs-and-logs) for the response fields.

The bookmark save body uses this shape:

```json
{
  "workflow": "acme/check-eligibility",
  "alias": "eligibility",
  "notes": "Default west region",
  "default_params": {
    "region": "west"
  }
}
```

If a publisher stops sharing a workflow or disables sharing, the bookmark stays
in the tenant's saved list and becomes unavailable. Libretto does not return
the disabled publication's schemas and will not run it.


---
title: SMS OTP
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/sms-otp.md"
description: "SMS OTP is for portals that text a code to a phone number. Prefer authenticator TOTP (*_totp_secret) when the portal supports it. Use one phone number per..."
---

> Receive portal SMS one-time codes through tenant-owned Libretto Cloud inbox numbers, then wait for the code inside a workflow.

SMS OTP is for portals that text a code to a phone number. Prefer authenticator TOTP (`*_totp_secret`) when the portal supports it.

## How it works

1. Provision an inbox number into your tenant pool (CLI or dashboard).
2. Register that full phone number (with country code, like `+15551234567`) as the MFA phone on the portal account.
3. In the workflow: `await claimSmsOtp(...)` to lock an inbox, click send-code, then `await otp.wait()` for the code.

Use one phone number per portal (or per portal login). Do not run concurrent SMS OTP claims on the same inbox number. A claim locks the inbox for up to 5 minutes; pulling the code unlocks it, and a new claim can take over after the lock expires.

## Configure numbers (outside workflows)

```bash
libretto cloud sms-numbers provision --label uhc
libretto cloud sms-numbers list
```

Dashboard: **Phone numbers**. Copy the phone number (with country code) and set it on the portal MFA settings.

### POST `/v1/smsNumbers/provision`

Requires `x-api-key`.

Request fields:

* `label` (optional): portal label such as `uhc`
* `country` (optional): two-letter country code
* `area_code` (optional): preferred area code

### POST `/v1/smsNumbers/list`

Lists pool numbers. Pass `include_released: true` to include released rows.

### POST `/v1/smsNumbers/update`

Update a number's `label`.

### POST `/v1/smsNumbers/release`

Release the number and remove it from your tenant pool. Irreversible.

## Wait for a code in a workflow

`claimSmsOtp` first locks the inbox (exclusive claim), then returns a handle. Always await the claim before clicking send-code so the lock is held when the SMS arrives. Call `otp.wait()` after send-code.

Full runtime API: [`claimSmsOtp`](/reference/runtime/sms-otp).

Pass one of `phoneNumber`, `phoneNumberId`, or `phoneNumberLabel` to select the inbox.

Auth is automatic — do not put an API key in workflow source:

* Local: set `LIBRETTO_API_KEY` (same key as deploy/CLI)
* Hosted Cloud: dispatch injects a short-lived job token; `claimSmsOtp` picks it up with no extra args

```typescript
import { claimSmsOtp, workflow } from "libretto";

export default workflow("portal-login", {
  credentials: ["username", "password"],
  async handler(ctx, input) {
    // ... fill username/password, then:
    const otp = await claimSmsOtp({ phoneNumberLabel: "uhc" });
    // Inbox is locked. otp.phoneNumber is the claimed number.

    await ctx.page.click('button:has-text("Send code")');
    const { code } = await otp.wait();
    await ctx.page.fill('input[name="otp"]', code);
  },
});
```

`claimSmsOtp`:

1. `POST /v1/smsOtp/claims/create` — exclusive open claim on the inbox (returns after lock)
2. `otp.wait()` polls `POST /v1/smsOtp/claims/get` until fulfilled, then consumes the code once

`timeoutMs` (default 120s) is how long to poll after the claim opens. Claim TTL is derived from it (clamped to 30–300 seconds).

### Claim routes

Accept `x-api-key` or `x-libretto-job-token` (hosted jobs inject the job token automatically).

**Create** — `number_id`, `label`, or `phone_number`; optional `ttl_seconds` (30–300, default 300). `claimSmsOtp` sets `ttl_seconds` from `timeoutMs` and does not expose a separate TTL option.

**Get** — `claim_id`; optional `consume` (default true). Returns `code` once when fulfilled.

**Expire** — `claim_id`. Marks an open claim expired so the inbox lock is freed (used when `otp.wait()` times out).

## Isolation rules

* Numbers are tenant-scoped (RLS).
* Inbound SMS is matched to the inbox number that received it.
* At most one open claim per number (HTTP 409 if busy). Create expires stale open locks (≥5 minutes) before taking the lock.
* Codes are encrypted at rest and single-consume (returned once, then cleared).


---
title: Jobs and logs
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/jobs-and-logs.md"
description: All routes on this page require x-api-key. POST /v1/jobs/create starts a run and returns a job_id. It does not return the data the workflow collected. Whatever...
---

> Use the jobs API to run workflows and inspect results, and use the logs API to fetch executor logs for recent jobs.

All routes on this page require `x-api-key`.

### Get the workflow output

`POST /v1/jobs/create` starts a run and returns a `job_id`. It does not return the data the workflow collected.

Whatever the handler `return`s is stored on the completed job as `result`. That object matches the workflow `output` schema.

Poll `POST /v1/jobs/get` until the job finishes, then read `result`. A `job.completed` webhook delivers the same field. See [Webhooks](/libretto-cloud-api/webhooks).

```bash
curl -X POST "https://api.libretto.sh/v1/jobs/get" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "<job-id>"
    }
  }'
```

```json
{
  "job_id": "<job-id>",
  "workflow": "scrape-page",
  "status": "completed",
  "result": {
    "title": "Example Domain"
  }
}
```

### Jobs

The CLI `jobs` namespace is reserved for workflows deployed by your tenant:

```bash
libretto cloud jobs create check-eligibility --params '{"member_id":"123"}'
libretto cloud jobs status <job-id>
libretto cloud jobs status <job-id> --watch
```

Use `libretto cloud catalogue` instead when discovering or running a
workflow publicly shared by another tenant.

#### POST `/v1/jobs/create`

Start a workflow job.

Request fields:

* `workflow`: deployed workflow name
* `params`: workflow input object
* `nonce`: optional idempotency-style nonce
* `timeout_seconds`: optional timeout, default `1800`, max `3600`
* `headless`: optional boolean browser mode override
* `start_url`: optional start URL override for browser launch
* `gpu`: optional boolean GPU override
* `viewport`: optional `{ width, height }` viewport override
* `callback_url`: optional callback URL
* `callback_secret`: optional callback secret
* `skip_callbacks`: optional boolean
* `residential_proxy`: optional residential proxy location object
* `disable_default_proxy`: optional boolean; Kernel only. Disables Kernel's default residential stealth proxy so traffic uses direct egress. Mutually exclusive with `residential_proxy`. Use when sites fail with `ERR_TUNNEL_CONNECTION_FAILED` on the default proxy.

If you set `callback_url`, you must also set `callback_secret`.

Prefer declaring `startUrl`, `gpu`, and `viewport` on the workflow definition so they travel with the deploy artifact. Use the job-level fields when you need a one-off override, or until the hosted executor applies workflow launch metadata automatically.

Use `residential_proxy` for location-sensitive runs that need traffic to originate from a specific residential proxy country, US state, city, ZIP code, or ASN. See [Residential proxy locations](/libretto-cloud-hosting/stealth#residential-proxy-locations) for the supported fields and combination rules.

Use `disable_default_proxy` when the default managed proxy cannot reach the target (for example `ERR_TUNNEL_CONNECTION_FAILED`). It cannot be combined with `residential_proxy`.

Callback delivery order:

1. If the job has `callback_url` and `callback_secret`, Libretto Cloud delivers only to that per-job callback.
2. Otherwise, Libretto Cloud delivers to every active stored webhook endpoint for the tenant.
3. If `skip_callbacks` is true, Libretto Cloud does not deliver callbacks or stored webhooks.

Use `callback_url` for one-off or environment-specific result delivery. Use stored webhook endpoints for long-lived production destinations. See [Webhooks](/libretto-cloud-api/webhooks) for payload fields and signature headers.

Response fields:

* `success`
* `job_id`
* `status`: always `running`
* `message`

This response does not include `result`. See [Get the workflow output](#get-the-workflow-output).

#### POST `/v1/jobs/list`

List jobs for the current tenant.

Request fields:

* `status`: optional status filter
* `limit`: optional page size, default `20`, max `100`
* `cursor`: optional pagination cursor from `next_cursor`

Response fields:

* `jobs`
* `next_cursor`

Each job summary includes:

* `job_id`
* `deployment_id`
* `workflow_id`
* `workflow`
* `status`
* `created_at`
* `completed_at`

#### POST `/v1/jobs/get`

Return a single job and its result data.

Request fields:

* `id`: job id

Response fields:

* `job_id`
* `deployment_id`
* `deployment_version`
* `workflow_id`
* `workflow`
* `params`
* `status`
* `created_at`
* `started_at`
* `completed_at`
* `result`: the workflow handler return value after the job completes. Matches `schemas.output`.
* `error`
* `mapped_stack`

Use [Recordings](/libretto-cloud-api/recordings) when you need the job's recording URL.

#### POST `/v1/jobs/debugReport`

Return the hosted debug or autofix report for a failed job.

Request fields:

* `id`: job id

Response fields:

* `job_id`
* `debug_agent_status`
* `debug_agent_mode`
* `error`
* `tldr`
* `handoff_prompt`
* `screenshot_url`
* `dom_snapshot_url`
* `autofix_summary`
* `autofix_deployment_id`
* `email_sent_at`
* `job_error`
* `job_mapped_stack`
* `job_params`
* `job_workflow`

#### POST `/v1/jobs/cancel`

Cancel a queued or running job.

Request fields:

* `id`: job id

Response fields:

* `success`
* `message`

### Logs

#### POST `/v1/logs/list`

Fetch recent executor logs for one job or for recent jobs in the tenant.

Request fields:

* `jobId`: optional job id
* `workflow`: optional workflow-name filter
* `level`: optional `info`, `warn`, or `error`
* `pageToken`: optional next-page token
* `limit`: optional page size, default `100`, max `1000`

Response fields:

* `logs`
* `nextPageToken`

Each log entry includes:

* `jobId`
* `workflow`
* `level`
* `message`
* `timestamp`

### Example job run

```bash
curl -X POST "https://api.libretto.sh/v1/jobs/create" \
  -H "x-api-key: $LIBRETTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "workflow": "check-eligibility",
      "params": {
        "memberId": "12345"
      },
      "timeout_seconds": 300,
      "callback_url": "https://example.com/libretto/job-callback",
      "callback_secret": "replace-with-your-secret"
    }
  }'
```

Job results stay for the life of your workspace, while logs age out sooner. See [Data retention](/libretto-cloud-hosting/data-retention).

<CardGroup cols={3}>
  <Card title="Credentials" icon="lock" href="/libretto-cloud-api/credentials">
    Store credentials used by Libretto Cloud workflows.
  </Card>

  <Card title="Deployments and Workflows" icon="rocket" href="/libretto-cloud-api/deployments-and-workflows">
    Deploy workflows and inspect build state.
  </Card>

  <Card title="Webhooks" icon="lucide:webhook" href="/libretto-cloud-api/webhooks">
    Configure tenant-level job result webhooks.
  </Card>

  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>
</CardGroup>


---
title: Credentials
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/credentials.md"
description: All routes on this page require x-api-key. The credentials API stores encrypted tenant-scoped key/value credentials. A workflow must declare the credential...
---

> Use the credentials API to store, update, list, read, and delete tenant-scoped credentials.

All routes on this page require `x-api-key`.

### Using stored credentials in jobs

The credentials API stores encrypted tenant-scoped key/value credentials. A
workflow must declare the credential names it needs; hosted execution injects
only those names from Libretto Cloud's credential store into workflow params
under `credentials`. Local non-production `libretto run` injects matching
`LIBRETTO_CLOUD_` variables from `.env` into the same field.
For example, `LIBRETTO_CLOUD_OPENAI_API_KEY` resolves to
`input.credentials.openai_api_key`.

```typescript
import { workflow } from "libretto";

export default workflow("portal-login", {
  credentials: ["username", "password"],
  async handler(ctx, input) {
    const credentials = input.credentials;

    await ctx.page.fill('[name="username"]', credentials.username);
    await ctx.page.fill('[name="password"]', credentials.password);
  },
});
```

### POST `/v1/credentials/create`

Store a new credential value.

Request fields:

* `name`: credential name
* `value`: string value to encrypt and store

Response fields:

* `success`
* `credential_id`
* `message`

### POST `/v1/credentials/list`

List stored credentials.

Response fields:

* `credentials`

Each credential entry includes:

* `credential_id`
* `name`
* `created_at`
* `updated_at`

### POST `/v1/credentials/get`

Read a stored credential by name.

Request fields:

* `name`: credential name

Response fields:

* `id`
* `value`

### POST `/v1/credentials/update`

Update a credential by id.

Request fields:

* `id`: credential id
* `value`: optional replacement string value
* `name`: optional new name

Response fields:

* `success`
* `message`

### POST `/v1/credentials/delete`

Delete a credential by id.

Request fields:

* `id`: credential id

Response fields:

* `success`
* `message`

Deletion is blocked while queued or running jobs still reference the credential.

<CardGroup cols={3}>
  <Card title="Webhooks" icon="lucide:webhook" href="/libretto-cloud-api/webhooks">
    Configure tenant-level job result webhooks.
  </Card>

  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    Run workflows that may use stored credentials.
  </Card>

  <Card title="Schedules" icon="clock" href="/libretto-cloud-api/schedules">
    Schedule recurring workflow runs.
  </Card>
</CardGroup>


---
title: Webhooks
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/webhooks.md"
description: All routes on this page require x-api-key. Libretto Cloud supports multiple stored webhook endpoints per tenant. Every active stored endpoint receives the...
---

> Use the webhooks API to store tenant-level job result destinations that receive `job.completed` and `job.failed` events.

All routes on this page require `x-api-key`.

### How webhook delivery works

Libretto Cloud supports multiple stored webhook endpoints per tenant. Every active stored endpoint receives the event payload.

Webhook delivery order is:

1. If a job has `callback_url` and `callback_secret`, Libretto Cloud delivers only to that per-job callback.
2. Otherwise, Libretto Cloud delivers to every active stored webhook endpoint for the tenant.
3. If `skip_callbacks` is true, Libretto Cloud does not deliver callbacks or stored webhooks.

Use stored webhook endpoints for long-lived production destinations. Use `callback_url` and `callback_secret` on `jobs/create` or `schedules/create` when you want a one-off or environment-specific callback, such as a dev endpoint.

### Create a stored webhook endpoint

Use `POST /v1/webhooks/create` to create a stored webhook endpoint.

### POST `/v1/webhooks/create`

Create a stored webhook endpoint.

Request fields:

* `url`
* `signing_secret`
* `description`: optional
* `active`: optional, default `true`

Response fields:

* `success`
* `webhook_endpoint_id`
* `message`

### POST `/v1/webhooks/list`

List stored webhook endpoints for the current tenant.

Response fields:

* `webhook_endpoints`

Each endpoint entry includes:

* `webhook_endpoint_id`
* `url`
* `description`
* `active`
* `created_at`
* `updated_at`

### POST `/v1/webhooks/update`

Update a stored webhook endpoint.

Request fields:

* `id`
* `url`: optional
* `signing_secret`: optional
* `description`: optional
* `active`: optional

Response fields:

* `success`
* `message`

### POST `/v1/webhooks/delete`

Delete a stored webhook endpoint.

Request fields:

* `id`

Response fields:

* `success`
* `message`

### Delivered payload

Stored webhook endpoints receive:

* `event`: `job.completed` or `job.failed`
* `job_id`
* `nonce`
* `workflow`
* `deployment_id`
* `status`
* `result`: workflow handler return value on `job.completed`. Same field as `jobs/get`. See [Get the workflow output](/libretto-cloud-api/jobs-and-logs#get-the-workflow-output).
* `error`
* `mapped_stack`

The request includes:

* `x-webhook-signature`: HMAC SHA-256 of the JSON body using the stored `signing_secret`
* `x-event-type`: the event name

<CardGroup cols={3}>
  <Card title="Jobs and Logs" icon="terminal" href="/libretto-cloud-api/jobs-and-logs">
    See the jobs API that triggers webhook delivery.
  </Card>

  <Card title="Schedules" icon="clock" href="/libretto-cloud-api/schedules">
    Use per-schedule callbacks for recurring runs.
  </Card>

  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>
</CardGroup>


---
title: Schedules
url: "https://libretto-docs.vercel.app/docs/libretto-cloud-api/schedules.md"
description: All routes on this page require x-api-key. Schedules create jobs in the future. Each schedule can carry its own callback_url and callback_secret. If a...
---

> Use the schedules API to run a deployed workflow on a recurring cron-based interval.

All routes on this page require `x-api-key`.

### Callback behavior

Schedules create jobs in the future. Each schedule can carry its own `callback_url` and `callback_secret`.

If a scheduled job has a callback configured, that per-job callback takes precedence over stored tenant webhook endpoints. Use schedule-level callbacks when you want recurring jobs to report to a specific endpoint.

### POST `/v1/schedules/create`

Create a recurring schedule for a deployed workflow.

Request fields:

* `workflow`
* `params`
* `cron_expr`
* `timezone`: optional, default `UTC`
* `timeout_seconds`: optional, default `900`
* `start_url`: optional start URL override for browser launch
* `gpu`: optional boolean GPU override
* `viewport`: optional `{ width, height }` viewport override
* `callback_url`: optional
* `callback_secret`: optional
* `skip_callbacks`: optional, default `false`
* `residential_proxy`: optional residential proxy location object
* `disable_default_proxy`: optional boolean; Kernel only. Disables Kernel's default residential stealth proxy so traffic uses direct egress. Mutually exclusive with `residential_proxy`.
* `enabled`: optional, default `true`

If you set `callback_url`, you must also set `callback_secret`.

Prefer declaring `startUrl`, `gpu`, and `viewport` on the workflow definition so they travel with the deploy artifact. Use the schedule-level fields when you need a one-off override, or until the hosted executor applies workflow launch metadata automatically.

Use `residential_proxy` for recurring workflows that need traffic to originate from a specific residential proxy country, US state, city, ZIP code, or ASN. See [Residential proxy locations](/libretto-cloud-hosting/stealth#residential-proxy-locations) for the supported fields and combination rules.

Use `disable_default_proxy` when the default managed proxy cannot reach the target (for example `ERR_TUNNEL_CONNECTION_FAILED`). It cannot be combined with `residential_proxy`.

Response fields:

* `success`
* `schedule`

### POST `/v1/schedules/list`

List schedules for the current tenant.

Request fields:

* `enabled`: optional boolean filter
* `workflow`: optional workflow-name filter
* `limit`: optional page size, default `50`, max `200`

Response fields:

* `schedules`

### POST `/v1/schedules/get`

Fetch one schedule.

Request fields:

* `id`

Response fields:

* `schedule`

### POST `/v1/schedules/update`

Update a schedule.

Request fields:

* `id`
* `params`: optional
* `cron_expr`: optional
* `timezone`: optional
* `timeout_seconds`: optional
* `callback_url`: optional string or `null`
* `callback_secret`: optional string or `null`
* `skip_callbacks`: optional
* `residential_proxy`: optional object or `null`
* `disable_default_proxy`: optional boolean; Kernel only. Mutually exclusive with a non-null `residential_proxy`.
* `enabled`: optional

To clear the callback, set both `callback_url` and `callback_secret` to `null`.
To clear the configured residential proxy location, set `residential_proxy` to `null`.
To clear direct-egress mode and restore the default proxy, set `disable_default_proxy` to `false`.

Response fields:

* `schedule`

### POST `/v1/schedules/delete`

Delete a schedule.

Request fields:

* `id`

Response fields:

* `success`
* `id`

### Shared schedule response shape

The `schedule` object returned by the schedules endpoints includes:

* `id`
* `workflow`
* `params`
* `cron_expr`
* `timezone`
* `timeout_seconds`
* `residential_proxy`
* `disable_default_proxy`
* `callback_url`
* `skip_callbacks`
* `enabled`
* `next_fire_at`
* `last_fire_at`
* `last_job_id`
* `last_error`
* `consecutive_failures`
* `created_at`
* `updated_at`

<CardGroup cols={3}>
  <Card title="Webhooks" icon="lucide:webhook" href="/libretto-cloud-api/webhooks">
    Compare stored tenant webhooks with schedule-level callbacks.
  </Card>

  <Card title="Deployments and Workflows" icon="rocket" href="/libretto-cloud-api/deployments-and-workflows">
    Manage the workflows that schedules invoke.
  </Card>

  <Card title="Libretto Cloud API overview" icon="book-open" href="/libretto-cloud-api/overview">
    See the shared request format.
  </Card>
</CardGroup>
