Composition

A single agent is just a function. Real systems are built by wiring agents together — calling sub-agents, invoking tools, running work in parallel, and exposing the whole thing back to the LLM for function-calling. Because everything is Starlark, composition is just function composition.

Calling another agent

Reference another agent by its filename (without the .star extension). The runtime resolves "summarizer" to agents/summarizer.star:

# agents/research_pipeline.star
config(model = "claude-sonnet")

def agent(question):
    raw       = tool("web_search", query = question)
    summary   = agent("summarizer",    document = repr(raw))
    verified  = agent("fact_checker",  claims = summary)
    return {"answer": verified, "sources": raw}

Sub-agents are isolated: they get their own call log segment, their own config(), and their output flows back as a plain value.

Invoking tools

Tools are .star files in the tools/ directory. Each file exports one function — its name becomes the tool name, its docstring becomes the description, and its parameters are auto-generated into a JSON schema for LLM function-calling.

tools/search.star

def web_search(query, max_results = 5):
    """Search the web and return results."""
    response = http("GET", "https://api.search.example/search", params = {
        "q": query,
        "limit": max_results,
    })
    return response["results"]

Call it from an agent:

results = tool("web_search", query = "rust programming", max_results = 5)

Letting the LLM pick tools

Pass a list of tool names to prompt() and the LLM can call them autonomously during its response. max_turns caps the tool-use loop:

answer = prompt(
    "Research this question and verify your findings: " + question,
    tools = ["web_search", "summarizer", "fact_checker"],
    max_turns = 5,
)

Sub-agents can be exposed as tools too — this is how you build recursive multi-agent systems without a special framework.

Parallel fan-out

parallel(fns) runs a list of functions concurrently and returns their results in order. Use it for independent searches, multi-audience summaries, or any embarrassingly parallel step:

# Search multiple queries in parallel
results = parallel([
    lambda q = q: tool("web_search", query = q)
    for q in queries
])

# Generate summaries for different audiences concurrently
summaries = parallel([
    lambda a = a: prompt("Summarize for " + a + " audience:\n" + doc)
    for a in ["technical", "executive", "general"]
])

Jinja templates for complex prompts

For prompts with conditionals, loops, or reusable fragments, render a Jinja template with template() and pass the result into prompt():

prompts/research.jinja

You are a {{ role }} research assistant.

Answer the following question: {{ question }}

{% if sources %}
Use these sources:
{% for source in sources %}
- [{{ source.title }}]({{ source.url }}): {{ source.snippet }}
{% endfor %}
{% endif %}

{% if format == "detailed" %}
Provide a comprehensive analysis with citations.
{% else %}
Provide a concise answer in 2-3 sentences.
{% endif %}
answer = prompt(template("prompts/research.jinja",
    role = "senior",
    question = question,
    sources = sources,
    format = "detailed",
))

A composed example

A research pipeline that plans queries, fans out searches, optionally fetches sources, renders a Jinja prompt, and fact-checks the result:

config(model = "claude-sonnet", max_turns = 15)

def agent(question, depth = "standard"):
    queries = prompt(
        "Generate 3 search queries for: " + question +
        "\nReturn as a JSON array of strings.",
        format = "json",
    )

    all_results = parallel([
        lambda q = q: tool("web_search", query = q)
        for q in queries
    ])

    sources = []
    if depth == "deep":
        urls = [r["url"] for batch in all_results for r in batch[:2]]
        sources = parallel([lambda u = u: tool("fetch_url", url = u) for u in urls])

    answer = prompt(template("prompts/research.jinja",
        question = question,
        results = all_results,
        sources = sources,
    ), model = "claude-opus", temperature = 0.3)

    final = prompt("Fact-check this answer. Return the corrected version.\n\n" + answer)

    return {"answer": final, "sources": all_results, "queries_used": queries}

Every step is ordinary Starlark. Every side effect is a logged, replayable host function call.

Was this page helpful?