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

# Give your AI app a browser

> One tool, browse(task), that lets your own assistant use live websites and cite them.

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

Your assistant can reason about anything except what is on a website right now: a room rate for next weekend, a return policy, a price behind a date picker. This program gives your own tool-calling model one tool, `browse(task)`. Each call runs an Outcrawl agent in a real browser, returns a schema-shaped answer with its sources, and your model writes the reply.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* Your model provider's key. The program uses OpenAI tool calling (`OPENAI_API_KEY`); Anthropic tool use works the same way.
* `npm install @outcrawl/sdk openai` or `pip install outcrawl openai`.

## How it works

<Steps>
  <Step title="Declare one tool">
    Your model sees a single function, `browse`, that takes a plain-language task. Nothing about Outcrawl leaks into your prompt.
  </Step>

  <Step title="Run an agent with a schema">
    Each call submits an [agent run](/docs/agents/overview) with a JSON schema, so every answer comes back as `{ answer, sources }` rather than free text.
  </Step>

  <Step title="Cap it and set its rules">
    Each run's `caps.budget` is 40 credits. A per-run [rule](/docs/account/rules) tells it not to sign in, buy anything or submit personal details, and the buying part is enforced.
  </Step>

  <Step title="Watch it, then feed the result back">
    The submit answers with a `watchUrl` you can open to watch the browser live. The finished run's `data` goes back to your model as the tool result, with its status and what it cost.
  </Step>
</Steps>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import OpenAI from 'openai';
  import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions';
  import { ConcurrencyLimitError, Outcrawl } from '@outcrawl/sdk';

  const openai = new OpenAI(); // reads OPENAI_API_KEY
  const oc = new Outcrawl(); // reads OUTCRAWL_API_KEY

  // The one tool your assistant gets.
  const browseTool: ChatCompletionTool = {
    type: 'function',
    function: {
      name: 'browse',
      description:
        'Do a task in a real web browser on live websites: search, open pages, pick dates, read what the page shows. ' +
        'Use it when the answer depends on current information from a specific site. ' +
        'Returns an answer and the URLs it came from.',
      parameters: {
        type: 'object',
        properties: {
          task: {
            type: 'string',
            description: 'What to do and what to report back, in one or two sentences. Name the site when you know it.',
          },
        },
        required: ['task'],
        additionalProperties: false,
      },
    },
  };

  // Every browse call answers in this shape, so the model reads the same thing each time.
  const answerSchema = {
    type: 'object',
    properties: {
      answer: { type: 'string', description: 'The answer to the task, with the specific numbers, dates and names found.' },
      sources: {
        type: 'array',
        items: {
          type: 'object',
          properties: { url: { type: 'string' }, title: { type: 'string' } },
          required: ['url'],
        },
      },
    },
    required: ['answer', 'sources'],
  };

  async function browse(task: string): Promise<string> {
    try {
      const job = oc.agent({
        task,
        schema: answerSchema,
        caps: { budget: '40.00' }, // about 2.5 minutes of a typical run
        rules: ['Do not sign in, buy anything, or submit any form with personal details.'],
      });
      const { watchUrl } = await job.submitted;
      console.log(`browse: ${task}\n  watch it live: ${watchUrl ?? 'recording declined'}`);

      const run = await job;
      if (run.data === undefined) {
        return JSON.stringify({ error: `The browser run ended ${run.status} (${run.stopReason ?? 'no reason given'}).` });
      }
      // `partial` and `capped` runs still carry what they found. Tell the model which it got.
      return JSON.stringify({ ...(run.data as object), status: run.status, credits: run.credits });
    } catch (err) {
      if (err instanceof ConcurrencyLimitError) {
        return JSON.stringify({ error: 'Every browser on this account is busy right now. Try again shortly.' });
      }
      throw err;
    }
  }

  const messages: ChatCompletionMessageParam[] = [
    {
      role: 'system',
      content:
        'You are a travel assistant. Use the browse tool for anything that depends on live prices or availability, ' +
        'and cite the URLs it returns.',
    },
    {
      role: 'user',
      content:
        'Two adults at The Hoxton, Williamsburg, Friday 16 to Sunday 18 October. ' +
        'What is the cheapest room I can still cancel for free, and until when?',
    },
  ];

  for (;;) {
    const completion = await openai.chat.completions.create({ model: 'gpt-5', messages, tools: [browseTool] });
    const message = completion.choices[0]!.message;
    messages.push(message);

    const calls = message.tool_calls ?? [];
    if (calls.length === 0) {
      console.log(message.content);
      break;
    }
    // One call at a time: each browse holds one of your account's browser slots while it runs.
    for (const call of calls) {
      if (call.type !== 'function') continue;
      const { task } = JSON.parse(call.function.arguments) as { task: string };
      messages.push({ role: 'tool', tool_call_id: call.id, content: await browse(task) });
    }
  }
  ```

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

  from openai import AsyncOpenAI
  from outcrawl import ConcurrencyLimitError, Outcrawl

  # The one tool your assistant gets.
  BROWSE_TOOL = {
      "type": "function",
      "function": {
          "name": "browse",
          "description": (
              "Do a task in a real web browser on live websites: search, open pages, pick dates, read what the page shows. "
              "Use it when the answer depends on current information from a specific site. "
              "Returns an answer and the URLs it came from."
          ),
          "parameters": {
              "type": "object",
              "properties": {
                  "task": {
                      "type": "string",
                      "description": "What to do and what to report back, in one or two sentences. Name the site when you know it.",
                  }
              },
              "required": ["task"],
              "additionalProperties": False,
          },
      },
  }

  # Every browse call answers in this shape, so the model reads the same thing each time.
  ANSWER_SCHEMA = {
      "type": "object",
      "properties": {
          "answer": {"type": "string", "description": "The answer to the task, with the specific numbers, dates and names found."},
          "sources": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {"url": {"type": "string"}, "title": {"type": "string"}},
                  "required": ["url"],
              },
          },
      },
      "required": ["answer", "sources"],
  }


  async def browse(oc: Outcrawl, task: str) -> str:
      try:
          job = oc.agent(
              task=task,
              schema=ANSWER_SCHEMA,
              caps={"budget": "40.00"},  # about 2.5 minutes of a typical run
              rules=["Do not sign in, buy anything, or submit any form with personal details."],
          )
          submitted = await job.submitted
          print(f"browse: {task}\n  watch it live: {submitted.get('watchUrl', 'recording declined')}")
          run = await job
      except ConcurrencyLimitError:
          return json.dumps({"error": "Every browser on this account is busy right now. Try again shortly."})

      data = run.get("data")
      if data is None:
          return json.dumps({"error": f"The browser run ended {run['status']} ({run.get('stopReason', 'no reason given')})."})
      # `partial` and `capped` runs still carry what they found. Tell the model which it got.
      return json.dumps({**data, "status": run["status"], "credits": run["credits"]})


  async def main() -> None:
      llm = AsyncOpenAI()  # reads OPENAI_API_KEY
      async with Outcrawl() as oc:  # reads OUTCRAWL_API_KEY
          messages: list = [
              {
                  "role": "system",
                  "content": "You are a travel assistant. Use the browse tool for anything that depends on live prices "
                  "or availability, and cite the URLs it returns.",
              },
              {
                  "role": "user",
                  "content": "Two adults at The Hoxton, Williamsburg, Friday 16 to Sunday 18 October. "
                  "What is the cheapest room I can still cancel for free, and until when?",
              },
          ]

          while True:
              completion = await llm.chat.completions.create(model="gpt-5", messages=messages, tools=[BROWSE_TOOL])
              message = completion.choices[0].message
              messages.append(message)

              if not message.tool_calls:
                  print(message.content)
                  break
              # One call at a time: each browse holds one of your account's browser slots while it runs.
              for call in message.tool_calls:
                  if call.type != "function":
                      continue
                  task = json.loads(call.function.arguments)["task"]
                  messages.append({"role": "tool", "tool_call_id": call.id, "content": await browse(oc, task)})


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

A tool call that fails still answers the model. A run that ends without data returns its status and stop reason; a full account returns "busy" instead of throwing. `ConcurrencyLimitError` means every browser slot on your account is in use. 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. This is why the program runs tool calls one at a time.

## What you get back

The console, while it runs:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
browse: On thehoxton.com, find rooms at The Hoxton, Williamsburg for 2 adults, 16 to 18 October, and report the cheapest rate with free cancellation and its cancellation deadline.
  watch it live: https://outcrawl.ai/play/ses_01K5ZQ7M3W
```

