> ## 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.

# Price the same basket in 20 countries

> Twenty agents, each browsing from a different country, return what a local customer pays, and your program builds the comparison.

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 />

A streaming plan, a phone and a software subscription cost different amounts in Tokyo, Istanbul and Oslo, and the only way to see the local price is to visit the site from that country. By hand that is twenty VPN hops, four sites each, and a spreadsheet of currencies. This program sends one agent per country, each browsing from a profile pinned to that country, and turns their structured answers into one table in US dollars.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* A plan with enough browsers for the lanes you choose: Free 2, Builder 5, Pro 25, Scale 100. Every key on your account shares one browser slot count. Browsers, agent runs, crawls, searches and scrapes that aren't served from cache each hold a slot, and an agent run holds its slot until it ends. The program runs 4 at a time, which fits Builder.
* Node 18 or later for TypeScript, or Python 3.10 or later. Exchange rates come from the free, keyless Frankfurter API (ECB reference rates).

## How it works

<Steps>
  <Step title="One profile per country">
    [Profiles](/docs/account/profiles) are created once with an `exit` country and reused on every run. The browser's IP, timezone and language follow that exit, so each site serves its local store. Cookie banners dismissed on the first run stay dismissed.
  </Step>

  <Step title="One agent per country, with a schema">
    Each [agent run](/docs/agents/overview) gets the same four items and a JSON Schema. The run is judged against the schema, and `run.data` comes back in that shape: item, price, currency, billing period, source URL.
  </Step>

  <Step title="A fixed number of lanes, retried on refusal">
    Over your account's browser count, a submit is refused with `ConcurrencyLimitError`, never queued. The program runs 4 lanes, waits 20 seconds and retries a refused submit, and waits the stated `retryAfterSeconds` on a per-minute `QuotaExceededError`. See [errors](/docs/api/errors).
  </Step>

  <Step title="A ceiling for the whole batch">
    Each run has a hard `caps.budget`. Before a run starts, the program reserves that budget against a batch ceiling and releases it to the real cost when the run ends, so the batch cannot start a run it could not afford.
  </Step>

  <Step title="One table">
    The program converts every local price to US dollars at today's reference rate, prints a table and writes `basket-prices.json`.
  </Step>
