README
¶
automata
Composable agent primitives in Go, built for traceability and developer ergonomics in multi-agent systems — the kind of reliability you need when agents run inside a web server, not a notebook.
- Primitives, not a platform.
coreis a small set of orthogonal pieces —Agent,Tool,Provider, stream events — that compose into orchestrators, sub-agents, and pipelines. No magic, no hidden state. - A block-based message model that never degrades a provider. A
Messageis a list of typedBlocks — text, thinking (with its signature), tool_use, tool_result (with an error flag), images — a superset of what any single provider exposes. Claude's thinking blocks round-trip through tool loops instead of being dropped; nativeis_errorsurvives; nothing is flattened to a lowest common denominator.RawBlockcarries provider-specific blocks so a new provider feature never blocks on a core release. - Traceable by construction. Every run emits structured
sloglogs andtracingspans; every streaming run emits a documented, tested event contract (see docs/streaming.md) you can fold into a UI, an SSE endpoint, or a log withcore.StreamAccumulator. - Dependency-light core. The root module depends only on
golang.org/x/sync. Vendor SDKs and API keys live in isolatedextensions/*modules.
Layout
| Module / package | What it is |
|---|---|
core |
Agent, Loop, Session, Tool, Provider, streaming, hooks, approval |
tools (module) |
First-party tools: HTTPFetch, ReadFile/WriteFile (sandboxed), Shell (allow-listed), WebSearch |
extensions/claude (module) |
Anthropic provider (core.StreamProvider); thinking, images, prompt caching |
extensions/openai (module) |
OpenAI Chat Completions provider (stdlib-only); any OpenAI-compatible base URL |
extensions/tavily (module) |
Tavily backend for tools.WebSearch |
retry, tracing |
Backoff policy and span interfaces used by core |
examples/* (modules) |
Runnable demos, including a multi-agent deep-research TUI |
Extensions and examples are separate Go modules tied together by go.work,
so importing core never pulls a vendor SDK into your build.
Quickstart
package main
import (
"context"
"fmt"
"os"
"github.com/emotional-data8482/automata/core"
"github.com/emotional-data8482/automata/extensions/claude"
"github.com/emotional-data8482/automata/tools"
)
type weatherArgs struct {
City string `json:"city" desc:"city to look up"`
}
func main() {
agent := core.New(claude.New("claude-sonnet-4-6", os.Getenv("ANTHROPIC_API_KEY"))).
WithSystemPrompt("You are a concise assistant.")
// A typed tool: the JSON schema is derived from the struct fields.
agent.RegisterTool(core.Func("weather", "Get the weather for a city",
func(ctx context.Context, a weatherArgs) (string, error) {
return "sunny in " + a.City, nil
}))
// A first-party tool: fetch a page as readable text.
agent.RegisterTool(tools.HTTPFetch())
res, err := agent.Run(context.Background(), "What's the weather in Paris?")
if err != nil {
panic(err)
}
fmt.Println(res.Output)
fmt.Printf("(%d steps, %d output tokens)\n", res.Steps, res.Usage.OutputTokens)
}
Run returns a RunResult — the final Output text, the full FinalMessage
(blocks included), the run's Messages transcript, summed Usage, Steps, and
a StopReason. It is populated as far as the run got even when err is
non-nil, so a run that exhausts its step budget still hands back its partial
transcript and usage. Per-call provider options (temperature, max tokens, stop
sequences, tool choice, thinking budget) are set with
agent.WithDefaultCallOptions(...) or per run with
agent.Run(ctx, task, core.WithCallOptions(...)).
Sessions and transcripts
Agent.Run is one-shot. For multi-turn conversations — and for the audit
trail — use a Session: every run continues the same conversation, and the
full transcript (system prompt, tasks, replies, tool calls and results) is
plain data you can persist and resume. The transcript is recorded even when a
run fails, so you can always see what happened.
sess := agent.NewSession()
draft, _ := sess.Run(ctx, "Draft a refund policy for our SaaS")
final, _ := sess.Run(ctx, "Make it friendlier and add a 30-day clause")
fmt.Println(final.Output) // each Run returns a RunResult
// Persist anywhere; resume later, even in another process. Every block type —
// text, thinking (with signature), tool calls and results, images — round-trips
// through JSON, so the resumed conversation is byte-for-byte the same.
blob, _ := json.Marshal(sess.Messages())
var transcript []core.Message
_ = json.Unmarshal(blob, &transcript)
sess = agent.ResumeSession(transcript)
_ = draft
Use a per-run post-run hook to checkpoint the transcript after the session has
committed it. Hooks also run for failed and canceled runs, receiving the partial
RunResult and the original run error:
checkpoint := core.WithPostRunHook(func(ctx context.Context, res core.RunResult, runErr error) error {
blob, err := json.Marshal(res.Messages)
if err != nil {
return err
}
return os.WriteFile("session.json", blob, 0o600)
})
res, err := sess.Run(ctx, "Plan the next bounded cycle", checkpoint)
The hook context retains the run context's values but is detached from its
cancellation and deadline so cancellation checkpoints can still complete. A
storage implementation should apply its own timeout. If persistence fails, its
error is returned (joined with the run error when both fail) without discarding
the RunResult. This is a completed-run boundary checkpoint, not resumable
execution inside an active provider turn or tool call; action-level idempotency
should protect external side effects.
Typed results
core.RunTyped[T] returns the agent's final answer decoded into a Go struct.
It injects a hidden tool whose JSON schema is derived from T and ends the run
when the model calls it; if the model answers in prose instead, it forces the
tool on one more turn. The agent's regular tools still work alongside it.
type Person struct {
Name string `json:"name"`
Age int `json:"age" desc:"age in years"`
}
p, res, err := core.RunTyped[Person](ctx, agent, "Who is Ada Lovelace?")
// p.Name == "Ada Lovelace"; res carries usage/steps/transcript.
For a persistent coordinator, use RunSessionTyped to keep the conversation
across typed decisions and JSON persistence:
sess := agent.NewSession()
first, _, err := core.RunSessionTyped[Person](ctx, sess, "Choose the first action", checkpoint)
blob, _ := json.Marshal(sess.Messages())
var transcript []core.Message
_ = json.Unmarshal(blob, &transcript)
sess = agent.ResumeSession(transcript)
next, res, err := core.RunSessionTyped[Person](ctx, sess, "Choose the next action", checkpoint)
_, _, _, _ = first, next, res, err
If typed output needs the forced structured-output fallback, that fallback is a second bounded session run. Post-run hooks fire after both completed runs, so the prose attempt is checkpointed before the forced run begins.
Multi-agent: sub-agents are just tools
An Agent becomes a tool on another agent with core.AsTool — the type
parameter defines the JSON schema the orchestrator's model fills in:
orch.RegisterTool(core.AsTool[researchParams](researcher, "researcher",
"Delegate a focused research assignment."))
AsTool forwards the raw JSON arguments as the sub-agent's task. When you'd
rather hand the sub-agent natural language (no "you will receive JSON…"
boilerplate in its prompt), use AsToolFunc with a renderer:
orch.RegisterTool(core.AsToolFunc[researchParams](researcher, "researcher",
"Delegate a focused research assignment.",
func(p researchParams) string {
return fmt.Sprintf("Research: %s\nQuestions:\n- %s",
p.Topic, strings.Join(p.Questions, "\n- "))
}))
Watch every agent work
RunStream delivers a live event stream — including events from nested
sub-agents, tagged with the sub-agent's name. StreamAccumulator folds the
deltas into per-agent state so rendering is a snapshot, not bookkeeping:
var acc core.StreamAccumulator
res, err := orch.RunStream(ctx, topic, func(ev core.StreamEvent) {
acc.Add(ev)
for _, v := range acc.Views() { // top-level first, then sub-agents
fmt.Printf("[%s] %d tool calls, %d tokens\n",
v.Agent, len(v.ToolCalls), v.Usage.OutputTokens)
}
})
_ = res // RunStream returns the same RunResult as Run
The full ordering and tagging contract is documented in docs/streaming.md.
Long conversations
For multi-turn sessions and long tool loops, core.Compactor is a pre-send hook
that summarizes older turns to stay within a token budget (keeping the system
prompt and recent turns intact, never splitting a tool call from its result),
and the Claude provider's WithConversationCache() caches the message prefix so
each turn re-reads it cheaply. See docs/context.md.
Web search
tools.WebSearch is vendor-neutral; backends implement tools.Searcher in
their own modules:
researcher.RegisterTool(tools.WebSearch(tavily.New(os.Getenv("TAVILY_API_KEY"))))
Examples
-
examples/claude— minimal tool-using agent. -
examples/deep_research— orchestrator + researcher + writer with a live Bubble Tea TUI rendered entirely from aStreamAccumulator. NeedsANTHROPIC_API_KEYandTAVILY_API_KEY:go run ./examples/deep_research "the impact of GLP-1 drugs on US healthcare costs"
Roadmap
See docs/roadmap.md.