Feature·Tools & Agents·LLM Primitives

The loop is the language.

Tools are lambdas with schemas. Agents are system prompts with a turn limit. The tool-dispatch loop, retries, cost caps, and provider fallback live in the runtime — your code stays the size of its idea.

$cargo install sema-lang

Eight chat providers · retries with backoff · scoped budgets · single binary

The agent loop, visualized

What the runtime handles for you.

Every agent script grows the same loop. In Sema it's not code you write — it's what agent/run does when you call it.

1
User message
"Find TODOs in src/"
2
LLM call
Model decides: call a tool or answer
3
Tool call?
Dispatch & feed result back
4
Final answer
String returned to caller
loop — tool result fed back to LLM
Retry with backoff — 429s and 5xx retried automatically, up to 3 retries with exponential backoff and full jitter
$Budget enforcementllm/with-budget caps spend for the scope; calls that would exceed it fail
Provider fallbackllm/with-fallback tries the next provider if one fails
Error recovery — a tool that throws doesn't abort; the error is fed back so the model can self-correct

deftool

A tool is a lambda with a schema.

Define a function the LLM can call. The name, description, and parameter schema are sent to the model automatically. No JSON schema objects to build by hand, no dispatch table to maintain.

  • Typed parameters. Declare types inline with :string, :number, :boolean — the runtime builds the JSON schema for you.
  • Self-documenting. The description string is what the model sees. Write it like a doc comment.
  • Inspection. tool/name, tool/description, tool/parameters, tool? — tools are first-class values.
tools.semadefine a tool
(deftool read-file
  "Read a file's contents"
  {:path {:type :string
          :description "File path"}}
  (lambda (path)
    (file/read path)))

(deftool run-command
  "Run a shell command"
  {:command {:type :string}}
  (lambda (command)
    (:stdout
      (shell "sh" "-c" command))))

defagent

An agent is a config, not a class.

System prompt, tools, model, and a turn limit (optional, defaults to 10). agent/run handles the loop — calling tools, feeding results back, stopping at the limit or when the model has a final answer.

  • One call to run. (agent/run coder "Find TODOs") returns the final answer string.
  • Observe tool calls. Pass :on-tool-call to watch each tool start and end — for logging, UIs, or debugging.
  • Full history. Pass an options map and get :response + :messages — the complete conversation for chaining or inspection.

Agent reference →

agent.semadefine & run
(defagent coder
  {:system    "You are a coding assistant."
   :tools     [read-file run-command]
   :model     "claude-sonnet-4-6"
   :max-turns 10})

(llm/with-budget
  {:max-cost-usd 0.50}
  (lambda ()
    (agent/run coder
      "Find TODOs in src/")))

;; => "Found 3 TODOs: src/main.rs:42, ..."
;;    cost: $0.03 · 4 tool calls · 2 turns

The runtime, in one screen

Everything you'd otherwise hand-roll.

These are forms — scoped expressions that wrap your code. They can't be forgotten on a late-night code path.

(llm/with-budget {:max-cost-usd 1.00} f)hard spend cap, scoped to a block
(llm/with-cache {:ttl 3600} f)response cache — dev loops stop costing money
(llm/with-fallback [:anthropic :openai] f)provider failover, in order
(llm/with-rate-limit 5 f)token-bucket rate limiting, requests/sec
(llm/extract {:amount {:type :number}} text)typed maps back, not strings to re-parse
(llm/with-cassette "tape.jsonl" f)record & replay — deterministic tests

Eight chat providers plus embedding providers, configured from environment variables — set the key and go. Browse the LLM reference →

Conversations as data

Fork it. Diff it. Replay it.

A conversation is an immutable value. Every operation — say, set-system, filter — returns a new conversation. The original is never modified. Branch from any point and explore two directions simultaneously.

  • Immutable history. conversation/say returns a new value — the old one is untouched.
  • Branch with fork. conversation/fork creates an independent copy at any point. Explore alternatives without losing the thread.
  • Inspect everything. conversation/messages, conversation/last-reply, conversation/token-count, conversation/cost — all first-class.
