← Blog

BLOG

Skills Agent-Browser: A Practical Schema for Browser Skills

Skills agent-browser: how to define a browser skill schema, wire it to hosted Chromium over CDP, and run it in production with Playwright.

October 11, 20268 min readRemote Browser

# Skills Agent-Browser: A Practical Schema for Browser Skills

A skills agent-browser setup is a way to give an AI agent a named, reusable browser capability — "log in and pull invoices," "fill the checkout form," "scrape the pricing table" — instead of asking the model to improvise every click. The skill is a contract: a name, a typed input schema, a typed output schema, and a deterministic implementation that drives a real browser. The agent decides *when* to call it; the skill decides *how* the page gets driven.

That split matters because it moves the fragile part of web automation out of the model's context window and into code you can test. This guide covers the schema shape, how to bind a skill to a hosted Chromium session over CDP, and what to check before you run skills in production.

Why a skill layer beats raw prompting

When an agent drives a browser directly from a prompt, every run re-derives the same decisions: which selector, which wait condition, which retry. That is expensive in tokens and unstable across model versions. A skill collapses those decisions into a function with a fixed signature.

The practical consequences:

  • Determinism where it counts. Selectors, waits, and retry logic live in version-controlled code, not in a prompt that drifts.
  • Cheaper context. The agent sees { name, description, inputSchema }, not a page-by-page transcript.
  • Testability. You can run a skill against a fixture page in CI without invoking a model at all.
  • Composability. A "checkout" skill can call a "login" skill and a "select shipping" skill.

The trade-off is real: skills are less flexible than free-form browsing. If the target site changes its DOM, a skill breaks loudly instead of silently adapting. For production workloads, loud breakage is usually the better failure mode.

The agent-browser skill schema

Most agent frameworks converge on a similar shape, borrowed from tool-calling APIs. A browser skill needs four things: an identity, a description the model can route on, a JSON Schema for inputs, and a declared output shape.

{
  "name": "extract_invoice_rows",
  "description": "Open the billing page for the signed-in account and return invoice rows as structured data.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "accountId": { "type": "string" },
      "since": { "type": "string", "format": "date" }
    },
    "required": ["accountId"]
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "rows": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "invoiceId": { "type": "string" },
            "amount": { "type": "number" },
            "currency": { "type": "string" },
            "issuedAt": { "type": "string" }
          },
          "required": ["invoiceId", "amount"]
        }
      }
    },
    "required": ["rows"]
  }
}

Three schema decisions are worth getting right early.

Keep inputs flat and primitive. Nested objects are harder for models to fill correctly and harder for you to validate. If a skill needs a date range, use two string fields rather than a nested range object.

Make outputs structured, not prose. Returning { rows: [...] } lets the agent reason over data. Returning "I found 12 invoices" forces the model to re-parse text and invites hallucination.

Version the name. extract_invoice_rows_v2 is uglier than a version field, but it is unambiguous in logs and lets you run both during a migration.

Binding a skill to a hosted browser session

A skill is only as reliable as the browser it runs in. Local Chrome on a developer laptop has a warm profile, a residential IP, and no concurrency pressure. Production has none of those. This is where a hosted runtime earns its place: you get an isolated Chromium session per skill invocation, a CDP endpoint you can connect to from anywhere, and a live viewer for debugging.

The connection pattern is the same one Playwright documents for connectOverCDP. You request a session, get a WebSocket endpoint, and attach.

import { chromium, Browser, Page } from 'playwright';

type SkillContext = {
  accountId: string;
  since?: string;
};

type InvoiceRow = {
  invoiceId: string;
  amount: number;
  currency: string;
  issuedAt: string;
};

// The runtime returns a CDP WebSocket URL for an isolated Chromium session.
async function openSession(): Promise<{ browser: Browser; cdpUrl: string }> {
  const res = await fetch('https://api.remote-browser.dev/v1/sessions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.REMOTE_BROWSER_KEY}`,
    },
    body: JSON.stringify({
      // Persistent profiles keep cookies and storage across runs.
      profile: 'billing-agent',
      // Configurable browser settings; check /documentation for the current set.
      viewport: { width: 1440, height: 900 },
    }),
  });

  if (!res.ok) throw new Error(`session create failed: ${res.status}`);
  const { cdpUrl } = (await res.json()) as { cdpUrl: string };

  const browser = await chromium.connectOverCDP(cdpUrl);
  return { browser, cdpUrl };
}

