> ## Documentation Index
> Fetch the complete documentation index at: https://outcrawl.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Script the sign-in, hand the rest to an agent

> Playwright for the steps that never change, an agent for the pages that always do.

export const OutcrawlStyles = () => {
  if (typeof document !== 'undefined' && !document.getElementById('outcrawl-overrides')) {
    const s = document.createElement('style');
    s.id = 'outcrawl-overrides';
    s.textContent = `
      /* Match the product's radius grid (4 / 6 / 8 / 9999), not Mintlify's default. */
      .rounded-sm { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-md { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-lg { border-radius: 0.5rem !important; }   /* 8px, --r-panel */
      .rounded-l-sm { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-sm { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-md { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-md { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-lg { border-top-left-radius: 0.5rem !important; border-bottom-left-radius: 0.5rem !important; }
      .rounded-r-lg { border-top-right-radius: 0.5rem !important; border-bottom-right-radius: 0.5rem !important; }

      /* Cards: never pure white in light mode, matching the one-big-card surface tone. */
      .card { background-color: #fcfcfc !important; border-radius: 0.5rem !important; }
      html.dark .card { background-color: #141311 !important; }

      /* Docs hero box */
      .oc-hero { background-color: #fcfcfc; border: 1px solid #e5e2dc; }
      html.dark .oc-hero { background-color: #141311; border-color: #26241f; }
      html.dark .oc-hero h1 { color: #edeae4; }

      /* Thin, transparent-track, hide-until-hover scrollbar. */
      ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; }
      ::-webkit-scrollbar-track { background-color: transparent; }
      ::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 10px; transition: opacity 0.3s ease; opacity: 0; }
      ::-webkit-scrollbar-thumb:hover { background-color: rgba(155, 155, 155, 0.7); }
      *:hover::-webkit-scrollbar-thumb,
      *:focus::-webkit-scrollbar-thumb,
      *:active::-webkit-scrollbar-thumb { opacity: 1; }
      * { scrollbar-width: thin; scrollbar-color: rgba(155, 155, 155, 0.5) transparent; }
    `;
    document.head.appendChild(s);
  }
  return null;
};

<OutcrawlStyles />

Every morning someone on an operations team signs in to the warehouse portal, opens the list of shipments with exceptions, and chases each one through a message thread and a carrier's tracking page. The first half is the same clicks every day. The second half is different every time: UPS, FedEx and a regional freight carrier each lay out tracking their own way. This program scripts the first half with Playwright and gives the second half to an agent, one shipment at a time.

This example is TypeScript only: `oc.browser()`, the browser you drive with Playwright, exists only in the TypeScript SDK.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* The portal's sign-in in `NORTHGATE_EMAIL` and `NORTHGATE_PASSWORD`. The script types them itself; the agent never sees them.
* `npm install @outcrawl/sdk playwright`. Playwright is only the driver; the browser runs on Outcrawl.

## How it works

<Steps>
  <Step title="Open a browser in a profile">
    `oc.browser({ profile })` opens a [browser](/docs/browsers/overview) in a [profile](/docs/account/profiles), so the portal's login is kept after the first run and the script skips the sign-in from then on.
  </Step>

  <Step title="Script the part that never changes">
    Sign in with the SDK's human-input methods, then read the exceptions table row by row with Playwright through `page.raw`. The list is read exactly, not interpreted by a model.
  </Step>

  <Step title="Close the browser and pass the profile on">
    An agent run cannot attach to a browser you opened. What it can share is the profile: closing the browser frees your browser slot and the profile, and the agent run starts signed in to the same account.
  </Step>

  <Step title="Give each exception to an agent">
    Each [agent run](/docs/agents/overview) reads one shipment's thread, follows its carrier's tracking page wherever it leads, and answers in a schema. A per-run [rule](/docs/account/rules) forbids messages, claims and changes to a shipment.
  </Step>
