# Agent loops

Script the model's side of a conversation: each Messages call returns the next scripted reply, text or tool calls, streamed or not, so an agent loop runs to its end and replays the same after a reset.

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:

```bash
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 reply | The response's `content` | Its `stop_reason` |
|---|---|---|
| a tool call | a `tool_use` block with a `toolu_` id | `tool_use` |
| text | the text | `end_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](https://www.rystic.ai/docs/simulators/anthropic.md#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()`:

```python
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:

```ts
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_choice` | The reply |
|---|---|
| `{"type": "tool", "name": …}` | a call to that tool |
| `{"type": "any"}` | a call to the first declared tool |
| `auto`, `none` or no `tool_choice` | text, since the simulator never calls a tool by itself |

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

---

Source: https://www.rystic.ai/docs/simulators/anthropic/features/agent-loop · Markdown: https://www.rystic.ai/docs/simulators/anthropic/features/agent-loop.md
