Skip to main content
The agent under test is whatever answers the simulated user. Whichever adapter you use, every tool call it makes goes through sandtable’s simulated world: it is checked against the invariants, it changes only the mock records, and it is graded. Nothing touches the customer’s real systems.

Pick an adapter

The model adapters (claude, bedrock:, openai:) all receive the same default system prompt (the domain description and policy) and the same tools, so their scores compare. Your own agent receives that prompt too and may ignore it. If your agent is built with a framework, such as the Vercel AI SDK, the OpenAI Agents SDK, the Claude Agent SDK, Pipecat or LiveKit Agents, start from a worked example: see Rehearse an agent built with a framework.
Unless you pass --user, the simulated user is Claude, so these runs also need Claude credentials. See the simulated user.

Claude on the Anthropic API

--agent claude runs the reference agent: Claude with the domain’s tools, and the domain description and policy as its system prompt. --agent claude:<model> picks the model (default claude-opus-5-5).
  • Authentication uses ANTHROPIC_API_KEY.
  • The system prompt, tools and history form an append-only prefix that is cached automatically, which keeps repeated trials cheap.
  • A user turn ends after 12 model calls; the reply is then [agent exceeded its tool-call budget for this turn].
  • Where the model supports it, sandtable opts into server-side refusal fallbacks on the Anthropic API. A fallback means another model served that turn. To measure one model strictly, build the agent in code with claudeAgent({ fallbacks: false }).
  • Tokens are recorded per model, and cost is estimated at Anthropic list prices.
Claude also plays the simulated user by default, and drafts invariants and tasks in sandtable init.

Claude on Amazon Bedrock

Model-backed commands (run, demo --live, init --sop, init --transcripts) use the Anthropic API by default. To run Claude on Amazon Bedrock with your AWS credentials, install the optional client and pass --provider bedrock:
Install optional packages next to sandtable-ai, in the same project. npx sandtable-ai resolves packages from its own cache, so it can’t see a package installed on its own in the current directory. For a one-off run without a project, name both: npx -p sandtable-ai -p @anthropic-ai/bedrock-sdk sandtable demo --live --provider bedrock. The Python launcher runs the CLI without optional packages, so use npm for Bedrock.
--provider applies to every Claude model in the command: the agent, the simulated user and init’s drafting steps. Plain model names map to the region’s cross-region inference profile, so claude-opus-5-5 becomes eu.anthropic.claude-opus-5-5 in eu-west-1: Full model IDs and ARNs pass through unchanged. Set SANDTABLE_BEDROCK_CLIENT=mantle to use Bedrock’s Messages-API endpoint instead, where your region serves the model; model names then become anthropic.<model>. Cost estimates use Anthropic list prices; your Bedrock rates may differ.

Other models on Amazon Bedrock

--agent bedrock:<model-id> runs any tool-capable model on Bedrock through the Converse API, such as OpenAI, Qwen, Mistral or Amazon Nova models. Install the AWS SDK client first:
  • <model-id> is the Bedrock model or inference profile ID, passed through unchanged. The model must be enabled for your account in that region.
  • The region comes from --region, then AWS_REGION, then AWS_DEFAULT_REGION. Credentials come from the default AWS chain, which honours AWS_PROFILE. Passing --aws-profile also needs @aws-sdk/credential-providers, installed the same way.
  • A turn allows up to 12 model calls of up to 16,000 output tokens each, reasoning included, the same as the Claude adapter. Output stopped by a guardrail or content filter becomes [agent output was filtered].
  • A tool call runs when its reply ends with tool_use or end_turn; Grok 4.7 sometimes sends a call with end_turn. A call in a reply that ended any other way, such as cut off at the output limit, filtered or malformed, is not run, and the agent gets an error result for it.
  • Reasoning never reaches the caller or the graders. Reasoning blocks are left out of the reply, and so is reasoning a model writes into its text as <reasoning>…</reasoning>, as gpt-oss sometimes does. A reply that opens <reasoning> and never closes it is requested again, up to twice. The discarded reply appears in neither the conversation nor the transcript, but its tokens, cost and time count toward the turn.
  • Prompt caching is automatic on Claude and Amazon Nova models. The adapter marks the tools, the system prompt and the newest message as cacheable, so each call pays the cache rate for context it already sent. Nova’s cache point stays on the caller’s latest message, because Nova rejects one next to tool calls and results. Other models get none, because Bedrock rejects the whole request when one is present; neither do Claude models older than Claude 3.5 Haiku, which AWS documents as not supporting prompt caching. Some of the other models, such as OpenAI’s GPT models and Grok, cache on their own, and those tokens are recorded as cache reads too. A model is recognised by its ID, so an application inference profile ARN gets no cache points. Caching changes token counts and cost, never the prompt the model reads.
  • This is an agent adapter only. The simulated user stays Claude; add --provider bedrock to run that on Bedrock too.
  • Tokens are recorded. Cost shows n/a unless you pass the model’s prices with --prices: sandtable holds verified prices only for Claude models.

