# Exercise an LLM tool with a script

URL: /docs/cookbook/scripted_tool
Markdown: /docs/cookbook/scripted_tool.md

> Drive a tool call and final answer with scripted model output, then replay the same provider exchange.

Generated by `cargo run -p agent_docs` from `compiler/examples/`. Edit the example files, then regenerate.

Drive a tool call and final answer with scripted model output, then replay the same provider exchange.

`@tool` gives `Tool.of(platform)` its name, description and argument schema. The
agent validates model arguments and invokes the real function under the caller's
handlers. The model receives no additional authority. `UnknownStation` becomes a
`ToolError` and stops the loop; the test checks its tag and verifies no success log
was produced. Other `LlmError` and `ToolError` variants remain in the signature.

`Llm.scripted` consumes responses in order. The successful test asserts both the
answer and the tool's captured output, so returning a canned answer without calling
the tool does not pass. A synthetic two-turn cassette exercises the provider shape
through `Llm.replay`, which denies network fallback. The harness uses test mode.
The manifest grants no network or key access. Budgets are intentionally unlimited
here; the budgeted-agent recipe exercises finite accounting.

[Source project](https://github.com/icantcodefyi/sakuralang/tree/master/compiler/examples/scripted_tool) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/scripted_tool
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "scripted_tool"
version = "0.1.0"
edition = "2026"

[permissions]
```

### Source and tests [#source-and-tests]

`src/main.sakura` includes all required imports and tests.

```sakura test id=cookbook-scripted_tool project=scripted_tool
// milestone: M3
// mode: test
// spec: design §17.145

import std/agent.{Agent}
import std/budget.{Budget}
import std/fs.{Fs}
import std/llm.{Llm, LlmError, Request}
import std/path.{Path}
import std/prompt.{Prompt}
import std/tool.{Tool, ToolError}

error UnknownStation

@tool("Look up a station platform")
fn platform(station: String) -> Int ! {Console, Fail<UnknownStation>} {
  if station != "North" { fail UnknownStation }
  Console.print("looked up {station}")
  4
}

fn platform_answer() -> Int ! {Budget, Console, Llm, Fail<LlmError | ToolError>} {
  let request: Request<Int> = Llm.request("Use the platform tool.", Prompt.text("North"))
  Agent.run(Agent { request, tools: [Tool.of(platform)], max_steps: 3, stop: fn(_) { false } })
}

test "scripted model calls the actual tool before answering" {
  with Budget.unlimited(),
      Llm.scripted([
        "\{\"tool_use\":[\{\"name\":\"platform\",\"input\":\{\"station\":\"North\"\}\}]\}",
        "4",
      ]) {
    expect_eq(platform_answer(), 4)
    expect_output(["looked up North"])
  }
}

test "tool errors end the loop before an answer" {
  with Budget.unlimited(),
      Llm.scripted([
        "\{\"tool_use\":[\{\"name\":\"platform\",\"input\":\{\"station\":\"Unknown\"\}\}]\}",
      ]) {
    expect_fails_with(fn() { platform_answer() }, fn(e) {
      match e {
        ToolError { tag, .. } => tag == "UnknownStation",
        _ => false,
      }
    })
    expect_output([])
  }
}

test "the same two turns replay from a synthetic provider cassette" {
  with Fs.rooted(Test.fixtures()), Budget.unlimited(), Llm.replay(Path.of("platform.jsonl")) {
    expect_eq(platform_answer(), 4)
    expect_output(["looked up North"])
  }
}
```

### `tests/fixtures/README.md` [#testsfixturesreadmemd]

```text
# Synthetic cassette

Hand-authored request/response exchanges in the version 1 cassette format.
No live request, provider account, API key or recorded personal data was used.
Requests match method, URL, headers and body; secret headers are absent.
Tests install an empty scripted HTTP fallback, so a missing exchange fails offline.
```

### `tests/fixtures/platform.jsonl` [#testsfixturesplatformjsonl]

```jsonl
{"schema_version":"1.0","kind":"cassette"}
{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":[["content-type","application/json"],["anthropic-version","2023-06-01"],["anthropic-beta","server-side-fallback-2026-07-01"]],"body":"{\"model\":\"claude-opus-5-5\",\"max_tokens\":1024,\"system\":\"Use the platform tool.\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"North\"}]}],\"tools\":[{\"name\":\"platform\",\"description\":\"Look up a station platform\",\"input_schema\":{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{\"station\":{\"type\":\"string\"}},\"required\":[\"station\"],\"additionalProperties\":false}}],\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"],\"additionalProperties\":false}}},\"fallbacks\":\"default\"}","status":200,"response_headers":[["content-type","application/json"]],"response_body":"{\"id\":\"msg_SYNTHETIC_0\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_SYNTHETIC_1\",\"name\":\"platform\",\"input\":{\"station\":\"North\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":120,\"output_tokens\":20}}"}
{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":[["content-type","application/json"],["anthropic-version","2023-06-01"],["anthropic-beta","server-side-fallback-2026-07-01"]],"body":"{\"model\":\"claude-opus-5-5\",\"max_tokens\":1024,\"system\":\"Use the platform tool.\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"North\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_SYNTHETIC_1\",\"name\":\"platform\",\"input\":{\"station\":\"North\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_SYNTHETIC_1\",\"content\":\"4\"}]}],\"tools\":[{\"name\":\"platform\",\"description\":\"Look up a station platform\",\"input_schema\":{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{\"station\":{\"type\":\"string\"}},\"required\":[\"station\"],\"additionalProperties\":false}}],\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"],\"additionalProperties\":false}}},\"fallbacks\":\"default\"}","status":200,"response_headers":[["content-type","application/json"]],"response_body":"{\"id\":\"msg_SYNTHETIC_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"value\\\":4}\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":120,\"output_tokens\":20}}"}
```

### Compiler provenance and regeneration [#compiler-provenance-and-regeneration]

These eight projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by `cargo test -p sakura_cli --test examples`;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.

The initial API baseline is compiler revision
[5cae349516c21714d90d36e87f1cfd6debe980c6](https://github.com/icantcodefyi/sakuralang/commit/5cae349516c21714d90d36e87f1cfd6debe980c6).
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run `sakura --version` and `sakura prompt` before adapting a recipe;
pre-alpha APIs can change. An installed skill's frontmatter records its compiler
version and build revision. These teaching examples are separate from the agent
evaluation tasks.

The source of each page is its example's `recipe.md`, `sakura.toml`,
`src/main.sakura` and fixture files. Run `cargo run -p agent_docs` from `compiler/`
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