</Steps>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import type { Page } from 'playwright';
  import { Outcrawl } from '@outcrawl/sdk';

  const oc = new Outcrawl();
  const PORTAL = 'https://portal.northgate-3pl.example';

  interface Exception {
    shipment: string;
    carrier: string;
    flaggedOn: string;
    trackingUrl: string;
  }

  interface Finding {
    whatHappened: string;
    lastScan: string;
    newEstimate?: string;
    askedOfUs?: string;
  }

  const findingSchema = {
    type: 'object',
    properties: {
      whatHappened: { type: 'string', description: 'the exception in one sentence' },
      lastScan: { type: 'string', description: 'the most recent carrier scan: place, time and event' },
      newEstimate: { type: 'string', description: "the carrier's current delivery estimate, YYYY-MM-DD" },
      askedOfUs: { type: 'string', description: 'what the carrier or the warehouse asks us to do, if anything' },
    },
    required: ['whatHappened', 'lastScan'],
  };

  // The profile keeps the portal's login between runs, and is what the agent inherits.
  const [existing] = await oc.profiles.list({ label: 'northgate-ops' });
  const profile = existing ?? (await oc.profiles.create({ label: 'northgate-ops' }));

  // 1. The known part, scripted: sign in if needed and read the exceptions table.
  const exceptions: Exception[] = [];
  const browser = await oc.browser({ profile: profile.id });
  try {
    const page = await browser.newPage();
    const pw = page.raw as unknown as Page; // page.raw is the real Playwright page

    await page.goto(`${PORTAL}/shipments?status=exception`);
    if (pw.url().includes('/login')) {
      await page.fill('#email', process.env.NORTHGATE_EMAIL!);
      await page.fill('#password', process.env.NORTHGATE_PASSWORD!);
      await page.click('button[type=submit]');
      await pw.waitForURL('**/shipments**');
    }

    await pw.waitForSelector('table.shipments');
    for (const row of await pw.locator('table.shipments tbody tr').all()) {
      const [shipment = '', carrier = '', flaggedOn = ''] = await row.locator('td').allInnerTexts();
      const trackingUrl = (await row.locator('a.tracking').getAttribute('href')) ?? '';
      exceptions.push({ shipment: shipment.trim(), carrier: carrier.trim(), flaggedOn: flaggedOn.trim(), trackingUrl });
    }
  } finally {
    // Closing frees the browser slot and the profile. The login stays in the profile.
    await browser.close();
  }
  console.log(`${exceptions.length} shipments flagged`);

  // 2. The messy part, one agent run per shipment, in the same signed-in profile.
  const report = [];
  for (const exception of exceptions) {
    const run = await oc.agent({
      task:
        `Open ${PORTAL}/shipments/${exception.shipment} and read the exception note and the message thread. ` +
        `Then open the ${exception.carrier} tracking page at ${exception.trackingUrl} and read the most recent scan. ` +
        "Report what went wrong, the last scan, the carrier's new delivery estimate, and anything we are asked to do.",
      profile: profile.id,
      schema: findingSchema,
      caps: { budget: '25.00' },
      rules: ['Never send a message, file a claim, or change, cancel or reship a shipment.'],
    });
    const finding = run.data as Finding | undefined;
    report.push({
      shipment: exception.shipment,
      carrier: exception.carrier,
      lastScan: finding?.lastScan ?? `(run ${run.status})`,
      newEstimate: finding?.newEstimate ?? '',
      askedOfUs: finding?.askedOfUs ?? '',
      credits: run.credits,
    });
  }

  console.table(report);
  ```
</CodeGroup>

The browser closes in `finally`, whatever happens in between. A browser that is never closed keeps holding one of your account's browser slots until its lease runs out, six hours later. The agent runs go one after another because a profile holds one session at a time.

## What you get back

`console.table(report)` prints one row per exception (a sample):

| shipment | carrier | lastScan | newEstimate | askedOfUs | credits |
| - | - | - | - | - | - |
| NG-48213 | UPS | Memphis, TN, 25 Sep 06:12: held, address needs correcting | 2026-09-29 | Confirm the suite number for 1200 Harbor Blvd | 13.00 |
| NG-48190 | FedEx | Oakland, CA, 24 Sep 22:40: delivery exception, weather | 2026-09-26 | | 10.25 |
| NG-48177 | Estes | Reno, NV, 24 Sep 15:05: pallet short one carton | 2026-10-01 | The warehouse asks whether to ship the short pallet as is | 16.00 |

## What it costs

A browser you drive yourself is 1 credit a minute, plus proxy usage. The scripted part takes about 45 seconds: **under 1 credit**. An agent run is billed for the model's usage, browser time and proxy usage, and a typical run costs about 15 credits a minute all in, proxy usage included. A shipment takes about 50 seconds (the shipment, the carrier's tracking page, one detail view): **about 13 credits per exception**, with a `caps.budget` of 25 for each run. That is an estimate: the real figure depends on the task, and a heavy tracking page costs more.

Twelve exceptions a morning is about 157 credits, or about 3,450 over 22 working days. Reading the table with an agent as well would add about 30 seconds, 8 credits a day, and the list would come from a model instead of from the page.

## Make it yours

* Run the script from a scheduler at 07:00 and post the table to your team's channel with a Slack incoming webhook.
* Filter in the script: send only exceptions older than a day to an agent.
* Use `page.extract(instruction, schema)` inside the scripted session for a one-off read that is not worth a whole agent run.
* When the portal changes its sign-in form, only the four sign-in lines change; the agents do not care.

## Related

<CardGroup cols={2}>
  <Card title="Browsers" icon="device-desktop" href="/docs/browsers/overview">`oc.browser()`, `page.raw` and human input.</Card>
  <Card title="Profiles" icon="id" href="/docs/account/profiles">The login the script and the agents share.</Card>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Schemas, caps and statuses.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Guardrails for each run.</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.