> ## 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 a table the moment one opens

> A monitor watches a fully booked restaurant, and when a table appears your server sends an agent to book it for your party, inside your limits.

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 restaurant you want is booked solid, and the tables that come back from cancellations go within minutes to whoever happens to be refreshing. This program watches the Resy page every minute. When it changes, your server sends an agent, signed in as you, to book the earliest time in your window for your party, with your card for the hold if Resy asks for one. You get a Slack message with the confirmation, and the watching stops.

Book tables you will sit at. The program books one table and deletes its monitor; it is not a way to hold tables for resale.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* A [profile](/docs/account/profiles) signed in to your Resy account, labelled `resy-personal`.
* Your card stored as a [secret](/docs/account/secrets) named `card_dining` with `kind: 'card'`. The agent types it by name when Resy asks for a card; the model never sees the number.
* A server reachable from the internet, with a long random string in `HOOK_TOKEN`. The monitor and the run both post to it.
* Optional: a Slack incoming webhook URL in `SLACK_WEBHOOK_URL`. Python also needs `fastapi` and `uvicorn`.

## How it works

<Steps>
  <Step title="A monitor watches the page">
    [`monitors.create`](/docs/monitors/overview) checks the Resy page for your date and party size every minute. Each check is 1 credit, whether or not the page changed; a real change posts to your endpoint. A monitor cannot start an agent itself, so your endpoint does.
  </Step>

  <Step title="Your endpoint sends an agent to book">
    It submits one [agent run](/docs/agents/overview) on your profile, with your card secret, your window and party size in the task, and a [schema](/docs/agents/overview#typed-results) for the booking. The [rule](/docs/account/rules) `Only use resy.com.` is enforced. One run at a time: a change that arrives while a run is booking is ignored.
  </Step>

  <Step title="The run reports back">
    The run's [webhook](/docs/agents/monitoring#webhooks) posts to your second endpoint when it ends, and retries if your server is down. The endpoint reads the run by id rather than trusting the body, and checks the booked time against your window.
  </Step>

  <Step title="You get the message, and the watching stops">
    A booking goes to Slack and `monitors.delete` removes the monitor. A change with nothing that fits is logged, and the monitor keeps watching.
  </Step>
</Steps>

<Note>
  Your window, party size and deposit ceiling are instructions in the task. What is enforced is the site (`Only use resy.com.`), the card (the run can type only the one secret it is given), and your code's check of the booked time. A spend-limit rule is left out on purpose: it has to read a total on the page before a booking button and blocks the booking when it cannot, and most reservations that need no deposit show no total.
</Note>

## The program

Run it as a server. It creates the monitor when it starts.

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

  const oc = new Outcrawl();
  const PUBLIC_URL = 'https://tables.yourapp.example'; // where Outcrawl can reach this server
  const HOOKS = `/hooks/${process.env.HOOK_TOKEN ?? ''}`; // a long random token: the only credential on these paths
  const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL ?? ''; // a Slack incoming webhook of yours
  if ((process.env.HOOK_TOKEN ?? '').length < 24) throw new Error('Set HOOK_TOKEN to a long random string.');

  const want = {
    restaurant: 'Lilia',
    page: 'https://resy.com/cities/new-york-ny/venues/lilia?date=2026-10-17&seats=4',
    date: '2026-10-17',
    partySize: 4,
    earliest: '19:00',
    latest: '21:00',
    maxDepositPerPerson: 25, // USD
  };

  interface Booking {
    booked: boolean;
    time?: string;
    partySize?: number;
    confirmation?: string;
    depositPerPerson?: string;
    cancellationPolicy?: string;
    reason?: string;
  }

  const bookingSchema = {
    type: 'object',
    properties: {
      booked: { type: 'boolean' },
      time: { type: 'string', description: 'Reservation time, 24-hour HH:MM' },
      partySize: { type: 'integer' },
      confirmation: { type: 'string', description: 'Confirmation number or message' },
      depositPerPerson: { type: 'string', description: 'Deposit or charge per person, with currency, or "none"' },
      cancellationPolicy: { type: 'string' },
      reason: { type: 'string', description: 'Why nothing was booked, when nothing was' },
    },
    required: ['booked'],
  };

  const [profile] = await oc.profiles.list({ label: 'resy-personal' });
  if (profile === undefined) throw new Error('Create the resy-personal profile and sign in to Resy with it first.');

  // Checked every minute. Each check is 1 credit, changed or not.
  const monitor = await oc.monitors.create({
    url: want.page,
    schedule: '* * * * *',
    label: 'table-lilia',
    judge: true,
    notify: { webhook: `${PUBLIC_URL}${HOOKS}/table-changed` },
  });

  let inFlight: AgentJob | undefined; // the run in flight, so two changes never book twice
  let done = false;

  async function tell(text: string): Promise<void> {
    console.log(text);
    if (SLACK_WEBHOOK_URL !== '') {
      await fetch(SLACK_WEBHOOK_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ text }),
      });
    }
  }

  const app = express();
  app.use(express.json());

  // 1. The monitor saw the page change. Send an agent to book.
  app.post(`${HOOKS}/table-changed`, async (req, res) => {
    const change = req.body as { monitorId: string; status: string; summary?: string };
    if (done || inFlight !== undefined || change.status !== 'changed') {
      res.sendStatus(204);
      return;
    }
    inFlight = oc.agent({
      task:
        `On Resy, book a table at ${want.restaurant} for ${want.partySize} people on ${want.date}, at the earliest ` +
        `available time from ${want.earliest} to ${want.latest}. Start at ${want.page}; you are signed in. If Resy ` +
        'asks for a card to hold the table, use {{secret:card_dining}}. Do not book if no time in that window is ' +
        `free, or if the reservation needs a deposit or charge now above $${want.maxDepositPerPerson} per person: ` +
        'report why instead.',
      caps: { budget: '75.00', duration: '6m' },
      profile: profile.id,
      secrets: ['card_dining'],
      rules: ['Only use resy.com.'],
      schema: bookingSchema,
      webhook: `${PUBLIC_URL}${HOOKS}/booking-finished`,
    });
    try {
      res.status(202).json({ run: await inFlight.id() });
    } catch (error) {
      inFlight = undefined; // refused, for example every browser slot busy; the next change tries again
      throw error;
    }
  });

  // 2. The run finished. Check what it booked and tell you.
  app.post(`${HOOKS}/booking-finished`, async (req, res) => {
    const { runId } = req.body as { runId: string };
    res.sendStatus(200);
    if (inFlight === undefined || runId !== (await inFlight.id())) return; // a repeat delivery
    inFlight = undefined;

    const run = await oc.agent.get(runId); // read the run itself, not the webhook body
    const booking = run.data as Booking | undefined;
    if (booking?.booked !== true) {
      console.log(`No table yet: ${booking?.reason ?? run.stopReason ?? run.status}`);
      return; // keep watching
    }
    done = true;
    await oc.monitors.delete(monitor.id);
    const inWindow = booking.time !== undefined && booking.time >= want.earliest && booking.time <= want.latest;
    await tell(
      `Booked ${want.restaurant}, ${want.date} at ${booking.time ?? '?'} for ${booking.partySize ?? want.partySize}. ` +
        `Confirmation ${booking.confirmation ?? 'on Resy'}. Deposit: ${booking.depositPerPerson ?? 'none'}. ` +
        `${booking.cancellationPolicy ?? ''}${inWindow ? '' : ' This is OUTSIDE your window: check it on Resy.'}`,
    );
  });

  app.listen(3000, () => console.log(`Watching ${want.page} as monitor ${monitor.id}`));
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import os
  from contextlib import asynccontextmanager

  import httpx
  from fastapi import FastAPI, Request, Response
  from fastapi.responses import JSONResponse

  from outcrawl import Outcrawl

  PUBLIC_URL = "https://tables.yourapp.example"  # where Outcrawl can reach this server
  HOOK_TOKEN = os.environ.get("HOOK_TOKEN", "")  # a long random token: the only credential on these paths
  if len(HOOK_TOKEN) < 24:
      raise SystemExit("Set HOOK_TOKEN to a long random string.")
  HOOKS = f"/hooks/{HOOK_TOKEN}"
  SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")  # a Slack incoming webhook of yours

  WANT = {
      "restaurant": "Lilia",
      "page": "https://resy.com/cities/new-york-ny/venues/lilia?date=2026-10-17&seats=4",
      "date": "2026-10-17",
      "party_size": 4,
      "earliest": "19:00",
      "latest": "21:00",
      "max_deposit_per_person": 25,  # USD
  }

  BOOKING_SCHEMA = {
      "type": "object",
      "properties": {
          "booked": {"type": "boolean"},
          "time": {"type": "string", "description": "Reservation time, 24-hour HH:MM"},
          "partySize": {"type": "integer"},
          "confirmation": {"type": "string", "description": "Confirmation number or message"},
          "depositPerPerson": {"type": "string", "description": 'Deposit or charge per person, with currency, or "none"'},
          "cancellationPolicy": {"type": "string"},
          "reason": {"type": "string", "description": "Why nothing was booked, when nothing was"},
      },
      "required": ["booked"],
  }

  oc = Outcrawl()  # one client for the life of the server
  state = {"profile": None, "monitor": None, "in_flight": None, "done": False}


  @asynccontextmanager
  async def lifespan(app: FastAPI):
      profiles = await oc.profiles.list(label="resy-personal")
      if not profiles:
          raise SystemExit("Create the resy-personal profile and sign in to Resy with it first.")
      state["profile"] = profiles[0]["id"]
      # Checked every minute. Each check is 1 credit, changed or not.
      monitor = await oc.monitors.create(
          url=WANT["page"],
          schedule="* * * * *",
          label="table-lilia",
          judge=True,
          notify={"webhook": f"{PUBLIC_URL}{HOOKS}/table-changed"},
      )
      state["monitor"] = monitor["id"]
      print(f"Watching {WANT['page']} as monitor {monitor['id']}")
      yield
      await oc.aclose()


  app = FastAPI(lifespan=lifespan)


  async def tell(text: str) -> None:
      print(text)
      if SLACK_WEBHOOK_URL:
          async with httpx.AsyncClient() as http:
              await http.post(SLACK_WEBHOOK_URL, json={"text": text})


  # 1. The monitor saw the page change. Send an agent to book.
  @app.post(f"{HOOKS}/table-changed")
  async def table_changed(request: Request) -> Response:
      change = await request.json()
      if state["done"] or state["in_flight"] or change.get("status") != "changed":
          return Response(status_code=204)
      state["in_flight"] = "submitting"  # so two changes never book twice
      try:
          job = oc.agent(
              task=(
                  f"On Resy, book a table at {WANT['restaurant']} for {WANT['party_size']} people on {WANT['date']}, "
                  f"at the earliest available time from {WANT['earliest']} to {WANT['latest']}. Start at "
                  f"{WANT['page']}; you are signed in. If Resy asks for a card to hold the table, use "
                  "{{secret:card_dining}}. Do not book if no time in that window is free, or if the reservation needs "
                  f"a deposit or charge now above ${WANT['max_deposit_per_person']} per person: report why instead."
              ),
              caps={"budget": "75.00", "duration": "6m"},
              profile=state["profile"],
              secrets=["card_dining"],
              rules=["Only use resy.com."],
              schema=BOOKING_SCHEMA,
              webhook=f"{PUBLIC_URL}{HOOKS}/booking-finished",
          )
          state["in_flight"] = await job.id()
      except Exception:
          state["in_flight"] = None  # refused, for example every browser slot busy; the next change tries again
          raise
      return JSONResponse({"run": state["in_flight"]}, status_code=202)


  # 2. The run finished. Check what it booked and tell you.
  @app.post(f"{HOOKS}/booking-finished")
  async def booking_finished(request: Request) -> Response:
      run_id = (await request.json())["runId"]
      if run_id != state["in_flight"]:
          return Response(status_code=200)  # a repeat delivery of a run already handled
      state["in_flight"] = None

      run = await oc.agent.get(run_id)  # read the run itself, not the webhook body
      booking = run.get("data") or {}
      if not booking.get("booked"):
          print(f"No table yet: {booking.get('reason') or run.get('stopReason') or run['status']}")
          return Response(status_code=200)  # keep watching

      state["done"] = True
      await oc.monitors.delete(state["monitor"])
      time = booking.get("time", "?")
      in_window = WANT["earliest"] <= time <= WANT["latest"]
      await tell(
          f"Booked {WANT['restaurant']}, {WANT['date']} at {time} for {booking.get('partySize', WANT['party_size'])}. "
          f"Confirmation {booking.get('confirmation', 'on Resy')}. Deposit: {booking.get('depositPerPerson', 'none')}. "
          f"{booking.get('cancellationPolicy', '')}{'' if in_window else ' This is OUTSIDE your window: check it on Resy.'}"
      )
      return Response(status_code=200)


  if __name__ == "__main__":
      import uvicorn

      uvicorn.run(app, port=3000)
  ```
</CodeGroup>

## What you get back

In Slack:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Booked Lilia, 2026-10-17 at 19:45 for 4. Confirmation RSY-88213. Deposit: none. $25 per person if cancelled within 24 hours of the reservation.
```