OpenAI-compatible endpoints

--agent openai:<model> and --user openai:<model> talk to any server that implements OpenAI’s Chat Completions API (POST <base>/chat/completions), using plain fetch. That covers OpenAI, OpenRouter, LiteLLM, vLLM, Ollama, Groq, Together and others.
  • The base URL is --openai-base-url, else OPENAI_BASE_URL, else https://api.openai.com/v1. Agent and user share it.
  • OPENAI_API_KEY, when set, is sent as a bearer token. It is required only for the default OpenAI URL; local servers such as Ollama and vLLM need none.
  • The model name passes through unchanged: openai:llama3.1:8b asks for llama3.1:8b.
  • The caller stays Claude (ANTHROPIC_API_KEY) unless you pass --user. Keep the caller the same across agents, so that only the agent changes.
  • The model must support tool calling on that server. vLLM, for example, needs --enable-auto-tool-choice and a --tool-call-parser.
The agent gets the same system prompt and tools as the Claude baseline. temperature is sent only when set:
The agent runs its own tool loop:
  • Each entry in tool_calls runs against the simulated world. Its result goes back as a role: "tool" message with the same tool_call_id. A turn ends after maxToolRounds model calls (default 12).
  • Arguments that are not valid JSON go back to the model as a tool error it can correct. The attempt still shows in the trajectory as a failed call.
  • History is append-only. Assistant messages go back as the endpoint returned them, including provider fields such as reasoning and Gemini’s thought signatures.
  • Inline <think>…</think> blocks are dropped from the reply, so neither the caller nor the graders see them.
Usage is recorded under the requested model name: PRICES holds verified list prices for Claude models only, since prices for other models vary by host. Their cost shows as n/a unless you pass their prices with --prices.

Compare models in code

openaiCompatAgent({ baseUrl, model, apiKey?, headers?, temperature?, maxToolRounds? }) and openaiCompatUser (the same options, without maxToolRounds) build the adapters. They do not read environment variables, so pass apiKey yourself. To compare models, loop over them and keep the caller fixed:

Your agent over HTTP

If your agent runs as a service, in any language, give --agent its URL. sandtable sends one POST per step: a new user message, or the results of the tool calls your agent asked for. Your agent answers with a reply, or with more tool calls for sandtable to run against the simulated world.
The quickstart has a runnable toy agent in Node and Python, and the HTTP agent protocol reference has every field, the error behaviour and the limits.
session_id is <run-id>/<task-id>#<trial>: unique per trial and never reused by another run.

Your agent as a JS or TS module

If your agent is a JavaScript or TypeScript function, default-export an AgentAdapter, or a function (sync or async) that returns one:
my-agent.ts
  • callTool(name, args) resolves to { ok, result?, error? }. Invalid arguments and failed checks come back as ok: false with an error your agent can show the model.
  • Call recordUsage(model, { input, output, cacheRead, cacheWrite }) after each model call if you want tokens and cost in the report. It is optional. Add maxTokensStops: 1 for a call whose reply stopped at the model’s output-token limit, so the report counts it.
  • A .ts module needs Node 22.18 or later, which strips types natively. On older Node, compile it to .js first.
A complete minimal example ships in the repo: examples/custom-agent.ts.

The simulated user

When you compare agents, keep the simulated user fixed (same model, same settings), so that only the agent changes. In demo --live, choose the models with --agent-model and --user-model. The simulated user can be wrong: it can drift from its goal or end a call early. Read failing trajectories in the report before blaming the agent. See what the first live runs taught us.