Example: appointments

Install DotSIP, open the web admin, connect real SIP, and decide each call from your own HTTP API.

This desk answers with a live agent. The caller asks for an appointment. The agent calls two tools on your server, list_slots and book_slot, and reads the result back. DotSIP does not store the diary. Your server does.

You can use this plan for every call, or add it as key 3 on the switchboard.

1. What you create in the admin

  1. Under Models, save the API key on the live model and select that model on the agent. The voice page, if you need it, is /agent on the admin host. It is not in the left menu.
  2. Open Rights and keep right-1, or create your own id and use that same id below.
  3. Open Tools. Add list_slots and book_slot. Both use the endpoint http://127.0.0.1:9099/tool, a description, a parameters object, and the rights id right-1.
  4. Open Agents. Create agent-desk. Set the tool ids to list_slots, book_slot. Router tool-router-builtin. Write the prompt the agent should follow.
  5. Under Routes, route-tools URL http://127.0.0.1:9099/route.
  6. Under SIP accounts, the trunk that receives the call. Set its inbound route to route-tools, or leave that field empty so the call uses route-tools.

Prompt you can paste on the agent:

You book appointments for Example Clinic.
Speak the caller's language.
Call list_slots before you offer a time.
Call book_slot only after the caller agrees to one offered start time.
Repeat the date, time, and reference number, then stop.
If a tool returns an error, say that the diary could not be reached and offer to transfer them.

2. Tool fields

list_slots parameters:

{
  "type": "object",
  "properties": {
    "day": { "type": "string", "description": "Calendar day, YYYY-MM-DD" }
  },
  "required": ["day"]
}

book_slot parameters:

{
  "type": "object",
  "properties": {
    "day": { "type": "string", "description": "Calendar day, YYYY-MM-DD" },
    "time": { "type": "string", "description": "Start time, HH:MM" },
    "name": { "type": "string", "description": "Caller name" }
  },
  "required": ["day", "time", "name"]
}

Both tools use http://127.0.0.1:9099/tool. The server reads tool and looks up operator.sqlite. Description is one sentence the model can read: “List free appointment times for a day.” and “Book one appointment.”

3. The route plan

Return this when the call should go straight to the agent. rights must be an id that is allowed on both tools, or the agent is given no tools.

{
  "format": "dotsip.route-result",
  "format_version": 2,
  "commands": [
    { "do": "answer" },
    { "do": "play", "media": "one-moment.wav" },
    {
      "do": "agent",
      "agent": "agent-desk",
      "rights": "right-1",
      "prompt": "The caller wants an appointment. Start by asking which day."
    }
  ]
}

one-moment.wav is optional. Put it in data/media if you use it. On the switchboard, key 3 can be only the agent command:

"3": [
  { "do": "agent", "agent": "agent-desk", "rights": "right-1" }
]

4. What DotSIP posts to a tool

{
  "format": "dotsip.tool-call",
  "format_version": 1,
  "call_id": "call-id-from-dotsip",
  "tool": "list_slots",
  "arguments": { "day": "2026-09-28" },
  "caller": { "number": "+421900000000", "name": null, "rights": "right-1" }
}

Reply with HTTP 200. ok must be the JSON boolean true. The model hears result. A missing ok is an error.

{
  "ok": true,
  "result": {
    "day": "2026-09-28",
    "slots": ["09:00", "09:20", "09:40"]
  }
}
{
  "ok": true,
  "result": {
    "reference": "A-1042",
    "day": "2026-09-28",
    "time": "09:20",
    "name": "Eva Kovac"
  }
}

5. Your database and the server

The diary is operator.sqlite next to the script. It is not data/dotsip.sqlite. A free time has an empty name. Booking writes the caller’s name into that row. The same process answers /route with the agent plan and /tool with the diary result.

CREATE TABLE slots (
  day TEXT NOT NULL,
  time TEXT NOT NULL,
  name TEXT,
  PRIMARY KEY(day, time)
);
INSERT INTO slots(day, time, name) VALUES
  ('2026-09-28', '09:00', NULL),
  ('2026-09-28', '09:20', NULL),
  ('2026-09-28', '09:40', NULL),
  ('2026-09-29', '10:00', NULL),
  ('2026-09-29', '10:20', NULL);
<?php
$db = new PDO("sqlite:" . __DIR__ . "/operator.sqlite");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE IF NOT EXISTS slots (day TEXT NOT NULL, time TEXT NOT NULL, name TEXT, PRIMARY KEY(day, time))");
$db->exec("INSERT OR IGNORE INTO slots(day, time, name) VALUES
  ('2026-09-28', '09:00', NULL), ('2026-09-28', '09:20', NULL), ('2026-09-28', '09:40', NULL),
  ('2026-09-29', '10:00', NULL), ('2026-09-29', '10:20', NULL)");
$body = json_decode(file_get_contents("php://input"), true);
header("Content-Type: application/json");
$format = is_array($body) && isset($body["format"]) ? $body["format"] : "";
if ($format === "dotsip.route") {
    echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => array(
        array("do" => "answer"),
        array("do" => "agent", "agent" => "agent-desk", "rights" => "right-1", "prompt" => "The caller wants an appointment. Start by asking which day."),
    )));
    return;
}
if ($format !== "dotsip.tool-call") {
    echo json_encode(array("ok" => false, "error" => "bad request"));
    return;
}
$args = isset($body["arguments"]) && is_array($body["arguments"]) ? $body["arguments"] : array();
$tool = isset($body["tool"]) ? $body["tool"] : "";
$day = isset($args["day"]) ? $args["day"] : "";
if ($tool === "list_slots") {
    $rows = $db->query("SELECT time FROM slots WHERE day = " . $db->quote($day) . " AND name IS NULL ORDER BY time")->fetchAll(PDO::FETCH_COLUMN);
    echo json_encode(array("ok" => true, "result" => array("day" => $day, "slots" => array_values($rows))));
    return;
}
if ($tool === "book_slot") {
    $time = isset($args["time"]) ? $args["time"] : "";
    $name = isset($args["name"]) ? $args["name"] : "";
    if ($name === "") {
        echo json_encode(array("ok" => false, "error" => "that time is not free"));
        return;
    }
    $stmt = $db->prepare("UPDATE slots SET name = ? WHERE day = ? AND time = ? AND name IS NULL");
    $stmt->execute(array($name, $day, $time));
    if ($name === "" || $stmt->rowCount() !== 1) {
        echo json_encode(array("ok" => false, "error" => "that time is not free"));
        return;
    }
    echo json_encode(array("ok" => true, "result" => array("reference" => "A-1042", "day" => $day, "time" => $time, "name" => $name)));
    return;
}
echo json_encode(array("ok" => false, "error" => "unknown tool"));

6. What a successful call sounds like

  1. The caller asks for Monday morning.
  2. The agent calls list_slots with that day. Your server returns the free times.
  3. The agent offers 09:00, 09:20, and 09:40.
  4. The caller chooses 09:20 and gives a name.
  5. The agent calls book_slot. Your server stores the name on that row and returns a reference.
  6. The agent repeats the day, the time, and the reference.

If ok is false, the agent receives error and should say the time could not be booked. A field rights on the tool result is ignored. The caller’s rights stay the ones from the route. To switch to another agent, return agent and a new prompt. The voice model keeps the tools from the start of the call. See Hand off to another agent.

Call debugging shows the route response and the tool calls. The transcript, when history has transcripts on, is the place to read what the agent said.