</Steps>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import { writeFile } from 'node:fs/promises';
  import {
    ConcurrencyLimitError,
    NoCapacityError,
    Outcrawl,
    QuotaExceededError,
  } from '@outcrawl/sdk';

  const oc = new Outcrawl();

  // Every key on your account shares one browser slot count (Free 2, Builder 5,
  // Pro 25, Scale 100). Keep LANES below yours so other work still gets a slot.
  const LANES = 4;
  const RUN_BUDGET = '100.00'; // per country: about 6.5 minutes of a typical run
  const BATCH_CEILING = 1_500; // credits, across the whole batch

  const BASKET = [
    { id: 'netflix-standard', ask: 'Netflix Standard plan, billed monthly, on netflix.com' },
    { id: 'spotify-premium', ask: 'Spotify Premium Individual, billed monthly, on spotify.com' },
    { id: 'iphone-16-128gb', ask: 'iPhone 16 with 128 GB, bought outright, on apple.com' },
    { id: 'm365-personal', ask: 'Microsoft 365 Personal, billed yearly, on microsoft.com' },
  ];

  const COUNTRIES: [code: string, name: string][] = [
    ['us', 'United States'], ['ca', 'Canada'], ['mx', 'Mexico'], ['br', 'Brazil'],
    ['gb', 'United Kingdom'], ['de', 'Germany'], ['fr', 'France'], ['es', 'Spain'],
    ['se', 'Sweden'], ['no', 'Norway'], ['pl', 'Poland'], ['ch', 'Switzerland'],
    ['tr', 'Türkiye'], ['za', 'South Africa'], ['in', 'India'], ['jp', 'Japan'],
    ['kr', 'South Korea'], ['sg', 'Singapore'], ['au', 'Australia'], ['nz', 'New Zealand'],
  ];

  const SCHEMA = {
    type: 'object',
    properties: {
      items: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            item: { type: 'string', enum: BASKET.map((b) => b.id) },
            available: { type: 'boolean' },
            price: { type: 'number', description: 'The number shown, tax as the page shows it' },
            currency: { type: 'string', description: 'ISO 4217 code, e.g. JPY' },
            period: { type: 'string', enum: ['month', 'year', 'one-off'] },
            url: { type: 'string' },
          },
          required: ['item', 'available'],
        },
      },
    },
    required: ['items'],
  };

  interface Price {
    item: string;
    available: boolean;
    price?: number;
    currency?: string;
    period?: string;
    url?: string;
  }
  interface Row {
    country: string;
    name: string;
    prices: Price[];
    credits: number;
    problem?: string;
  }

  const sleep = (ms: number) => new Promise((wake) => setTimeout(wake, ms));

  // A refused call is retried; anything else is the caller's to see.
  async function withSlot<T>(call: () => Promise<T>): Promise<T> {
    for (let attempt = 1; ; attempt++) {
      try {
        return await call();
      } catch (error) {
        if (attempt === 8) throw error;
        if (error instanceof ConcurrencyLimitError || error instanceof NoCapacityError) {
          // Refused, not queued, and no Retry-After: a slot frees when one of your runs ends.
          await sleep(20_000);
        } else if (error instanceof QuotaExceededError && error.ceiling === 'requests_per_minute') {
          await sleep(error.retryAfterSeconds * 1_000);
        } else {
          throw error; // out of monthly credits, or a bad request: retrying cannot help
        }
      }
    }
  }

  // At most LANES items in flight: each lane takes the next item when it is free.
  async function inLanes<T, R>(items: readonly T[], work: (item: T) => Promise<R>): Promise<R[]> {
    const results: R[] = [];
    let next = 0;
    const lane = async () => {
      while (next < items.length) {
        const index = next++;
        results[index] = await work(items[index] as T);
      }
    };
    await Promise.all(Array.from({ length: Math.min(LANES, items.length) }, lane));
    return results;
  }

  // Reserve a run's cap before it starts; settle to what it really cost.
  let spent = 0;
  let reserved = 0;

  // 1. One profile per country, created once and reused on every run.
  const profiles = new Map((await oc.profiles.list({ label: 'basket-*', limit: 100 })).map((p) => [p.label, p.id]));
  for (const [code] of COUNTRIES) {
    const label = `basket-${code}`;
    if (!profiles.has(label)) profiles.set(label, (await oc.profiles.create({ label, exit: { country: code } })).id);
  }

  // 2. One agent per country.
  async function priceCountry([code, name]: [string, string]): Promise<Row> {
    const cap = Number(RUN_BUDGET);
    if (spent + reserved + cap > BATCH_CEILING) {
      return { country: code, name, prices: [], credits: 0, problem: 'skipped: batch ceiling reached' };
    }
    reserved += cap;
    let credits = 0;
    try {
      // Only the submit is retried: a refusal arrives before any run exists.
      const { job } = await withSlot(async () => {
        const job = oc.agent({
          task:
            `You are a new customer in ${name}. For each item below, open the official site as it ` +
            `is served in ${name} and find today's price for this country, in the local currency, ` +
            `as the page shows it. If an item is not sold in ${name}, set available to false.\n` +
            BASKET.map((b) => `- ${b.id}: ${b.ask}`).join('\n'),
          caps: { budget: RUN_BUDGET, duration: '8m' },
          schema: SCHEMA,
          profile: profiles.get(`basket-${code}`),
          rules: ['Never sign in, create an account, or start a checkout.'],
        });
        await job.submitted; // a refused submit throws here
        return { job }; // wrapped, because a job is awaitable and returning it bare waits for the run
      });
      const run = await job; // the lane holds its slot until the run ends
      credits = Number(run.credits);
      const data = run.data as { items?: Price[] } | undefined;
      return {
        country: code,
        name,
        prices: data?.items ?? [],
        credits,
        problem: run.status === 'completed' ? undefined : `${run.status} (${run.stopReason ?? run.error ?? 'no reason'})`,
      };
    } catch (error) {
      return { country: code, name, prices: [], credits, problem: String(error) };
    } finally {
      reserved -= cap;
      spent += credits;
    }
  }

  const rows = await inLanes(COUNTRIES, priceCountry);

  // 3. One table, in US dollars at today's ECB reference rate.
  const fx = (await (await fetch('https://api.frankfurter.dev/v1/latest?base=USD')).json()) as {
    date: string;
    rates: Record<string, number>;
  };
  const toUsd = (p: Price): number | undefined => {
    if (!p.available || p.price === undefined || p.currency === undefined) return undefined;
    const rate = p.currency === 'USD' ? 1 : fx.rates[p.currency];
    return rate === undefined ? undefined : p.price / rate;
  };

  console.log(`| Country | ${BASKET.map((b) => b.id).join(' | ')} |`);
  console.log(`| --- |${BASKET.map(() => ' --- |').join('')}`);
  for (const row of rows) {
    const cells = BASKET.map((b) => {
      const p = row.prices.find((price) => price.item === b.id);
      const usd = p === undefined ? undefined : toUsd(p);
      if (p === undefined || !p.available) return 'not sold';
      return usd === undefined ? `${p.price} ${p.currency}` : `$${usd.toFixed(2)} (${p.price} ${p.currency})`;
    });
    console.log(`| ${row.name} | ${row.problem ?? cells.join(' | ')} |`);
  }
  console.log(`\n${rows.length} countries, ${spent.toFixed(2)} credits, rates of ${fx.date}`);

  await writeFile('basket-prices.json', JSON.stringify({ rates: fx.date, rows }, null, 2));
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import json

  import httpx
  from outcrawl import ConcurrencyLimitError, NoCapacityError, Outcrawl, QuotaExceededError

  # Every key on your account shares one browser slot count (Free 2, Builder 5,
  # Pro 25, Scale 100). Keep LANES below yours so other work still gets a slot.
  LANES = 4
  RUN_BUDGET = "100.00"  # per country: about 6.5 minutes of a typical run
  BATCH_CEILING = 1_500  # credits, across the whole batch

  BASKET = [
      ("netflix-standard", "Netflix Standard plan, billed monthly, on netflix.com"),
      ("spotify-premium", "Spotify Premium Individual, billed monthly, on spotify.com"),
      ("iphone-16-128gb", "iPhone 16 with 128 GB, bought outright, on apple.com"),
      ("m365-personal", "Microsoft 365 Personal, billed yearly, on microsoft.com"),
  ]

  COUNTRIES = [
      ("us", "United States"), ("ca", "Canada"), ("mx", "Mexico"), ("br", "Brazil"),
      ("gb", "United Kingdom"), ("de", "Germany"), ("fr", "France"), ("es", "Spain"),
      ("se", "Sweden"), ("no", "Norway"), ("pl", "Poland"), ("ch", "Switzerland"),
      ("tr", "Türkiye"), ("za", "South Africa"), ("in", "India"), ("jp", "Japan"),
      ("kr", "South Korea"), ("sg", "Singapore"), ("au", "Australia"), ("nz", "New Zealand"),
  ]

  SCHEMA = {
      "type": "object",
      "properties": {
          "items": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "item": {"type": "string", "enum": [item for item, _ in BASKET]},
                      "available": {"type": "boolean"},
                      "price": {"type": "number", "description": "The number shown, tax as the page shows it"},
                      "currency": {"type": "string", "description": "ISO 4217 code, e.g. JPY"},
                      "period": {"type": "string", "enum": ["month", "year", "one-off"]},
                      "url": {"type": "string"},
                  },
                  "required": ["item", "available"],
              },
          }
      },
      "required": ["items"],
  }


  async def with_slot(call):
      """A refused call is retried; anything else is the caller's to see."""
      for attempt in range(1, 9):
          try:
              return await call()
          except (ConcurrencyLimitError, NoCapacityError):
              # Refused, not queued, and no Retry-After: a slot frees when one of your runs ends.
              if attempt == 8:
                  raise
              await asyncio.sleep(20)
          except QuotaExceededError as error:
              # Out of monthly credits: retrying cannot help. Per-minute: wait as told.
              if error.ceiling != "requests_per_minute" or attempt == 8:
                  raise
              await asyncio.sleep(error.retry_after_seconds)


  async def main() -> None:
      async with Outcrawl() as oc:
          # 1. One profile per country, created once and reused on every run.
          profiles = {p["label"]: p["id"] for p in await oc.profiles.list(label="basket-*", limit=100)}
          for code, _ in COUNTRIES:
              label = f"basket-{code}"
              if label not in profiles:
                  profiles[label] = (await oc.profiles.create(label=label, exit={"country": code}))["id"]

          lanes = asyncio.Semaphore(LANES)
          tally = {"spent": 0.0, "reserved": 0.0}  # reserve a run's cap; settle to its real cost

          # 2. One agent per country.
          async def price_country(code: str, name: str) -> dict:
              async with lanes:
                  cap = float(RUN_BUDGET)
                  if tally["spent"] + tally["reserved"] + cap > BATCH_CEILING:
                      return {"country": code, "name": name, "prices": [], "credits": 0,
                              "problem": "skipped: batch ceiling reached"}
                  tally["reserved"] += cap
                  credits = 0.0
                  try:
                      async def submit():
                          job = oc.agent(
                              task=(
                                  f"You are a new customer in {name}. For each item below, open the official "
                                  f"site as it is served in {name} and find today's price for this country, "
                                  f"in the local currency, as the page shows it. If an item is not sold in "
                                  f"{name}, set available to false.\n"
                                  + "\n".join(f"- {item}: {ask}" for item, ask in BASKET)
                              ),
                              caps={"budget": RUN_BUDGET, "duration": "8m"},
                              schema=SCHEMA,
                              profile=profiles[f"basket-{code}"],
                              rules=["Never sign in, create an account, or start a checkout."],
                          )
                          await job.submitted  # a refused submit raises here
                          return job

                      # Only the submit is retried: a refusal arrives before any run exists.
                      job = await with_slot(submit)
                      run = await job  # the lane holds its slot until the run ends
                      credits = float(run["credits"])
                      data = run.get("data") or {}
                      problem = None
                      if run["status"] != "completed":
                          problem = f"{run['status']} ({run.get('stopReason') or run.get('error') or 'no reason'})"
                      return {"country": code, "name": name, "prices": data.get("items", []),
                              "credits": credits, "problem": problem}
                  except Exception as error:
                      return {"country": code, "name": name, "prices": [], "credits": credits, "problem": str(error)}
                  finally:
                      tally["reserved"] -= cap
                      tally["spent"] += credits

          rows = await asyncio.gather(*(price_country(code, name) for code, name in COUNTRIES))

      # 3. One table, in US dollars at today's ECB reference rate.
      async with httpx.AsyncClient() as http:
          fx = (await http.get("https://api.frankfurter.dev/v1/latest", params={"base": "USD"})).json()

      def cell(price: dict | None) -> str:
          if price is None or not price.get("available"):
              return "not sold"
          amount, currency = price.get("price"), price.get("currency")
          rate = 1 if currency == "USD" else fx["rates"].get(currency)
          if amount is None or rate is None:
              return f"{amount} {currency}"
          return f"${amount / rate:.2f} ({amount} {currency})"

      print("| Country | " + " | ".join(item for item, _ in BASKET) + " |")
      print("| --- |" + " --- |" * len(BASKET))
      for row in rows:
          by_item = {p["item"]: p for p in row["prices"]}
          cells = " | ".join(cell(by_item.get(item)) for item, _ in BASKET)
          print(f"| {row['name']} | {row['problem'] or cells} |")
      print(f"\n{len(rows)} countries, {tally['spent']:.2f} credits, rates of {fx['date']}")

      with open("basket-prices.json", "w") as out:
          json.dump({"rates": fx["date"], "rows": rows}, out, indent=2, ensure_ascii=False)


  asyncio.run(main())
  ```
</CodeGroup>

A refusal arrives on the submit, before any run exists, so the retry wraps the submit and nothing else. A retry never starts a second run for the same country. Once a run exists it holds its slot until it ends, and the lane waits for it.

## What you get back

The printed table (rows shortened, prices illustrative):

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
| Country | netflix-standard | spotify-premium | iphone-16-128gb | m365-personal |
| --- | --- | --- | --- | --- |
| United States | $17.99 (17.99 USD) | $11.99 (11.99 USD) | $799.00 (799 USD) | $99.99 (99.99 USD) |
| United Kingdom | $17.17 (12.99 GBP) | $15.85 (11.99 GBP) | $1056.25 (799 GBP) | $112.35 (84.99 GBP) |
| Türkiye | $3.89 (189.99 TRY) | $2.05 (99.99 TRY) | $1330.45 (64999 TRY) | not sold |
| Japan | $10.01 (1590 JPY) | $6.80 (1080 JPY) | $785.65 (124800 JPY) | $135.35 (21500 JPY) |
| India | $5.20 (499 INR) | $1.24 (119 INR) | $832.64 (79900 INR) | $60.43 (5799 INR) |
| Norway | capped (max-budget) |

20 countries, 629.00 credits, rates of 2026-09-24
```

And `basket-prices.json`, one row per country in the schema's shape:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "rates": "2026-09-24",
  "rows": [
    {
      "country": "jp",
      "name": "Japan",
      "prices": [
        { "item": "netflix-standard", "available": true, "price": 1590, "currency": "JPY", "period": "month", "url": "https://www.netflix.com/jp-en/" },
        { "item": "spotify-premium", "available": true, "price": 1080, "currency": "JPY", "period": "month", "url": "https://www.spotify.com/jp/premium/" },
        { "item": "iphone-16-128gb", "available": true, "price": 124800, "currency": "JPY", "period": "one-off", "url": "https://www.apple.com/jp/shop/buy-iphone/iphone-16" },
        { "item": "m365-personal", "available": true, "price": 21500, "currency": "JPY", "period": "year", "url": "https://www.microsoft.com/ja-jp/microsoft-365/microsoft-365-personal" }
      ],
      "credits": 31.0
    }
  ]
}
```

A country the fleet has no exit for, or a run that hit its cap, comes back with `problem` set and no prices. The rest of the table is unaffected.

## What it costs

An agent run is billed for the model's usage, browser time and proxy usage ([pricing](/docs/billing/pricing)). A typical run costs about 15 credits a minute all in, proxy usage included; the real figure depends on the task, and a page-heavy site costs more.

* One country: about 2 minutes across the four sites, so **about 30 credits**.
* Twenty countries: **about 600 credits**, which the batch ceiling of 1,500 bounds.
* Wall clock: 20 runs over 4 lanes is 5 rounds of about 2 minutes, so about 10 minutes. With 20 lanes on Pro it is one round.

## Make it yours

* Swap the basket for flights, hotel nights or car rentals, and add a `date` field to the schema.
* Pin a city, not only a country: `exit: { country: 'us', region: 'texas', city: 'austin' }`.
* Run it weekly and diff `basket-prices.json` against last week's to catch a price change in one market.
* Set `LANES` from your plan: 4 on Builder, 20 on Pro, 90 on Scale, always below the plan's count.

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">
    Tasks, caps, schemas and what a run returns.
  </Card>

  <Card title="Profiles" icon="id" href="/docs/account/profiles">
    Saved logins and a pinned geography, reused across runs.
  </Card>

  <Card title="Rules" icon="shield-check" href="/docs/account/rules">
    Plain-language limits the run is held to.
  </Card>

  <Card title="Errors" icon="alert-triangle" href="/docs/api/errors">
    What `concurrency_limit` and `quota_exceeded` mean, and which are retryable.
  </Card>
</CardGroup>


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