The run's answer, as your endpoint reads it with `oc.agent.get`:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "booked": true,
  "time": "19:45",
  "partySize": 4,
  "confirmation": "RSY-88213",
  "depositPerPerson": "none",
  "cancellationPolicy": "$25 per person if cancelled within 24 hours of the reservation."
}
```

When the page changed but nothing fit, the answer is `{ "booked": false, "reason": "The only new time is 22:15, outside 19:00 to 21:00." }` and the monitor keeps going.

## What it costs

* **Watching** is 1 credit per check, whether or not the page changed, plus the model's usage when a changed page is judged (`judge: true`), per [pricing](/docs/billing/pricing). A check every minute is 1,440 checks a day, so **1,440 credits a day**.
* **A change with nothing that fits** starts a short run. 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; the real figure depends on the task and how heavy the pages are. About 30 s is **about 8 credits**.
* **The booking** is about 2 minutes: **about 30 credits**, once.

A popular page also changes when other people take times. A week of watching with 30 changes before a table fits is 10,080 × 1 + 29 × 8 + 30 = **about 10,340 credits**, plus model usage for the 30 judged changes. Each run's `caps.budget` is 75. Checking every 5 minutes instead cuts the watching to 2,016 credits a week.

## Make it yours

* Check the deposit in code before anything is booked: split the run into one that lists open times with their deposits and one that books the time your code picks, as [Book the first appointment slot that fits your week](/docs/examples/appointment-slot-hunter) does. It costs about a minute more, and tables go fast.
* Skip changes that only remove times: `judge: true` puts a model's `summary` of the change in the monitor's post, and your endpoint can start a run only when it mentions a new time.
* Watch several dates: one monitor per date's page, all posting to the same endpoint, and delete them all once one books.
* Text yourself instead of Slack: `tell` is one HTTP call.

## Related

<CardGroup cols={2}>
  <Card title="Monitors" icon="eye" href="/docs/monitors/overview">Schedules and notify targets.</Card>
  <Card title="Monitoring a run" icon="activity" href="/docs/agents/monitoring">Run webhooks and their retries.</Card>
  <Card title="Secrets" icon="key" href="/docs/account/secrets">A card the agent types but never reads.</Card>
  <Card title="Profiles" icon="id" href="/docs/account/profiles">Stay signed in to Resy.</Card>
</CardGroup>


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