conversation.semafork & explore
(define conv
  (conversation/new
    {:model "claude-sonnet-4-6"}))

(define conv
  (conversation/say conv
    "Remember: the secret is 7"))

;; Fork and explore two paths
(define path-a
  (conversation/say
    (conversation/fork conv)
    "What about Python?"))

(define path-b
  (conversation/say
    (conversation/fork conv)
    "What about Rust?"))

;; conv, path-a, path-b — all independent

Error recovery

Tools that throw don't abort.

A tool that raises an error, isn't found, or gets bad arguments doesn't crash the agent. The error is fed back to the model as the tool result — so it can correct itself and continue. The loop is bounded by :max-turns and aborts after 5 consecutive tool errors.

  • Self-correcting loop. The model sees the error message and adjusts its next call — wrong file path, bad command, missing argument.
  • Bounded by design. :max-turns caps the total loop; 5 consecutive tool errors abort. No infinite loops.
  • Observe everything. :on-tool-call fires on each tool start and end, with duration and result preview.
agent run — tool error & recovery
$ sema run agent.sema
turn 1: model calls read-file
  ✗ tool error: "No such file: src/mai.rs"
turn 2: model retries with corrected path
  read-file "src/main.rs" → 2.1 KB
turn 3: model calls run-command
  run-command "grep -n TODO src/main.rs"
  → "42: // TODO: handle empty input"
turn 4: final answer
  ✓ "Found 1 TODO in src/main.rs:42"
  4 turns · 2 tool calls · $0.03

The argument

The same agent, twice.

A coding agent with a tool loop, retries, and a spend limit. Once with an SDK, once in Sema.

agent.py — Python + SDKyou write the machinery
import anthropic, time

client = anthropic.Anthropic()

TOOLS = [{
    "name": "read_file",
    "description": "Read a file",
    "input_schema": {
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"],
    },
}, {
    "name": "run_command",
    "input_schema": { # ...same again },
}]

def call_with_retry(messages, attempt=0):
    try:
        return client.messages.create(
            model=MODEL, max_tokens=4096,
            tools=TOOLS, messages=messages)
    except anthropic.RateLimitError:
        if attempt > 5: raise
        time.sleep(2 ** attempt)
        return call_with_retry(messages, attempt + 1)

def dispatch(name, args):
    if name == "read_file":
        return open(args["path"]).read()
    if name == "run_command":
        # subprocess, capture stdout+stderr...

messages = [{"role": "user", "content": task}]
for turn in range(10):
    resp = call_with_retry(messages)
    track_cost(resp.usage)  # you wrote this too
    if resp.stop_reason != "tool_use": break
    results = []
    for block in resp.content:
        if block.type == "tool_use":
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": dispatch(block.name, block.input),
            })
    messages.append(...)
And there's still no response cache, no hard spend cap, no fallback provider. That's another dependency — or another hundred lines.
agent.sema — Semathe machinery is the language
(deftool read-file
  "Read a file's contents"
  {:path {:type :string}}
  (lambda (path) (file/read path)))

(deftool run-command
  "Run a shell command"
  {:command {:type :string}}
  (lambda (command)
    (:stdout (shell "sh" "-c" command))))

(defagent coder
  {:system    "You are a coding assistant."
   :tools     [read-file run-command]
   :max-turns 10})

(llm/with-budget {:max-cost-usd 0.50}
  (lambda ()
    (agent/run coder "Find TODOs in src/")))
The tool loop, retries with backoff, and cost tracking live in the runtime. The spend cap is a scope — it can't be forgotten on the late-night code path.

Build an agent in ten lines.

Install Sema, define a tool, run an agent. The loop is already there.

curl$curl -fsSL https://sema-lang.com/install.sh | sh
cargo$cargo install sema-lang