export async function extractInvoiceRows(
  ctx: SkillContext
): Promise<{ rows: InvoiceRow[] }> {
  const { browser } = await openSession();
  const context = browser.contexts()[0];
  const page: Page = context.pages()[0] ?? (await context.newPage());

  try {
    await page.goto('https://billing.example.com/invoices', {
      waitUntil: 'domcontentloaded',
    });

    // Prefer role-based locators; they survive most markup refactors.
    await page.getByRole('heading', { name: 'Invoices' }).waitFor();

    if (ctx.since) {
      await page.getByLabel('From date').fill(ctx.since);
      await page.getByRole('button', { name: 'Apply' }).click();
      await page.waitForLoadState('networkidle');
    }

    const rows = await page.$$eval('table.invoices tbody tr', (trs) =>
      trs.map((tr) => {
        const cells = tr.querySelectorAll('td');
        return {
          invoiceId: cells[0]?.textContent?.trim() ?? '',
          amount: Number(
            (cells[1]?.textContent ?? '0').replace(/[^0-9.]/g, '')
          ),
          currency: cells[1]?.getAttribute('data-currency') ?? 'USD',
          issuedAt: cells[2]?.textContent?.trim() ?? '',
        };
      })
    );

    return { rows };
  } finally {
    // Closing the browser releases the hosted session.
    await browser.close();
  }
}

Two details in that snippet are load-bearing. First, connectOverCDP attaches to a browser that already exists — you are not launching Chromium on the machine running the agent. Second, the finally block matters: a leaked session keeps billing browser-hours until it times out. See the documentation for session lifecycle and timeout defaults.

Skills vs. free-form agent browsing

The two approaches are not mutually exclusive, but they have different cost and reliability profiles. The table below reflects typical production experience rather than a benchmark.

DimensionFree-form agent browsingSkills agent-browser
Selector stabilityRe-derived per runFixed in code, versioned
Token cost per taskHigh — page state in contextLow — schema only
Failure modeSilent drift, wrong dataLoud exception, no data
TestabilityHard; needs a model in the loopEasy; run against fixtures
Adapts to new pagesYes, within model limitsNo, until you update the skill
DebuggingReplay the transcriptReplay the session in a live viewer
Best fitExploration, one-off tasksRepeated, well-defined tasks

A common production pattern is a hybrid: free-form browsing for discovery, then promote the successful trajectory into a skill once the workflow stabilizes. That is also how you avoid the trap of writing skills for pages you have not actually seen.

If you are still deciding between the two, Remote Browser for AI agents covers the runtime layer that both approaches share.

Production criteria for browser skills

Before a skill goes anywhere near a paying customer, it should pass a short list of checks.

Session isolation. Each invocation should get its own browser context. Two concurrent runs of the same skill must not share cookies or storage unless you deliberately want them to. Hosted runtimes handle this by default; self-hosted setups often do not.

Profile strategy. Some skills need a persistent profile (logged-in state, MFA trust). Others must start clean every time. Decide per skill, not per project. Persistent profiles are a feature, not a default.

Proxy and network posture. If the target site rate-limits by IP, a single egress address will get you blocked at concurrency. Residential or rotating proxies are a configuration choice; check what your runtime supports rather than assuming.

Timeouts and retries. A skill should have an explicit page-level timeout and a bounded retry count. Unbounded retries on a flaky selector turn a 30-second failure into a 10-minute one.

Observability. You need to know which skill ran, against which session, and what the DOM looked like when it failed. A live viewer plus session recording covers most of this. Without it, you are debugging from stack traces alone.

Cost accounting. Browser time is metered. A skill that idles waiting on networkidle for 20 seconds per run is a skill that costs more than it needs to. See pricing for how browser-hours are billed and what counts toward them.

Where the runtime boundary sits

It helps to be explicit about what the skill owns versus what the runtime owns.

The skill owns: navigation targets, selectors, wait conditions, input validation, output shaping, and retry policy.

The runtime owns: Chromium process lifecycle, session isolation, CDP endpoint exposure, profile persistence, proxy configuration, and the viewer.

Getting this boundary wrong is the most common source of production pain. If your skill code is managing Chromium processes, downloading browser binaries, or cleaning up zombie processes, you have moved runtime concerns into application code. That is exactly the work a hosted runtime exists to absorb. The remote browser online guide walks through what that looks like when you stop managing Chrome yourself.

A note on model compatibility

Skills are model-agnostic by design — the schema is just JSON, and any tool-calling model can route to it. But the *routing* quality varies. Smaller models tend to pick the wrong skill when descriptions overlap, so keep descriptions distinct and action-oriented. "Extract invoice rows from the billing page" routes better than "handles billing."

If you are running skills across multiple models, log which skill each model selected and how often it was the right one. That single metric tells you more about your schema quality than any prompt tuning.

Getting started

The shortest path is: pick one repeated browser task, write it as a skill with a typed schema, run it against a hosted session over CDP, and watch it in the live viewer. If it survives a week of real traffic without a selector change, promote it. If it breaks daily, the page is not stable enough to be a skill yet — keep it as free-form browsing and revisit later.

For the connection details, session API, and supported browser settings, start with the documentation. For the underlying protocol, the Chrome DevTools Protocol reference is the authoritative source on what CDP can and cannot do.