# Book an appointment without double-booking

## Requirement

"The agent must book appointments and must never double-book." The client's
bookings live in their own system, behind an API key.

## Pieces

- **Custom data**: a collection `opening_hours`, one record per weekday.
- **Connection**: the booking system's key, provider `http_key`.
- **One tool per action**: an HTTP tool that fetches a day's bookings and an
  HTTP tool that creates one, both pointed at the connection.
- **Custom tool**: `free_slots`, a pure calculation over the hours and the
  bookings.
- **Flow**: query hours, fetch bookings, compute free slots, then an edge on
  the result to the booking step.
- **The flow as a tool**, attached to the node where booking happens, on a
  published agent.

This is the shape for a diary that lives in the client's own system. When the
diary is the client's Google Calendar instead, the no-double-booking rule is a
parameter of the calendar write — `refuse_overlap` — and not a flow at all: the
last variation below says how, and
[Tools and their parameters](/tools-and-parameters) says what those built-ins
accept and how a fixed value is changed without losing the others.

## The shape to avoid

One tool that authenticates, filters out the hours the business is closed,
checks availability and books. Nothing in it can be changed, tested or reused
apart from the rest; the hours are wrong the first holiday; and the key sits
in a tool's configuration, which the API refuses.

## Steps

Ids from an earlier step are written as `{{step_N.field}}`; the test that runs
this page substitutes them. Every body below was sent, in this order, against
the API before this page was published.

1. Hold the booking system's key once. `grant_secret` is a JSON string
   naming the header the key travels in; it is never returned.

   `create_connection`

   ```json
   {
     "provider": "http_key",
     "account_label": "Booking system",
     "scopes": [],
     "grant_secret": "{\"header\":\"X-API-Key\",\"prefix\":\"\",\"value\":\"bk_live_replace_me\"}",
     "granted_via": "pasted"
   }
   ```

2. The opening hours are records, not code. The collection's `query_`, `get_`
   and `write_` tools exist the moment it does; their ids come back as
   `query_tool_id`, `get_tool_id` and `write_tool_id`.

   `create_collection`

   ```json
   {
     "name": "Opening hours",
     "slug": "opening_hours",
     "description": "When the business takes appointments, one record per weekday.",
     "fields": [
       {"key": "weekday", "label": "Weekday", "type": "option",
        "options": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
        "required": true},
       {"key": "opens", "label": "Opens", "type": "text", "required": true},
       {"key": "closes", "label": "Closes", "type": "text", "required": true}
     ]
   }
   ```

3. One record per day the business is open.

   `create_record` (`collection_id` = `{{step_2.id}}`)

   ```json
   {"values": {"weekday": "monday", "opens": "09:00", "closes": "18:00"}}
   ```

4. And the next.

   `create_record` (`collection_id` = `{{step_2.id}}`)

   ```json
   {"values": {"weekday": "tuesday", "opens": "09:00", "closes": "18:00"}}
   ```

5. One tool that reads the day's bookings. A `GET` sends its parameters as
   the query string. It carries no header: the connection supplies the key.

   `create_tool`

   ```json
   {
     "name": "fetch_bookings",
     "description": "The bookings already taken on one date.",
     "source": "builtin",
     "config": {"endpoint": "https://bookings.example.com/api/bookings", "method": "GET"},
     "input_schema": {
       "type": "object",
       "properties": {"date": {"type": "string", "description": "YYYY-MM-DD"}},
       "required": ["date"]
     },
     "connection_id": "{{step_1.id}}"
   }
   ```

6. One tool that creates a booking. A `POST` sends its parameters as a JSON
   body.

   `create_tool`

   ```json
   {
     "name": "create_booking",
     "description": "Create one booking in the booking system.",
     "source": "builtin",
     "config": {"endpoint": "https://bookings.example.com/api/bookings", "method": "POST"},
     "input_schema": {
       "type": "object",
       "properties": {
         "date": {"type": "string"},
         "time": {"type": "string", "description": "HH:MM"},
         "name": {"type": "string"}
       },
       "required": ["date", "time", "name"]
     },
     "connection_id": "{{step_1.id}}"
   }
   ```

7. The one calculation no piece provides: which hours are free. It is handed
   the hours and the bookings and answers a value; it reaches nothing, so
   `declared_reach` is empty. The response carries `tool_id`, the id nodes and
   flows attach; it answers "not published" until step 8.

   `create_custom_tool`

   ```json
   {
     "name": "free_slots",
     "description": "The free one-hour slots on a day, given the opening hours and the bookings taken.",
     "purpose": "Compute free appointment slots from opening hours and existing bookings, and say whether the requested time is one of them.",
     "source_code": "def transform(arguments):\n    hours = arguments.get(\"hours\") or []\n    bookings = arguments.get(\"bookings\") or []\n    taken = [b.get(\"time\") for b in bookings]\n    free = []\n    for h in hours:\n        start = int(h[\"opens\"][:2])\n        end = int(h[\"closes\"][:2])\n        for hour in range(start, end):\n            slot = (\"0\" + str(hour))[-2:] + \":00\"\n            if slot not in taken:\n                free.append(slot)\n    return {\"free\": free, \"requested_is_free\": arguments.get(\"time\") in free}\n",
     "input_schema": {
       "type": "object",
       "properties": {
         "hours": {"type": "array", "description": "Records of opening_hours: opens and closes as HH:MM."},
         "bookings": {"type": "array", "description": "Bookings taken, each with a time HH:MM."},
         "time": {"type": "string", "description": "The requested time, HH:MM."}
       },
       "required": ["hours", "bookings", "time"]
     },
     "output_schema": {
       "type": "object",
       "properties": {
         "free": {"type": "array", "items": {"type": "string"}},
         "requested_is_free": {"type": "boolean"}
       }
     },
     "declared_reach": []
   }
   ```

8. Publish it. Refused if the code does not parse or defines no `transform`.

   `publish_custom_tool` (`custom_tool_id` = `{{step_7.id}}`)

   ```json
   {}
   ```

9. The flow that composes the four.

   `create_flow`

   ```json
   {"name": "book_appointment"}
   ```

10. First step: read the hours with the collection's own query tool. The first
    step added is the entry step. The flow's own inputs (`date`, `time`,
    `name`) are in scope as `$ref:trigger.<key>`.

    `add_step` (`flow_id` = `{{step_9.id}}`)

    ```json
    {"tool_id": "{{step_2.query_tool_id}}", "param_overrides": {"limit": 7}}
    ```

11. Second step: fetch the day's bookings for the requested date.

    `add_step` (`flow_id` = `{{step_9.id}}`)

    ```json
    {"tool_id": "{{step_5.id}}", "param_overrides": {"date": "$ref:trigger.date"}}
    ```

12. Third step: the calculation. A query step answers `{"records": [...]}`,
    so its records are `$ref:<step>.records`. The booking system is assumed
    to answer `{"bookings": [...]}`; a JSON object is read as it is, and any
    other body is wrapped as `{"body": ...}`.

    `add_step` (`flow_id` = `{{step_9.id}}`)

    ```json
    {
      "tool_id": "{{step_7.tool_id}}",
      "param_overrides": {
        "hours": "$ref:{{step_10.id}}.records",
        "bookings": "$ref:{{step_11.id}}.bookings",
        "time": "$ref:trigger.time"
      }
    }
    ```

13. Fourth step: the booking, only ever reached through the conditional edge.

    `add_step` (`flow_id` = `{{step_9.id}}`)

    ```json
    {
      "tool_id": "{{step_6.id}}",
      "param_overrides": {
        "date": "$ref:trigger.date",
        "time": "$ref:trigger.time",
        "name": "$ref:trigger.name"
      }
    }
    ```

14. Hours, then bookings: unconditional.

    `add_flow_edge` (`flow_id` = `{{step_9.id}}`)

    ```json
    {"source_step_id": "{{step_10.id}}", "target_step_id": "{{step_11.id}}"}
    ```

15. Bookings, then the calculation: unconditional.

    `add_flow_edge` (`flow_id` = `{{step_9.id}}`)

    ```json
    {"source_step_id": "{{step_11.id}}", "target_step_id": "{{step_12.id}}"}
    ```

16. The decision. A custom tool's answer is under `result`, so the field is
    `<step>.result.requested_is_free`. When it is not `true` the flow ends at
    step 12 and its output, the free slots, is what the agent gets.

    `add_flow_edge` (`flow_id` = `{{step_9.id}}`)

    ```json
    {
      "source_step_id": "{{step_12.id}}",
      "target_step_id": "{{step_13.id}}",
      "condition": {"field": "{{step_12.id}}.result.requested_is_free", "op": "===", "value": true}
    }
    ```

