Skip to Content

Simulators

Agent loops

The simulator calls no model, so a test writes the model’s side of the conversation: an ordered script of replies, each text or calls to the tools your request declares. Post a script, then run both turns of a loop against it:

A=(-H 'x-api-key: sk-ant-placeholder' -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json')
curl -s -X POST "$RYSTIC_URL/_rystic/replies" -H 'Content-Type: application/json' -d '{"replies": [{"content": [{"type": "tool_use", "name": "get_weather", "input": {"city": "Paris"}}]}, {"content": [{"type": "text", "text": "It is sunny in Paris."}]}]}'
curl -s "$RYSTIC_URL/_rystic/replies" | jq -c '{left: (.left | length), taken: (.taken | length)}'
# -> {"left":2,"taken":0}
curl -s "${A[@]}" "$RYSTIC_URL/v1/messages" -d '{"model":"claude-haiku-4-5-20251001","max_tokens":64,"tools":[{"name":"get_weather","description":"Current weather for a city.","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],"messages":[{"role":"user","content":"Weather in Paris?"}]}' | jq -c '{stop_reason, name: .content[0].name, input: .content[0].input}'
# -> {"stop_reason":"tool_use","name":"get_weather","input":{"city":"Paris"}}
curl -s "${A[@]}" "$RYSTIC_URL/v1/messages" -d '{"model":"claude-haiku-4-5-20251001","max_tokens":64,"tools":[{"name":"get_weather","description":"Current weather for a city.","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],"messages":[{"role":"user","content":"Weather in Paris?"},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"21C, sunny"}]}]}' | jq -c '{stop_reason, text: .content[0].text}'
# -> {"stop_reason":"end_turn","text":"It is sunny in Paris."}
curl -s "$RYSTIC_URL/_rystic/replies" | jq -c '{left: (.left | length), taken: (.taken | length)}'
# -> {"left":0,"taken":2}

The script

Each POST /v1/messages returns the next reply in the script, in the API’s shape, and streams it when you ask for stream: true:

Scripted replyThe response’s contentIts stop_reason
a tool calla tool_use block with a toolu_ idtool_use
textthe textend_turn

Once the script runs out, the simulator gives its default reply. Post the script to /_rystic/replies, or carry it under replies in a scenario or boot file (Seeding state). The anthropic_agent_loop scenario ships one for the two tools below.

GET /_rystic/replies reports left, taken and the operations a script covers, and DELETE /_rystic/replies empties it. POST /_rystic/reset starts the script over. Ids and usage come out the same each run, so a loop’s transcript is identical after every reset.

Run a loop unchanged

Load anthropic_agent_loop and run your loop as it is. Python, streaming with messages.stream():

import os
import anthropic

client = anthropic.Anthropic(base_url=os.environ["RYSTIC_URL"], api_key="sk-ant-placeholder")
tools = [
    {"name": "get_weather", "description": "Current weather for a city.",
     "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}},
    {"name": "search_help", "description": "Search the help center.",
     "input_schema": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
                      "required": ["query"]}},
]
results = {"get_weather": "21°C, sunny", "search_help": "Orders > Edit order"}
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
while True:
    with client.messages.stream(model="claude-haiku-4-5-20251001", max_tokens=1024,
                                tools=tools, messages=messages) as stream:
        reply = stream.get_final_message()
    messages.append({"role": "assistant", "content": reply.content})
    if reply.stop_reason != "tool_use":
        break
    messages.append({"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": block.id, "content": results[block.name]}
        for block in reply.content if block.type == "tool_use"]})
print(reply.content[-1].text)

TypeScript:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({ baseURL: process.env.RYSTIC_URL, apiKey: "sk-ant-placeholder" });
const tools: Anthropic.Tool[] = [
  { name: "get_weather", description: "Current weather for a city.",
    input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } },
  { name: "search_help", description: "Search the help center.",
    input_schema: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer" } }, required: ["query"] } },
];
const results: Record<string, string> = { get_weather: "21°C, sunny", search_help: "Orders > Edit order" };
const messages: Anthropic.MessageParam[] = [{ role: "user", content: "What's the weather in Paris?" }];
let reply: Anthropic.Message;
for (;;) {
  reply = await client.messages.stream({ model: "claude-haiku-4-5-20251001", max_tokens: 1024, tools, messages }).finalMessage();
  messages.push({ role: "assistant", content: reply.content });
  if (reply.stop_reason !== "tool_use") break;
  messages.push({ role: "user", content: reply.content.flatMap((b) =>
    b.type === "tool_use" ? [{ type: "tool_result" as const, tool_use_id: b.id, content: results[b.name] }] : []) });
}
console.log(reply.content.at(-1));

Tool calls

A scripted call to a tool your request does not declare returns 400 invalid_request_error naming the tool. GET /_rystic/replies still reports that reply under taken.

With no script left, tool_choice decides the reply:

tool_choiceThe reply
{"type": "tool", "name": …}a call to that tool
{"type": "any"}a call to the first declared tool
auto, none or no tool_choicetext, since the simulator never calls a tool by itself

A call’s input is one that its tool’s input_schema accepts.

Last updated on