The tool result your model receives (a sample; the figures are illustrative):

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "answer": "Cheapest rate with free cancellation: Cosy King, Flexible rate, $289 a night, $578 for both nights before tax. Free cancellation until 3 pm on Wednesday 14 October, after which the first night is charged. The same room is $259 a night on the non-refundable rate.",
  "sources": [
    { "url": "https://thehoxton.com/williamsburg/rooms/", "title": "Rooms | The Hoxton, Williamsburg" }
  ],
  "status": "completed",
  "credits": "15.50"
}
```

And the reply your model writes from it:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
The cheapest room you can cancel for free is the Cosy King on the Flexible rate: $289 a night, $578 for the two nights before tax. You can cancel without charge until 3 pm on Wednesday 14 October. (Source: thehoxton.com/williamsburg/rooms)
```

## What it costs

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 lookup like this one takes about 60 seconds: **about 15 credits per browse call**, with a `caps.budget` of 40 for each call. That is an estimate: the real figure depends on the task, and how much a page weighs varies.

A Builder plan's 10,000 monthly credits covers about 650 browse calls at that size. The model in your own app is billed by your model provider, not by Outcrawl.

## Make it yours

* Run several tool calls at once with `Promise.all` or `asyncio.gather`, keeping the number below your plan's concurrent browsers.
* Add a `profile` to the agent call so `browse` works on sites where your user is signed in.
* Stream the run to your UI: `for await (const record of job)` yields each step as it happens.
* Give the model a second tool, `read(url)`, backed by `oc.scrape` at 1 credit a page, for pages that need no interaction.

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Schemas, caps and statuses.</Card>
  <Card title="Monitoring a run" icon="activity" href="/docs/agents/monitoring">The watch link and the event stream.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">What a run is allowed to do.</Card>
  <Card title="Pricing" icon="tag" href="/docs/billing/pricing">Every rate on this page.</Card>
</CardGroup>


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