tool_calls, and a module agent calls callTool. An agent you can’t change calls its tools itself: it sends requests to the customer’s API, connects to an MCP server, or, on a hosted voice platform, has the platform call a webhook. run --serve serves the simulated world on all three while sandtable drives the conversation. Each trial’s calls are checked against the invariants, hit by the task’s faults, and graded, exactly like calls sandtable makes for the agent.
reply and never sends tool_calls.
Which trial a request is for
Each trial’s world is a session with its own URLs, under/s/<session>/:
- An HTTP agent receives them as
worldon every request of the trial:url(the REST base),mcpandwebhook. - A module agent receives them as
ctx.worldincreateSession.
http://127.0.0.1:4010/mcp, go to the trial that is running. That is what an agent configured once with a fixed URL needs, and it is unambiguous because --serve runs trials one at a time. With -c 4, four trials run at once: a request to the base URL then gets a 409 that says so, and the agent must use the URLs it was given for its trial. A session’s URLs answer 404 once its trial ends.
--serve 5000 picks the port; --serve 0 picks a free one. The server listens on 127.0.0.1 only, has no authentication and serves simulated records, so don’t expose it beyond your machine. It refuses a request that a web page sends from another origin (an Origin header that isn’t its own), and one whose Host isn’t this machine (127.0.0.1, localhost or [::1]), as MCP’s transport asks a local server to; agents, curl and platforms’ webhooks send no Origin.
REST
Every tool is served atPOST /tools/<name>, with its arguments as a JSON object. A tool with an http route is also served there, so an agent written against the customer’s API calls the same paths it would in production:
init --openapi drafts an http route for each operation it turns into a tool. The answers are what a real API sends:
GET / describes what is served: the domain, the open sessions, the routes, and the MCP and webhook URLs.
MCP
/mcp is an MCP server over Streamable HTTP, with plain JSON responses: initialize, tools/list, tools/call and ping. tools/list returns the domain’s tools with their JSON schemas. A tool error, or a fault, comes back as a result with isError: true and the error as text. Configure the agent’s MCP client with the URL:
GET /mcp answers 405, and it doesn’t issue MCP session ids: the URL says which trial a call is for.
Webhooks
Hosted voice platforms call tools through a webhook.POST /webhook answers the tool-call payloads they send:
A tool error comes with status 200, as the platforms expect, so the agent’s model reads it. A fault answers the whole request with its status, as a real outage would: Vapi then tells its model that no result came back. Other Vapi server messages, such as status updates and the end-of-call report, get an empty 200. sandtable doesn’t check webhook signatures.
Conversations another system drives
An agent on a hosted voice platform, such as Vapi or Retell, talks to the platform’s callers, and the platform’s own simulated callers can drive test calls. sandtable can’t drive those conversations, but it can serve their tools and grade them:sandtable serve keeps the simulated world up until you stop it, and grades each conversation when it ends.
- Each conversation is a session with its own world: a trial of the task. A platform’s call id opens one (Vapi’s
message.call.id, Retell’scall.call_id), so concurrent calls don’t share records. - It is graded when it ends: on Vapi’s end-of-call report or Retell’s
call_endedevent, which carry the transcript. - Other systems open a session with
POST /sessions({ "task": "<id>" }picks the task, else--task), use its URLs, and end it withPOST /sessions/<id>/end, with the transcript as{ "transcript": [{ "role": "user" | "agent", "text" }] }when they have it. An MCP connection is a session too:initializeopens it and returns its id asMcp-Session-Id, andDELETE /mcpends it. - A request that names no session goes to the one conversation open without a call id or an MCP session, or opens one. A conversation a platform’s call id or an MCP connection opened is reached only through them, so one that never ends, such as an MCP client that never sends
DELETE, catches no other requests. It stays open until you end it withPOST /sessions/<id>/end, or stop the server. - Once a call has ended, a platform’s retry of its end report is answered and not graded again, and a late tool call for it gets a 409.
results.jsonandreport.htmlin-oare rewritten after each graded conversation, before its end is answered, withuser: "external"and the agent named--label. Ctrl-C prints the summary.
reply_includes) can’t be graded on it, so the trial is skipped, unless the agent broke a rule; never_disclose rules can’t fire either. Agent turn latency is unknown, so the report shows n/a.
What is graded
Everything, as for any run: the end state, the invariants, the required and forbidden tools, and what the agent said. Inresults.json, a call that came over the server has via (rest, mcp or webhook), and the report shows it. An agent may mix them, and may still call callTool too: every call lands in the same trial’s log, in order.