17. Register the flow as one tool. `config.flow_id` is what makes a tool a
    flow; the `input_schema` is what the model sees.

    `create_tool`

    ```json
    {
      "name": "book_appointment",
      "description": "Book an appointment at a date and time. Answers the free slots when the requested time is taken.",
      "source": "builtin",
      "config": {"flow_id": "{{step_9.id}}"},
      "input_schema": {
        "type": "object",
        "properties": {
          "date": {"type": "string", "description": "YYYY-MM-DD"},
          "time": {"type": "string", "description": "HH:MM"},
          "name": {"type": "string", "description": "Who the appointment is for."}
        },
        "required": ["date", "time", "name"]
      }
    }
    ```

18. An agent publishes only with an AI profile. One is made from an LLM
    setting naming a model the platform offers (`get_ai_profile_capabilities`
    lists them).

    `create_llm_settings`

    ```json
    {"name": "fast", "provider": "openai", "model": "gpt-4o-mini"}
    ```

19. The profile.

    `create_profile`

    ```json
    {"name": "default", "text": {"llm_settings_id": "{{step_18.id}}"}}
    ```

20. The agent. It is created with an empty graph and a draft version.

    `create_agent`

    ```json
    {"name": "Receptionist"}
    ```

21. The draft names the graph (`graph_id`) and is the version to edit (`id`).

    `get_draft` (`agent_id` = `{{step_20.id}}`)

    ```json
    {}
    ```

22. The node where booking happens, with the one tool attached. The
    instructions say what to do with the answer; the procedure is in the flow.

    `add_node` (`graph_id` = `{{step_21.graph_id}}`)

    ```json
    {
      "instructions": "You book appointments. Ask for the date, the time and the caller's name, then call book_appointment. If it answers that the requested time is not free, offer the free slots it lists and book the one the caller picks.",
      "tool_ids": ["{{step_17.id}}"]
    }
    ```

23. A conversation starts at the root node.

    `set_root` (`graph_id` = `{{step_21.graph_id}}`, `node_id` = `{{step_22.id}}`)

    ```json
    {}
    ```

24. The draft names the profile.

    `edit_draft` (`version_id` = `{{step_21.id}}`)

    ```json
    {"ai_profile_id": "{{step_19.id}}"}
    ```

25. Nothing takes traffic until this. Refused, with reasons, if the graph has
    no root node or the draft names no profile.

    `publish_version` (`version_id` = `{{step_21.id}}`)

    ```json
    {}
    ```

## What the agent sees

One tool, `book_appointment`, with `date`, `time` and `name`. The four steps,
the connection and the hours are invisible from the conversation; when the
time is taken the tool answers with the free slots and the node instructions
say what to do with them.

## Variations

- **Bookings kept on the platform instead of the client's system**: replace
  the two HTTP tools with the collection's own tools, `query_bookings` and
  `write_bookings`, on a `bookings` collection; the flow is otherwise the same.
- **Confirm before booking**: `requires_confirmation: true` on the
  `book_appointment` tool puts the call to the person before it runs.
- **A second agent that books**: attach the same tool id to its node. The
  flow, the tools and the connection are the organization's, not the agent's.
- **The diary is the client's Google Calendar**: drop both HTTP tools and the
  custom tool. `calendar_read_busy` answers the busy ranges and
  `calendar_create_event` writes the visit, each an instance of a built-in
  pointed at a `google` connection, with `calendar_id` and `timezone` fixed on
  it. The overlap check comes with the write: fix `refuse_overlap: true` and a
  slot that collides is refused, with the ranges it collides with named, so the
  agent can offer another time without a flow computing anything. `location` on
  the same tool is where the visit's address — or coordinates as `lat,lng` —
  goes, kept exactly as given and read back the same way. The opening-hours
  collection still earns its place: the diary says what is taken, not when the
  business is open.

  ```json
  {
    "name": "book_visit",
    "description": "Put a visit in the diary.",
    "source": "builtin",
    "config": {"builtin": "calendar_create_event"},
    "fixed_parameters": {
      "calendar_id": "primary",
      "timezone": "Europe/Madrid",
      "refuse_overlap": true
    },
    "connection_id": "8f1c0e6a-5c2e-4f1e-9c2a-0b7d5a3e1f44"
  }
  ```

  A later `update_tool` that names `fixed_parameters` replaces that whole
  object, so adding a fourth fixed value means sending these three back with
  it — the trap [Tools and their parameters](/tools-and-parameters) opens with.
