atteq API
  • Guides
  • Recipes
  • Cookbook
  • API Reference
Book an appointment without double-bookingSync a lead to the client's systemPush every call to a webhookGreet callers by nameQuote from a price list
powered by Zudoku
Recipes

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

    Code
    { "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

    Code
    { "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}})

    Code
    {"values": {"weekday": "monday", "opens": "09:00", "closes": "18:00"}}
  4. And the next.

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

    Code
    {"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

    Code
    { "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

    Code
    { "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

    Code
    { "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}})

    Code
    {}
  9. The flow that composes the four.

    create_flow

    Code
    {"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}})

    Code
    {"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}})

    Code
    {"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}})

    Code
    { "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}})

    Code
    { "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}})

    Code
    {"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}})

    Code
    {"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}})

    Code
    { "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

    Code
    { "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

    Code
    {"name": "fast", "provider": "openai", "model": "gpt-4o-mini"}
  19. The profile.

    create_profile

    Code
    {"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

    Code
    {"name": "Receptionist"}
  21. The draft names the graph (graph_id) and is the version to edit (id).

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

    Code
    {}
  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}})

    Code
    { "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}})

    Code
    {}
  24. The draft names the profile.

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

    Code
    {"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}})

    Code
    {}

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.

    Code
    { "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 opens with.

Last modified on November 4, 2026
Sync a lead to the client's system
On this page
  • Requirement
  • Pieces
  • The shape to avoid
  • Steps
  • What the agent sees
  • Variations
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON