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

# Book the first appointment slot that fits your week

> A monitor watches a booking page; when a slot opens, your rules pick it and an agent books it, or holds it for you to confirm in the live view.

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

The dermatologist is booked out for four months, the passport office for six weeks, and cancellations appear at random and go within the hour. Refreshing a booking page all day is the only way to get one. This program watches the page for you, reads every open slot the moment the page changes, picks the first one that fits your rules in your own code, and either books it or gets it to the last screen and hands you the live browser to confirm it yourself.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* A [profile](/docs/account/profiles) signed in to the booking site with your details saved on the account, and its id in `PROFILE`. The monitor and every run use it, so they see the page as you do.
* A server reachable from the internet (the monitor posts to it), and a long random string in `HOOK_TOKEN` for its paths.
* A way to reach your phone. The program posts to an [ntfy](https://ntfy.sh) topic; any push or chat webhook works the same way.

Book for yourself, one appointment at a time. The program deletes its monitor once a booking is made (in hand-off mode, when you tap the done link), so it never takes a second slot.

## How it works

<Steps>
  <Step title="A monitor watches the booking page">
    [`monitors.create`](/docs/monitors/overview) checks the page every 10 minutes with your profile. It posts to your endpoint only when the page really changes, and a diff that only removes lines (a slot going) is ignored.
  </Step>

  <Step title="A first run lists every open slot">
    Your endpoint starts an [agent run](/docs/agents/overview) that books nothing: it answers a [schema](/docs/agents/overview#typed-results) listing each open slot's date, time, clinician and location.
  </Step>

  <Step title="Your code picks the slot">
    `acceptable()` applies your rules (weekdays, before 10:00 or after 16:00, within 21 days) to that list. The agent never decides which slot is good enough; your code does, before anything irreversible happens.
  </Step>

  <Step title="Book mode: a second run books exactly that slot">
    It books the slot your code approved and no other, and returns the confirmation number. [Rules](/docs/account/rules) keep it on the clinic's site and stop it making a payment.
  </Step>

  <Step title="Hand-off mode: you confirm it in the live view">
    The run picks the slot and goes through the form. When its event stream shows the slot being picked, your code calls [`control('take')`](/docs/agents/human-in-the-loop), and your phone gets the live view link. You press take control there, confirm, and tap the done link, which ends the run and deletes the monitor.
  </Step>
</Steps>

## The program

Run it once with `watch` to create the monitor, then run it as a server. Set `SLOT_MODE=hand-off` to confirm bookings yourself; it books on its own otherwise.

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import express from 'express';
  import { setTimeout as sleep } from 'node:timers/promises';
  import { Outcrawl } from '@outcrawl/sdk';

  const oc = new Outcrawl(); // reads OUTCRAWL_API_KEY

  const BOOKING_PAGE = 'https://booking.riversideclinic.example/dermatology';
  const PROFILE = 'prof_8c1d2e'; // the id of a profile already signed in to the booking site
  const PUBLIC_URL = 'https://yourapp.example';
  const TOKEN = process.env.HOOK_TOKEN ?? ''; // long and random: it is the only credential on these paths
  const NTFY = 'https://ntfy.sh/your-private-topic'; // push to your phone
  const MODE = process.env.SLOT_MODE === 'hand-off' ? 'hand-off' : 'book';

  // Your rules, checked in code before anything is booked.
  const WITHIN_DAYS = 21;
  const WEEKDAYS = [1, 2, 3, 4, 5]; // Monday to Friday
  const acceptable = (slot: Slot) => {
    const at = new Date(`${slot.date}T${slot.time}:00`);
    const hour = at.getHours();
    return (
      at.getTime() > Date.now() &&
      at.getTime() < Date.now() + WITHIN_DAYS * 86_400_000 &&
      WEEKDAYS.includes(at.getDay()) &&
      (hour < 10 || hour >= 16) // before work or after it
    );
  };

  interface Slot { date: string; time: string; clinician: string; location: string }
  interface Booking { booked: boolean; confirmation: string; date: string; time: string; clinician: string }

  const slotSchema = {
    type: 'object',
    properties: {
      slots: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            date: { type: 'string', description: 'YYYY-MM-DD' },
            time: { type: 'string', description: '24-hour HH:MM' },
            clinician: { type: 'string' },
            location: { type: 'string' },
          },
          required: ['date', 'time', 'clinician', 'location'],
        },
      },
    },
    required: ['slots'],
  };

  const bookingSchema = {
    type: 'object',
    properties: {
      booked: { type: 'boolean' },
      confirmation: { type: 'string', description: "The confirmation number the site shows, or ''." },
      date: { type: 'string' },
      time: { type: 'string' },
      clinician: { type: 'string' },
    },
    required: ['booked', 'confirmation', 'date', 'time', 'clinician'],
  };

  const rules = ['Only use riversideclinic.example.', 'Never make a payment.'];

  async function push(title: string, message: string, click?: string): Promise<void> {
    await fetch(NTFY, {
      method: 'POST',
      headers: { Title: title, Priority: 'urgent', ...(click ? { Click: click } : {}) },
      body: message,
    });
  }

  let hunting = false;
  const handOffs = new Map<string, string>(); // run id to the monitor that found its slot

  async function hunt(monitorId: string): Promise<void> {
    // 1. Read every open slot. Nothing is booked in this run.
    const found = await oc.agent({
      task: `Open ${BOOKING_PAGE} and list every appointment slot that can be booked now, across the next ${WITHIN_DAYS} days.`,
      schema: slotSchema,
      profile: PROFILE,
      caps: { budget: '40.00', duration: '3m' },
      rules,
    });
    const listed = found.data as { slots: Slot[] } | undefined; // shaped by slotSchema
    const slots = (listed?.slots ?? []).sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`));

    // 2. Your code decides, not the agent.
    const slot = slots.find(acceptable);
    if (slot === undefined) return console.log(`${slots.length} open slots, none within your rules`);
    const when = `${slot.date} at ${slot.time} with ${slot.clinician}`;

    // 3a. Book mode: a second run books exactly the slot your code approved.
    if (MODE === 'book') {
      const run = await oc.agent({
        task: `On ${BOOKING_PAGE}, book the slot on ${slot.date} at ${slot.time} with ${slot.clinician} at ${slot.location}.
  Use the details already saved on the account. Book that slot and no other; if it is gone, book nothing.`,
        schema: bookingSchema,
        profile: PROFILE,
        caps: { budget: '65.00', duration: '5m' },
        rules,
      });
      const booking = run.data as Booking | undefined;
      if (booking?.booked) {
        await oc.monitors.delete(monitorId); // done hunting
        await push('Appointment booked', `${when}. Confirmation ${booking.confirmation}.`, run.replayUrl);
      } else {
        console.log(`could not book ${when}: ${run.stopReason ?? run.status}`, run.replayUrl);
      }
      return;
    }

    // 3b. Hand-off mode: the agent gets the slot in hand, and you confirm it yourself.
    const job = oc.agent({
      task: `On ${BOOKING_PAGE}, pick the slot on ${slot.date} at ${slot.time} with ${slot.clinician} at ${slot.location}
  and go through the booking form with the details saved on the account. Do not press the final confirm button.`,
      profile: PROFILE,
      caps: { budget: '150.00', duration: '10m' }, // time the run waits for you is billed; these caps bound it
      rules,
    });
    const runId = await job.id();
    handOffs.set(runId, monitorId);
    for await (const record of job) {
      const step = record.value as { action?: { description?: string } } | null;
      if (record.kind === 'step' && step?.action?.description?.includes(slot.time)) {
        await job.control('take', 'me'); // holds at the agent's next step boundary
        break;
      }
    }
    let run = await job.status();
    while (run.status === 'queued' || run.status === 'running') {
      await sleep(2000);
      run = await job.status();
    }
    if (run.pause?.viewerUrl === undefined) return console.log(`run ended before the hand-off: ${run.stopReason}`);
    await push(
      `Slot held: ${when}`,
      `Tap to open the live view, press take control, and confirm. When you are done: ${PUBLIC_URL}/done/${TOKEN}/${runId}`,
      run.pause.viewerUrl,
    );
  }

  if (process.argv[2] === 'watch') {
    const monitor = await oc.monitors.create({
      url: BOOKING_PAGE,
      label: 'slots-dermatology',
      schedule: '*/10 * * * *', // every 10 minutes
      profile: PROFILE, // see the page the way you see it when signed in
      notify: { webhook: `${PUBLIC_URL}/hooks/slots/${TOKEN}` },
    });
    console.log('watching', monitor.id);
  } else {
    const app = express();
    app.use(express.json({ limit: '2mb' }));

    app.post(`/hooks/slots/${TOKEN}`, (req, res) => {
      res.sendStatus(202); // answer at once: a monitor delivers each change once and never retries
      const { monitorId, diff } = req.body as { monitorId: string; diff?: string };
      const added = diff === undefined || /^\+(?!\+\+)/m.test(diff); // a diff that only removes lines is a slot going, not coming
      if (!added || hunting) return;
      hunting = true;
      hunt(monitorId)
        .catch((err) => console.error('hunt failed', err))
        .finally(() => (hunting = false));
    });

    // The link in the push: you have confirmed, so end the run, stop paying for it, and stop hunting.
    app.get(`/done/${TOKEN}/:runId`, async (req, res) => {
      await oc.agent.cancel(req.params.runId);
      const monitorId = handOffs.get(req.params.runId);
      if (monitorId !== undefined) await oc.monitors.delete(monitorId);
      res.send('Run ended.');
    });

    app.listen(3000, () => console.log(`listening on :3000 in ${MODE} mode`));
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import os
  import re
  from datetime import datetime, timedelta

  import httpx
  from fastapi import BackgroundTasks, FastAPI, Request
  from outcrawl import Outcrawl

  oc = Outcrawl()  # one client for the life of the server; reads OUTCRAWL_API_KEY
  app = FastAPI()

  BOOKING_PAGE = "https://booking.riversideclinic.example/dermatology"
  PROFILE = "prof_8c1d2e"  # the id of a profile already signed in to the booking site
  PUBLIC_URL = "https://yourapp.example"
  TOKEN = os.environ.get("HOOK_TOKEN", "")  # long and random: it is the only credential on these paths
  NTFY = "https://ntfy.sh/your-private-topic"  # push to your phone
  MODE = "hand-off" if os.environ.get("SLOT_MODE") == "hand-off" else "book"
  RULES = ["Only use riversideclinic.example.", "Never make a payment."]

  # Your rules, checked in code before anything is booked.
  WITHIN_DAYS = 21
  WEEKDAYS = {0, 1, 2, 3, 4}  # Monday to Friday


  def acceptable(slot: dict) -> bool:
      at = datetime.fromisoformat(f"{slot['date']}T{slot['time']}")
      return (
          datetime.now() < at < datetime.now() + timedelta(days=WITHIN_DAYS)
          and at.weekday() in WEEKDAYS
          and (at.hour < 10 or at.hour >= 16)  # before work or after it
      )


  SLOT_SCHEMA = {
      "type": "object",
      "properties": {
          "slots": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "date": {"type": "string", "description": "YYYY-MM-DD"},
                      "time": {"type": "string", "description": "24-hour HH:MM"},
                      "clinician": {"type": "string"},
                      "location": {"type": "string"},
                  },
                  "required": ["date", "time", "clinician", "location"],
              },
          }
      },
      "required": ["slots"],
  }

  BOOKING_SCHEMA = {
      "type": "object",
      "properties": {
          "booked": {"type": "boolean"},
          "confirmation": {"type": "string", "description": "The confirmation number the site shows, or ''."},
          "date": {"type": "string"},
          "time": {"type": "string"},
          "clinician": {"type": "string"},
      },
      "required": ["booked", "confirmation", "date", "time", "clinician"],
  }


  async def push(title: str, message: str, click: str | None = None) -> None:
      headers = {"Title": title, "Priority": "urgent", **({"Click": click} if click else {})}
      async with httpx.AsyncClient() as http:
          await http.post(NTFY, headers=headers, content=message)


  hunting = False
  hand_offs: dict[str, str] = {}  # run id to the monitor that found its slot


  async def hunt(monitor_id: str) -> None:
      # 1. Read every open slot. Nothing is booked in this run.
      found = await oc.agent(
          task=f"Open {BOOKING_PAGE} and list every appointment slot that can be booked now, across the next {WITHIN_DAYS} days.",
          schema=SLOT_SCHEMA,
          profile=PROFILE,
          caps={"budget": "40.00", "duration": "3m"},
          rules=RULES,
      )
      slots = sorted((found.get("data") or {}).get("slots", []), key=lambda s: f"{s['date']}T{s['time']}")

      # 2. Your code decides, not the agent.
      slot = next((s for s in slots if acceptable(s)), None)
      if slot is None:
          print(f"{len(slots)} open slots, none within your rules")
          return
      when = f"{slot['date']} at {slot['time']} with {slot['clinician']}"

      # 3a. Book mode: a second run books exactly the slot your code approved.
      if MODE == "book":
          run = await oc.agent(
              task=f"""On {BOOKING_PAGE}, book the slot on {slot['date']} at {slot['time']} with {slot['clinician']} at {slot['location']}.
  Use the details already saved on the account. Book that slot and no other; if it is gone, book nothing.""",
              schema=BOOKING_SCHEMA,
              profile=PROFILE,
              caps={"budget": "65.00", "duration": "5m"},
              rules=RULES,
          )
          booking = run.get("data") or {}
          if booking.get("booked"):
              await oc.monitors.delete(monitor_id)  # done hunting
              await push("Appointment booked", f"{when}. Confirmation {booking['confirmation']}.", run.get("replayUrl"))
          else:
              print(f"could not book {when}: {run.get('stopReason') or run['status']}", run.get("replayUrl"))
          return

      # 3b. Hand-off mode: the agent gets the slot in hand, and you confirm it yourself.
      job = oc.agent(
          task=f"""On {BOOKING_PAGE}, pick the slot on {slot['date']} at {slot['time']} with {slot['clinician']} at {slot['location']}
  and go through the booking form with the details saved on the account. Do not press the final confirm button.""",
          profile=PROFILE,
          caps={"budget": "150.00", "duration": "10m"},  # time the run waits for you is billed; these caps bound it
          rules=RULES,
      )
      run_id = await job.id()
      hand_offs[run_id] = monitor_id
      async for record in job:
          if record["kind"] != "step":
              continue
          description = ((record["value"] or {}).get("action") or {}).get("description") or ""
          if slot["time"] in description:
              await job.control("take", "me")  # holds at the agent's next step boundary
              break
      run = await job.status()
      while run["status"] in ("queued", "running"):
          await asyncio.sleep(2)
          run = await job.status()
      viewer = (run.get("pause") or {}).get("viewerUrl")
      if viewer is None:
          print(f"run ended before the hand-off: {run.get('stopReason')}")
          return
      await push(
          f"Slot held: {when}",
          f"Tap to open the live view, press take control, and confirm. When you are done: {PUBLIC_URL}/done/{TOKEN}/{run_id}",
          viewer,
      )


  async def hunt_once(monitor_id: str) -> None:
      global hunting
      try:
          await hunt(monitor_id)
      finally:
          hunting = False


  @app.post(f"/hooks/slots/{TOKEN}", status_code=202)
  async def slots_changed(request: Request, background: BackgroundTasks) -> dict:
      # Answer at once: a monitor delivers each change once and never retries.
      global hunting
      change = await request.json()
      diff = change.get("diff")
      added = diff is None or re.search(r"^\+(?!\+\+)", diff, re.M) is not None  # only removed lines: a slot going, not coming
      if added and not hunting:
          hunting = True
          background.add_task(hunt_once, change["monitorId"])
      return {"accepted": True}


  # The link in the push: you have confirmed, so end the run, stop paying for it, and stop hunting.
  @app.get(f"/done/{TOKEN}/{{run_id}}")
  async def done(run_id: str) -> str:
      await oc.agent.cancel(run_id)
      if run_id in hand_offs:
          await oc.monitors.delete(hand_offs.pop(run_id))
      return "Run ended."


  async def watch() -> None:
      monitor = await oc.monitors.create(
          url=BOOKING_PAGE,
          label="slots-dermatology",
          schedule="*/10 * * * *",  # every 10 minutes
          profile=PROFILE,  # see the page the way you see it when signed in
          notify={"webhook": f"{PUBLIC_URL}/hooks/slots/{TOKEN}"},
      )
      print("watching", monitor["id"])
      await oc.aclose()


  if __name__ == "__main__":
      asyncio.run(watch())  # python slot_hunter.py, once; then serve with: SLOT_MODE=book uvicorn slot_hunter:app --port 3000
  ```
</CodeGroup>

### The limits of each mode

* **Book mode** is two runs, so a slot can go in the seconds between them. The booking run is told to book that slot or nothing, and reports `booked: false` when it is gone; the next change starts a fresh hunt.
* **Hand-off mode** holds the run at the agent's next step boundary, not instantly. The step already under way finishes first. On a site where picking the slot and confirming are separate screens, that is one more field or a Continue button; on a site where one click books, it is the booking of a slot your rules already approved.
* If the site asks for a deposit, `Never make a payment.` blocks the agent at the card form in either mode. Use hand-off mode and pay it yourself.

## What you get back

In book mode, one push:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Appointment booked
2026-10-06 at 08:40 with Dr Osei. Confirmation RVS-7F3K2.
```

In hand-off mode, a push whose tap opens the live view:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Slot held: 2026-10-06 at 08:40 with Dr Osei
Tap to open the live view, press take control, and confirm. When you are done: https://yourapp.example/done/<token>/run_01JB2M6Q
```

The first run's answer, which your code filters:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "slots": [
    { "date": "2026-10-04", "time": "11:20", "clinician": "Dr Sun", "location": "Main building" },
    { "date": "2026-10-06", "time": "08:40", "clinician": "Dr Osei", "location": "Riverside" },
    { "date": "2026-10-09", "time": "13:00", "clinician": "Dr Osei", "location": "Riverside" }
  ]
}
```

The first slot falls on a Sunday and the third at lunchtime, so the program takes the 08:40 on Tuesday.

## What it costs

* **Watching** is 1 credit per check, whether or not the page changed, per [pricing](/docs/billing/pricing). A check every 10 minutes is 144 checks a day, so **144 credits a day**.
* **Listing slots** is one agent run, billed for the model's usage, browser time and proxy usage. A typical run costs about 15 credits a minute all in, and the real figure depends on the task and how heavy the pages are. About 40 s is **about 10 credits** each time new slots appear.
* **Booking** is about a minute: **about 15 credits**, once.
* **Hand-off** bills the agent's part the same way. While you hold the wheel, whether after `control('take')` or by driving in the live viewer, the run bills browser time (1 credit a minute) and proxy usage only, with no model usage, until you hand it back. Here the done link cancels the run, which stops the clock: five minutes from the push to your tap is about 5 credits, plus proxy usage for the pages you load. `caps.duration` ends the run after 10 minutes if you never come back.

A page where new slots appear five times a day costs about 144 + 5 × 10 = **194 credits a day** until the booking lands.

## Make it yours

* Watch several clinics at once: one monitor per booking page, all posting to the same endpoint, and delete every monitor once one books.
* Change `acceptable()` to match your week: a particular clinician, a location near you, or a date before you travel.
* Send the hand-off link to Slack or SMS instead of ntfy; it is one HTTP call.
* Check every 5 minutes instead of 10 on a page where slots go fast. That is 288 checks a day, so watching costs 288 credits a day instead of 144.

## Related

<CardGroup cols={2}>
  <Card title="Monitors" icon="eye" href="/docs/monitors/overview">Schedules, notify targets and profiles.</Card>
  <Card title="Human in the loop" icon="hand-click" href="/docs/agents/human-in-the-loop">Take and release control of a live run.</Card>
  <Card title="Profiles" icon="id" href="/docs/account/profiles">Stay signed in to the booking site.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Stop the agent paying for anything.</Card>
</CardGroup>


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