# big-brain — full documentation for AI agents > Source: https://big-brain.mjasadi.com/docs/ — this file is the entire authoring guide for pkg/bb, concatenated for an LLM agent to read in one shot and then write a brain with it. big-brain (module `github.com/force1267/big-brain`, import path `github.com/force1267/big-brain/pkg/bb`) is a Go library — not a hosted service — that wraps LLMs behind standard OpenAI- and Anthropic-compatible APIs. From the outside a deployment is just another model endpoint; every existing chat UI, IDE plugin, and SDK is a free client. Inside, a request runs through a **brain**: a tree of flows and agents with memory, tools, routing, and durable execution. A brain is a Go program that imports `pkg/bb`, assembles a tree of flows and agents, and calls `bb.Serve`. ## Table of contents 1. Introduction 2. Quick start 3. Mental model 4. Flows, agents & turns 5. Models & roles 6. Routing with Select 7. Concurrency 8. Tools 9. Memory 10. Streaming 11. Durability 12. Triggers & initiative 13. Telemetry & cost 14. Serving 15. The rules (short list) 16. Testing 17. Reference brains --- ## Introduction **big-brain is a Go library, not a service you configure.** You write a small Go program that imports `pkg/bb`, assembles your **brain** as a tree of flows and agents, and calls one function to serve it. What comes out the other end speaks the exact wire protocol of an OpenAI or Anthropic model — so every chat UI, IDE plugin, and SDK you already have is a free client. ```go bb.Serve(ctx, brain) // OpenAI + Anthropic-compatible, at :8080 ``` ### The core idea **An agent that disguises itself as a model.** From the outside, a big-brain deployment is indistinguishable from a model endpoint: point `curl`, the OpenAI SDK, or your IDE's assistant panel at it and it just answers. Inside, a request runs through a brain — model calls, memory, tools, routing, and background work — that makes it far more capable than a single model call. The disguise extends to cost, honestly: the `usage` block big-brain reports is the real **sum of every upstream call** the brain made to answer you, not a mirror of your own prompt size. A brain that made five model calls really did spend five calls' worth of tokens, and bb tells you that plainly — see [Telemetry & cost](/concepts/telemetry). ### Why a library, not a platform **This codebase is vLLM, not OpenAI.** A running process is one deployment owned by one author, not a multi-tenant provider. There's no first-class multi-tenancy, no per-tenant billing, no isolated memories — because that's somebody else's product, built *around* this one, using the embeddable `pkg/` and an externalized store. What one process *can* do is serve several named flows behind one endpoint, each its own "model" id, chosen by the request's `model` field — a routing convenience for one owner (a chat brain, a coding brain, a summarizer, from a single binary), not a tenancy model. ### The authoring model, in one sentence **A brain is a tree of flows, and control flow is Go.** A flow runs one or more agents over a chat and hands the result to the next flow; flows compose (a group of flows is itself a flow) and chain with `Next`. An agent's `OnMessage` handler is a plain Go function: it branches, calls tools, reads memory, and `Select`s which flow runs next. There is no graph DSL, no `Vars map[string]any`, no node vocabulary to grow — `if` is `if`. Continue to the [quick start](/guide/quick-start) for a complete, runnable brain, or jump straight to the [mental model](/guide/mental-model) for the vocabulary (flow, agent, turn) used throughout the rest of these docs. ### What the engine actually sells A brain author could hand-write the model calls, the prompt templates, the routing, and the database wiring, and get exactly what a reference brain does. The engine earns its place by owning the parts an author would get wrong or forget: - **Composition** — agents, flows, `Select` routing, and the concurrency strategies (`All`/`One`/`Group`, `Checkpoint`); you write handlers and wiring, the engine runs the tree and resolves selection. - **Durable, resumable execution** — with a store configured, each flow's result is checkpointed; a client that retries a crashed run (same run id) resumes from the flow that was interrupted, not from the start. - **Observability, free from the same boundaries** — every flow start/end, select, response, and cached-resume is a timed trace event, with what that flow actually spent attached. Debugging is a byproduct of running the tree. - **The boring boundary** — OpenAI/Anthropic-compatible serving, `/v1/models`, startup validation of the whole wiring, faithful passthrough of the chat protocol including a caller's own tools. - **Faculties** — model roles, structured extraction, typed prompt templates, a `Notify` outgoing flow — the common machinery, abstracted so it adds value without getting in the way of your business logic. ### What it does not promise (v1) - **Streaming is terminal-only per stage, buffered everywhere else.** See [Streaming](/concepts/streaming) for exactly where a client gets live tokens and why. - **At-least-once, not exactly-once.** A resumed durable run replays already-delivered response stages so the client sees the complete answer again; side effects that must not double are the author's responsibility. See [Durability](/concepts/durability). - Voice/vision/realtime endpoints, a graph file format, a generic plugin system, server-side transcripts, and multi-tenancy are explicitly out of scope for v1 — later, when a real slice demands them, not speculatively now. --- ## Quick start ### Install ```sh go get github.com/force1267/big-brain/pkg/bb ``` ### The 60-second demo A complete, working brain — a persona assistant that just asks a model and replies. No config files, no YAML graph, no plugin system: it's Go. ```go package main import ( "context" "os" "os/signal" "github.com/force1267/big-brain/pkg/bb" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() // A model backs the "chat" role — a real provider if BIG_BRAIN_API_KEY is // set, else a canned reply so this runs with no credentials. if os.Getenv("BIG_BRAIN_API_KEY") != "" { bb.WithModel(bb.NewModel().WithName("gpt-4o-mini")).WithTag("chat") } else { bb.WithModel(bb.FixedModel("At your service.")).WithTag("chat") } // One agent, one flow: ask the model and reply. assistant := bb.NewAgent(). WithModel(bb.NewModel("chat")). WithRole(bb.Role("You are Jarvis: warm, brief, lightly witty.")) brain := bb.NewFlow().WithAgent(assistant) bb.Serve(ctx, brain) // OpenAI + Anthropic at :8080 } ``` Run it: ```sh go run . ``` Point *any* OpenAI-compatible client at it: ```sh curl localhost:8080/v1/chat/completions -H 'content-type: application/json' -d '{ "messages": [{"role": "user", "content": "hello there"}] }' ``` Or an Anthropic-compatible one, same brain: ```sh curl localhost:8080/v1/messages -H 'content-type: application/json' -d '{ "messages": [{"role": "user", "content": "hello there"}] }' ``` From the client's side this is an OpenAI (or Anthropic) model. From the inside it's a flow you grow — add a router that `Select`s capabilities, agents that call tools, memory the brain keeps across turns — none of which the client has to know about. ### Environment Provider credentials come from the environment (12-factor), prefix `BIG_BRAIN_`: | Variable | Purpose | |---|---| | `BIG_BRAIN_API_KEY` | Provider API key | | `BIG_BRAIN_BASE_URL` | Override the OpenAI-compatible base URL (self-hosted endpoints, etc.) | | `BIG_BRAIN_MODEL` | Default model name | | `BIG_BRAIN_DATA` | Directory for durable state — makes a brain's durability survive restarts | | `BIG_BRAIN_TELEMETRY` | `stdout` or `otlp` to turn on OTel metrics (see [Telemetry](/concepts/telemetry)) | ### Try the reference brains Clone the repo and run a complete smart-home assistant with no API key: ```sh git clone https://github.com/force1267/big-brain cd big-brain go run ./cmd/jarvis-demo # smart-home brain: world on :8090, brain on :8080 ``` See [Reference brains](/reference/reference-brains) for what each one demonstrates. ### Where to go next - [Mental model](/guide/mental-model) — the vocabulary: flow, agent, turn. - [Flows, agents & turns](/concepts/flows-agents-turns) — the full API, with the `turn`/`chat` split explained. - [Models & roles](/concepts/models) — how a model gets resolved and how to register several. --- ## Mental model Three nouns cover almost everything in `pkg/bb`. - A **Flow** runs one or more **agents** over an incoming chat, collects their replies, and hands the result to the next flow. - Flows **compose**: a group of flows is itself a flow (`Select`, `All`, `One`, `Group`), and `Next` chains them into a longer flow. - An **Agent** is *build-time* configuration (model, role, schema, handler). It cannot act on its own. - A **Turn** and a **ModelChat** are the agent *live* on one message — the two handles an `OnMessage` handler receives. `turn` faces the **client** (`Reply`/`Stream`/`Call`/`Select`); `chat` faces the **model** (`Add`/`Ask`/`Resolve`). Neither can reconfigure the agent — the compiler enforces both splits, so an invalid state (a builder that asks, a running turn that changes its model) is unrepresentable. ### The smallest brain ```go func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() bb.WithModel(bb.NewModel().WithName("gpt-4o-mini")).WithTag("chat") agent := bb.NewAgent(). // no model: inherits the flow's / the default WithRole(bb.Role("You are a terse assistant.")) flow := bb.NewFlow().WithModel(bb.NewModel("chat")).WithAgent(agent) bb.Serve(ctx, flow) // OpenAI + Anthropic at :8080 } ``` A default (no-`OnMessage`) agent just asks the model with the incoming chat and replies — forwarding the caller's tools and relaying the model's tool calls untouched, so it behaves exactly like the model behind it. That is the whole walking skeleton; everything else in these docs is what you add to it. ### Two handles, two directions An agent mediates two opposite conversations — the client that called your brain, and the model your brain calls — and the same nouns (a message, a tool call, a tool result) flow both ways. So they are kept as separate objects, and which one you touch says which direction you mean: | | `turn bb.Turn` — the **client** side | `chat bb.ModelChat` — the **model** side | |---|---|---| | read | `Messages`, `Last()`, `Request()`, `ToolResults()` | `Messages()` | | write | `Reply`, `Stream`, `Call` | `Add`, `WithTools`, `ForwardTools`, `WithToolChoice` | | act | `Select` | `Ask`, `AskWith`, `Resolve` | `chat.Ask` asks the model; `turn.Reply` answers the client. `chat.Add` builds the prompt; `turn.Call` asks the client to run something. Neither can reconfigure the agent — there are no `With…` methods for that on either handle, so runtime self-modification stays impossible by construction. This split is the single idea to hold onto before reading the rest of the concept pages — every API in `pkg/bb` is an instance of "which side of the conversation is this for." Continue to [Flows, agents & turns](/concepts/flows-agents-turns) for the full API surface built on top of this model. --- ## Flows, agents & turns ### Building an agent ```go agent := bb.NewAgent(). WithModel(bb.NewModel("fast")). WithRole(bb.Role("You are Jarvis.")). WithSchema(bb.Schema[intent]()). // optional: expect structured output Selects(idTalk, idHouse). // optional: declare Select exits OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { chat.Add(turn.Last()) // add the latest incoming message reply, err := chat.Ask() // send role + added chat to the model if err != nil { return err // schema mismatch + transport surface here } turn.Reply(reply.ReadAll()) // add an assistant message to the flow return nil }) ``` - `turn.Messages` is the incoming conversation; `turn.Last()` is the latest. - `chat.Add(msgs…)` chooses what the next `Ask` sends; `chat.AskWith(msgs…)` is `Add`+`Ask`. - `turn.Reply(text)` appends output to the flow's chat (zero or many times); it does **not** go to the model. - `reply.ReadAll()` / `Read()` / `Stream()` read the answer; `bb.Extract[T](reply)` decodes it into a schema type. - `ctx` is this turn's context; it is done when the handler returns. Pass it to any I/O you do so cancellation is respected. - `turn.Request()` is the client's request as context: the sampling parameters it sent (`model`, `temperature`, `top_p`, `max_tokens`, `stop`, `think`, …). They are **not** applied to your agent automatically — they are an input for the handler to read and act on. ### Request parameters are input, never an override The engine never silently honors a client's sampling knobs; it hands them to the flow so *you* decide. A handler can honor, clamp, ignore, or branch on them: ```go OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { req := turn.Request() if n := req.MaxTokens; n != nil && *n < 64 { chat.Add(bb.NewMessage("Answer in one short sentence.").As("system")) } // req.Model is the model id the client asked for (also what selects a // named flow at the serving layer); req.Temperature/TopP/Stop are theirs // to weigh in. req.TopK is Anthropic-only (nil on OpenAI requests). // req.MaxTokens already resolves OpenAI's deprecated max_tokens vs. the // current max_completion_tokens for you — one field either way. // req.Tools() / req.ToolChoice() are the tools the client declared. // req.Think (Anthropic "thinking", OpenAI "reasoning_effort") is nil when // the client sent no opinion; non-nil is a request, not a command — the // agent's own model decides whether WithThink means anything to it. chat.Add(turn.Last()) reply, err := chat.Ask() // asks with the agent's own model config, not req's // ... }) ``` The agent's own `WithModel` config is what `Ask` uses; the request params are context, never an override — so a brain stays a brain, not a raw model whose behavior the caller dictates. ### Structured output `WithSchema(bb.Schema[T]())` tells the agent to expect JSON matching `T`; `Ask` validates the reply against it (a mismatch is the error from `Ask`), and `bb.Extract[T](reply)` returns a typed `T`: ```go type intent struct { Intent string `json:"intent" enum:"talk,house,remember" doc:"the chosen capability"` Reason string `json:"reason"` } // ... reply, err := chat.Ask() if err != nil { return err } it := bb.Extract[intent](reply) turn.Select(it.Intent) ``` Struct tags shape the schema sent to the model: `doc:"…"` becomes a field description, and `enum:"a,b,c"` constrains a field to a fixed set — handy for a router that must pick one of a known list of ids. `bb.Extract` is a free function (not `reply.Extract[T]()`) because Go forbids type parameters on methods — the same shape as `bb.Schema[T]()`. It reads a tool call's arguments too: `bb.Extract[sensorArgs](call)`. ### Talking to a model with no flow at all The same `ModelChat` works on its own, which is useful in tests, scripts, or inside a Go tool a flow calls: ```go reply, err := bb.Chat(ctx, bb.NewModel("smart")).AskWith(bb.NewMessage("hi")) ``` ### Chaining and continuing past the reply `a.Next(b).Next(c)` runs a→b→c, threading the chat. `bb.Respond` is the prebuilt flow that delivers everything produced since the previous `Respond` (or since the start) as one stage of the answer; you can chain flows after it to keep acting: ```go brain := router.Next(bb.Select(caps...)).Next(bb.Respond).Next(notify) ``` `bb.Respond` is **repeatable** — a chain may contain several, and each is a stage boundary the client sees as soon as it's ready: ```go // A -> B -> respond -> C -> D -> respond -> E brain := flowA.Next(flowB).Next(bb.Respond). // stage 1: B's reply Next(flowC).Next(flowD).Next(bb.Respond). // stage 2: C's + D's replies Next(flowE) // initiative; not part of the answer ``` Only flows after the **last** `Respond` are pure initiative. A streaming client sees each stage's tokens as they're produced; a non-streaming client gets every stage's text, joined with a blank line between stages. A stage with nothing new since the previous boundary answers with nothing for that stage — it never falls back to echoing the client's own message. `bb.Notify(send)` is a prebuilt outgoing flow — it sends the chat's last message to `send` and passes the chat through: ```go notify := bb.Notify(func(ctx context.Context, text string) error { return postToWebhook(ctx, text) }) ``` ### Naming and models on any flow `WithId` and `WithModel` are methods of **every** flow, not just `NewFlow()` — a `Select`, an `All`/`One`/`Group`, or a `Next` chain too. Name a composite to make it one addressable unit (Selectable, triggerable, durable); set a model on a group to give the agents inside it a default: ```go capabilities := bb.Select(talk, remember, house). WithModel(bb.NewModel("cheap")). // default for member agents that set none WithId("capabilities") // name the whole group ``` Model resolution is lexical scope over the tree: **agent's own → its flow's → nearest enclosing group's → `bb.WithDefaultModel` → first registered.** Because these return the `Flow` interface, call them *after* the `Basic`-only `WithAgent` (`NewFlow().WithAgent(a).WithModel(m).WithId("x")`). Next: [Models & roles](/concepts/models) for exactly how that ladder resolves, or [Routing with Select](/concepts/routing) for multi-capability brains. --- ## Models & roles ```go bb.WithModel(bb.NewModel().WithName("gpt-4o-mini").WithTemprature(0.5)).WithTag("fast", "cheap") m := bb.NewModel("fast") // seeded from the registered model m2 := bb.NewModel("fast").WithTemprature(0.9) // overrides just this use inline := bb.NewModel().WithName("gpt-4o") // no registry, built inline demo := bb.FixedModel("canned reply") // no provider — for demos/tests ``` `bb.WithModel(m)` registers `m` and returns a handle; `.WithTag(…)` binds it to lookup tags. `bb.NewModel(tags…)` is always a builder: with no tags it starts blank, with tags it is seeded from the registered model and stays overridable. Provider credentials come from the environment (`BIG_BRAIN_API_KEY`, `BIG_BRAIN_BASE_URL`). Flow code names a *role*; deployment decides which provider backs it. ### Consuming a model through Anthropic's own API By default a name resolves through the OpenAI-compatible client. To consume a model natively through Anthropic's own API instead: ```go bb.NewModel().WithName("claude-sonnet-5").WithProvider(bb.AnthropicProvider) ``` `WithProvider` is the only thing that changes — the same registry, tags, `WithTemprature`, and inheritance ladder apply either way. `WithThink(true)` requests extended reasoning mode; only the Anthropic provider honors it (a fixed token budget), OpenAI silently ignores it. This is independent of `bb.Serve`, which always speaks both the OpenAI and Anthropic wire protocols to *callers* regardless of which provider a brain *consumes*. ### The resolution ladder **Which model an agent asks** is resolved along a ladder, first match wins: 1. `agent.WithModel(m)` — the agent's own model. 2. `flow.WithModel(m)` — the model set on the flow the agent runs in. 3. `bb.WithDefaultModel(m)` — an explicit process default. 4. the first `bb.WithModel(…)` registered — the implicit default. So no agent is ever truly model-less; leaving `WithModel` off just means "use whatever the flow, then the default, provides". A default (no-`OnMessage`) agent that resolves to no model at any rung is a startup error from `bb.Serve` — see [Serving](/reference/serving) for the full list of what gets validated at boot. For a group (`Select`/`All`/`One`/`Group`), the ladder gains one more rung between an agent and the process default: **agent's own → its flow's → nearest enclosing group's → `bb.WithDefaultModel` → first registered.** ### One name, several tags One model can answer to several tags — `"fast"` and `"cheap"` can point at the same small model until a real reason emerges to split them: ```go var register bb.RegisterModel = bb.WithModel( bb.NewModel().WithName("google/gemma-4-e4b").WithThink(false).WithTemprature(0.3), ) register = register.WithTag("cheap", "fast") ``` Building models inline (no tag, no registry lookup) is equally valid — tagging is a convenience for reuse across many flows, not a requirement. --- ## Routing with Select `Select` groups flows so an upstream agent picks one by id: ```go brain := router.Next(bb.Select(talk, remember, house)).Next(bb.Respond) ``` - Each `Select` member must set an id with `WithId`; a member without one is ignored (with a warning). - An agent picks a member with `turn.Select(id)`. An unknown id is a **loud error** at request time, never a silent misroute. - Declaring an agent's exits with `Selects(id…)` adds a **startup** check (`bb.Serve`/`bb.Handler` verifies every declared exit is a group member) before any request runs. It is optional — declare it when you want the boot-time guarantee. - Within one agent, the last `Select` wins (program order). Across *concurrent* agents, two different selects is a loud `error`, not a race; the same id is fine. ### A typical intent router The pattern behind both reference brains: classify with a model + typed schema, then dispatch. ```go type intent struct { Intent string `json:"intent" enum:"talk,house,remember" doc:"the chosen capability"` Reason string `json:"reason"` } router := bb.NewAgent(). WithModel(bb.NewModel("cheap")). WithSchema(bb.Schema[intent]()). Selects("talk", "house", "remember"). OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { chat.Add(turn.Last()) reply, err := chat.Ask() if err != nil { return err } it := bb.Extract[intent](reply) turn.Select(it.Intent) return nil }) brain := bb.NewFlow().WithAgent(router). Next(bb.Select(talkFlow, houseFlow, rememberFlow)). Next(bb.Respond) ``` `Selects(...)` is worth adding here: a typo in `it.Intent`, or a capability you forgot to add to the `Select` group, fails at `bb.Serve` startup instead of the first time a live classification happens to pick it. See [Concurrency](/concepts/concurrency) for what happens when multiple flows in a `Select` group's siblings (`All`/`One`/`Group`) run at once. --- ## Concurrency A flow with several agents runs them concurrently; they can coordinate with a checkpoint: ```go cp := bb.NewCheckpoint() recognizer := bb.NewAgent().OnMessage(func(ctx context.Context, t bb.Turn, _ bb.ModelChat) error { t.Reply(classify(t.Last().Content)); bb.Reached(cp); return nil }) guard := bb.NewAgent().OnMessage(func(ctx context.Context, t bb.Turn, chat bb.ModelChat) error { if err := bb.Wait(ctx, cp); err != nil { return err } // wait for recognizer // ... return nil }) flow := bb.NewFlow().WithAgent(recognizer, guard) ``` ### Group strategies over member flows - **`bb.All(a, b, …)`** — run all, merge every reply, end when all end. - **`bb.One(a, b, …)`** — first to finish wins, the rest are cancelled. - **`bb.Group(a, b, …)`** — run all over one **live shared chat**: a member's reply is immediately visible to the others (a member's next `Ask`, or `turn.Last()`, sees it). Order members with `Checkpoint`/`Wait` when one must see another's contribution first. Two agents `Select`ing different next-flows concurrently is a loud error, never a silent last-writer race — the same id from both is fine. The same tracing applies to parallel work: see [Telemetry](/concepts/telemetry) for how token spend is (and isn't) attributed across overlapping flows. ### Streaming inside a concurrent group No member of `Select`/`One`/`All`/`Group` may claim the client's live stream directly — racing concurrent members for one stream would let whichever called `turn.Stream()` first win, regardless of whose content ends up in the answer. Their replies still reach the client, buffered and delivered by the next `bb.Respond`. Full detail in [Streaming](/concepts/streaming). ### Triggers reached inside a group A trigger (`bb.Every`/`bb.Once`) reached inside `bb.One(a, b, …)` only commits once that branch is the actual winner — a losing member's trigger is discarded, not scheduled anyway. `All`/`Group` need no such gating: every member's contribution is kept, so committing a member's trigger the moment it's reached is already correct there. See [Triggers & initiative](/concepts/triggers). --- ## Tools A model cannot run anything. It can only *ask* — the client executes and sends the result back. bb sits on both sides of that: it is a client to its upstream model, and a model to whoever called it. So there are two boundaries, and they are different problems: - **Inner** — your agent wants the upstream model to call a tool **your Go code** runs. The caller never learns it exists. - **Outer** — your *caller* declared tools and bb must faithfully pass them through: surface them, relay the model's requests back, accept the results. bb never executes a caller's tool by itself. Three plain data types cover both, and they are just messages in the chat: ```go type Tool struct { Name, Description string; Schema map[string]any } // a definition type ToolCall struct { ID, Name string; Input json.RawMessage } // an invocation type ToolResult struct { CallID, Content string; IsError bool } // an answer ``` A `Message` carries `Calls` and `Results` alongside its text, so "let me check" plus two tool calls is one ordinary message and reading one is a `len` check, never a type assertion. ### Defining a tool ```go type sensorArgs struct { Sensor string `json:"sensor" enum:"temperature,humidity" doc:"which sensor"` } readSensor := bb.NewTool(). As("read_sensor"). // the name the model emits Is("read one of the house sensors"). // the whole basis for the model choosing it WithSchema(bb.Schema[sensorArgs]()) // the argument shape ``` That is a *bare* definition: pure data, no handler, no chat. It can be sent to a model, forwarded from a client, or compared. ### Inner tools, the manual way `Ask` sends the tools and **never runs your code** — executing a side effect can never be an implicit consequence of asking a question. You get the calls back and decide: ```go OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { chat.Add(turn.Last()) for { reply, err := chat.WithTools(readSensor, setDevice).Ask() if err != nil { return err } calls := reply.ToolCalls() if len(calls) == 0 { // the model answered in prose turn.Reply(reply.ReadAll()) return nil } results := make([]bb.ToolResult, 0, len(calls)) for _, c := range calls { switch c.Name { case "read_sensor": a := bb.Extract[sensorArgs](c) // decode this call's arguments results = append(results, bb.NewToolResult().WithId(c.ID). WithContent(house.Read(ctx, a.Sensor))) } } chat.Add(bb.NewMessage("").WithResults(results...)) // ALL of them, one message } }) ``` Two rules are load-bearing here: - **Nothing is forwarded implicitly.** A flow has several models, and a small one must not be handed every tool in the process, so each ask declares what its model may call. `WithTools` applies to that ask only. - **Answer a round's calls in ONE message.** Parallel tool use is several calls in one message; splitting the answers across messages is a documented footgun on both providers that trains a model to stop calling in parallel. ### Inner tools, the short way: `bb.OnCall` + `Resolve` The `switch` above repeats every tool's name as a string with nothing checking it still matches. Bind a handler to the tool instead and bb dispatches: ```go realSensor := bb.OnCall(readSensor, func(ctx context.Context, a sensorArgs) (string, error) { return house.Read(ctx, a.Sensor) }) OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { reply, err := chat.WithTools(realSensor, realDevice).Resolve(turn.Last()) if err != nil { return err } turn.Reply(reply.ReadAll()) return nil }) ``` - `bb.OnCall(tool, fn)` returns a **copy** — the bare definition stays bare, so one definition can carry several bindings (production, a test stub, or none at all where the tool is only forwarded). - It **checks** the handler's argument type against the schema already on the tool rather than replacing it, so every tool is built the same way. A mismatch is recorded and surfaces at the first `Ask` that would send it; the broken tool never reaches a provider. - `Ask` = one round, runs nothing. `Resolve` = ask → run the handlers → feed the results back → repeat, until the model answers without calling. The mode is on the verb, so the call site says which you meant. - A handler returning an **error becomes an is-error result** the model reads and can retry against — not an aborted turn. Only a cancelled context stops it. - `Resolve` is **capped** (`.WithMaxRounds(n)`, default 8) so a model that keeps calling errors instead of spinning. - A round is **all-or-nothing**: if a batch mixes tools bb can run with tools only the client can, `Resolve` runs none of them and hands the whole batch back. Both providers reject a turn where some call went unanswered, and running a side effect whose result must then be discarded is worse than not running it. ### Outer tools: your caller's The caller's tools arrive as read-only context, always **bare** — a tool that crossed the wire never carries a handler, so bb cannot execute one by accident: ```go turn.Request().Tools() // []bb.Tool the client declared turn.Request().ToolChoice() // "" (auto), "any", "none", or a tool name turn.ToolResults() // results the client sent back ``` Forward them explicitly, and relay what the model asks for: ```go OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { reply, err := chat.ForwardTools().AskWith(turn.Messages...) // tools AND choice if err != nil { return err } turn.Call(reply.ToolCalls()...) // ask the CLIENT to run them turn.Reply(reply.ReadAll()) // text can accompany the calls return nil }) ``` `ForwardTools()` is sugar for `WithTools(turn.Request().Tools()...)` plus the choice, and the two stack: `chat.ForwardTools().WithTools(readSensor)` sends the caller's tools plus your own. There is no `ForwardCalls` — a reply is transient and plural, so `turn.Call(reply.ToolCalls()...)` names its source instead. ### The one rule that ties it together At the end of a request: > **A tool call with no matching result in the chat goes to the client. A > call that has one is settled history and stays internal.** That single rule gives you all three behaviours with no extra machinery: | what you do | what the client sees | |---|---| | `turn.Call(c)` and never answer it | `tool_use` / `finish_reason: tool_calls` | | answer it (`Resolve`, or add a `ToolResult`) | nothing — it was internal | | one agent calls, a later agent/flow answers | nothing — handoff needs no mechanism | **There is no tool state on the server.** bb emits the call and the turn *ends*. The client runs the tool and re-sends the whole transcript — exactly as it would to a real model API — and the flow re-runs from the top with the result in `turn.Messages`. Nothing to checkpoint, no loop to resume. Counterpart lookups (`call.ToolResult()`, `result.ToolCall()`) are therefore resolved **per flow**: if the counterpart is not in the messages this flow saw, you get an id-only stub rather than a nil or a panic. ### A bare agent is already tool-transparent An agent with **no `OnMessage`** is a full transparent proxy: it forwards the caller's tools and choice and replays the model's text and tool calls untouched. Point any OpenAI or Anthropic SDK at it and it behaves exactly like the model behind it, tools included. Write `OnMessage` and **all** of that stops — every forward becomes explicit, which is the point: you own the loop. --- ## Memory Memory is the brain's own state — bb does not impose a store. Keep facts in a map, or in a KV via `bb.MemStore()` / `bb.FileStore(dir)` (a `Get`/`Put` backend), and read/write it inside a handler, weaving recalled facts into the persona: ```go if facts := mem.recall(); len(facts) > 0 { chat.Add(bb.NewMessage("You remember: " + strings.Join(facts, "; ")).As("system")) } ``` That's the whole API surface on purpose. The engine gives you durable execution and a KV; **what** to remember and **how** to recall it is the author's — deliberately, so a memory strategy never gets in the way of the next one you want to try. A memoryful "model" is visibly unlike the providers it imitates, and that difference is entirely in your handler, not in a framework-owned memory subsystem you have to configure around. ### Continuity: transcripts vs. memory The chat API is stateless: the client sends history each request, and the engine keeps no server-side conversation. **Transcripts belong to the client; durable facts belong to memory.** Memory is the only continuity there is — if a fact matters past the current request, it has to end up in your store, because nothing else survives. ### Working state within a run The chat threading through a flow chain *is* the run's working state: each flow appends its replies, and the next flow sees them. Beyond that, an agent handler holds ordinary Go variables for the span of a turn. Long-term facts are memory (above); the chat is the scratch a single run carries between flows — don't reach for a store to pass something two flows down the same chain. See `cmd/jarvis-demo` for a complete memory + tools + briefing brain: a keyword router into remember/recall capabilities, a `Group`-based briefing that reads several sensors concurrently, and facts that persist across turns via `bb.FileStore`. --- ## Streaming The client can see tokens as the model types them — but only at each stage's **terminal** boundary: the flow whose reply is that stage's answer (the one before a `bb.Respond`, or the last in the chain if there's none). Everywhere upstream, flows hand each other *complete* messages, because that is what durable checkpointing needs. So `State` always carries whole messages; streaming is a parallel live tee that exists only at each stage's end. ### Why not stream everywhere A live stream cannot cross a flow boundary and still leave a consistent save point — durability checkpoints *complete* messages between flows. So there are two output paths: the durable one (always whole messages) and an ephemeral live tee to the client that exists only at each stage's terminus. Genuine keep-working-*after*-the-connection-closes is future engine work, not a streaming limitation. ### Default agents stream for free A **default agent (no `OnMessage`) streams automatically** when it is terminal and the client asked for it — nothing to write. To stream from a handler, tee the model's live output into the outgoing channel `turn.Stream()` hands you: ```go OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error { chat.Add(turn.Last()) reply, err := chat.Ask() if err != nil { return err } if out, ok := turn.Stream(); ok { // ok only when terminal + client wants SSE for tok := range reply.Stream() { // live model tokens out <- tok // forward (or transform/inject) } close(out) // done; the full text is captured into State for you return reply.Err() // a mid-stream model error surfaces here } turn.Reply(reply.ReadAll()) // buffered fallback (non-terminal, or non-streaming) return nil }) ``` ### Key facts - `turn.Stream()` returns `(chan<- string, ok)`. `ok` is **claim-once per stage**: the first agent to call it in a stage's terminal flow wins; everyone else (a sibling in a concurrent group, a non-terminal agent, a non-streaming request) gets `ok=false` and should `turn.Reply` normally. `Respond` resets the claim once it flushes its stage, so the next stage's terminal flow gets its own fresh shot at it. - No member of `Select`/`One`/`All`/`Group` may claim the stream directly — concurrent members racing for one client stream would let whichever called `Stream()` first win, regardless of which member's content actually ends up in the answer (`One`'s winner, say). Their replies still reach the client: buffered into `State`, then delivered by the next `Respond` like any other buffered reply. (`Select` is the exception in name only — it routes to exactly one member synchronously, so there's no race to guard against; that member's own terminal flow can stream normally.) The same rule applies to **multiple agents inside one flow** (`WithAgent(a, b)`): they run concurrently too, so none of them may claim the stream either, even when that flow is the terminal one — `Respond`'s claim-once flush only accounts for a single streamed contribution per stage, so letting two agents race for it would silently drop whichever one lost. - If a handler claims `turn.Stream()` and then returns an error without closing the channel, the framework still recovers: the flow cancels the turn's context on error, and the stream's tee goroutine watches that cancellation as well as the channel, so the request can't hang forever on an abandoned stream. Still close `out` yourself on every path you can — this is a backstop, not a substitute for closing it. - `reply.Stream()` and `reply.ReadAll()`/`bb.Extract` **coexist** — read the live tokens *and* still get the whole text (e.g. to save to memory after). You are never forced to choose. - Closing `out` is enough: the framework delivers to the client and records the complete message into `State`, so `Respond`/`Notify` and durability all see the whole reply. Do **not** also `turn.Reply` the same text. - `reply.Err()` is where a mid-stream model error lands (once tokens are flowing there is no HTTP status left to fail with; the server emits an SSE error frame). - A schema agent never streams live (structured output is validated whole); its `reply.Stream()` yields the finished JSON once. - A non-streaming client gets every stage's text, joined with a blank line between stages — a `Respond` with nothing new since the previous boundary contributes nothing (not an echo of the client's own message, not an error). A chain with no `Respond` at all keeps today's convention: the whole chain's last message is the answer. --- ## Durability Durability is a deliberate, per-flow choice — never a silent effect of configuring a store. Name a flow, then make it durable: ```go remember := bb.NewFlow().WithAgent(a).WithId("remember").Durable() ``` `WithId` returns a `NamedFlow`; only a `NamedFlow` has `.Durable()`, so a durable flow always has the id it resumes against — durable-but-anonymous won't compile. A flow **without** `.Durable()` never persists, even with a store (explicit or the in-memory default) configured; the store is just the backend for the flows that opted in. A durable flow checkpoints its sub-flows: on a retry with the same `X-Run-Id`, completed ones replay from their savepoint (a `flow.cached` trace event) instead of re-asking. `.Durable()` takes options: - `bb.ForwardCompatible()` — resume even if the graph changed; by default a changed structure is discarded, not resumed into. - `bb.Retries(n)` - `bb.TTL(d)` - `bb.ResumeOnReregister()` Use `bb.FileStore(dir)` to survive restarts — the in-memory default (`bb.MemStore()`) means durability and triggers work with zero config, but nothing survives a process restart. ### What "durable" promises — and doesn't **At-least-once, not exactly-once.** A durable run that a client retries with the same run id resumes from the last completed flow; a crash in the narrow window before a result is checkpointed re-runs that flow. Side effects that must not double are the author's responsibility (an idempotency key derived from the run), aided but not guaranteed by the engine. This extends to response delivery: a resumed run **re-delivers** every already-delivered `Respond` stage to the new connection — the crashed connection saw nothing, so the client gets the complete answer again, in order. Re-sent *text* is harmless; it is exactly the side effects that durability was already promising not to double. Think of a checkpoint as a save point in a game — the run continues from where it was, not from the top, but "continues" means "at-least-once from that point," not a transactional guarantee across everything the flow touched. ### Testing durability `flow.MockStore` (an in-memory `flow.Store`) is available for durability tests — see the package tests under `internal/flow` for patterns of crashing a run mid-flow and asserting it resumes rather than re-executes. --- ## Triggers & initiative A brain can act on its own, not only per request. **Triggers are flows.** Reaching one *splits the chain*: the flow after it becomes a deferred, durable body that runs later, on its own. ```go // A nightly job, registered outside Serve (runs at startup, then on the cron): nightly := bb.NewFlow().WithAgent(summarize).WithId("nightly"). Next(bb.Notify(text)) bb.Trigger().Next(bb.Every("0 21 * * *")).Next(nightly) // Keep working past the reply ("I'll text you when it's done"): router.Next(capabilities).Next(bb.Respond).Next(bb.Once(when)).Next(followUp) // React to an inbound HTTP call — the reception half of bb.Payload/bb.Metadata: bb.Trigger().Next(bb.Webhook("stripe-payment")).Next(handlePayment) ``` - `bb.Trigger(opts…)` heads a startup chain; a bare `Trigger().Next(f)` is a boot task. `bb.Every(spec)` schedules on a cron; `bb.Once(t)` fires a single time; `bb.Webhook(endpointID)` fires on `POST /v1/hooks/{endpointID}`. - The deferred body of `Every`/`Once` **must** resolve to exactly one id-bearing top-level step (usually just one `WithId` at the end of the chain, e.g. `A.Next(B).Next(C.WithId("job"))`) so it can be resolved after a restart; zero or more than one id-bearing step is a loud error (`flow.ErrTriggerBodyID`) at the moment the trigger is reached, not a logged warning that leaves it dead. `WithId` names only the flow it's called on — same rule as everywhere else. `Webhook`'s body has no such requirement — its endpoint id is the explicit parameter, chosen independently of the body's own `WithId` on purpose: a public URL a third party hardcodes is a different concern from an internal Durable/Select identity, and coupling them means renaming one breaks the other. ### Webhooks A webhook's response depends on whether its body **reaches** a `bb.Respond` — including nested inside `Select`/`One`/`All`/`Group`, not just a top-level step: with one, Serve waits for the run and replies 200 with its content; without one, Serve replies 202 immediately and runs the body in the background — a webhook is often a long-running job, and the caller shouldn't be blocked on it. Unlike `Every`/`Once`, a webhook needs no `Store` at all for this base case (`Durable()` nested inside still no-ops without one, same as everywhere else). **No auth, rate limiting, or body-size cap is applied by this package** — put a reverse proxy/gateway in front before exposing it, and don't rely on the endpoint id as a secret. `bb.Respond` is repeatable inside a webhook body too, same as anywhere else: every stage runs (`Respond` never halts execution), and the 200 answer is every stage's text since the body started, joined — the last `Respond` is what settles the call, not the first. ### Triggers inside a concurrent group A trigger reached inside a concurrent group only commits for real once the group has accepted that branch. In `bb.One(a, b, …)`, a losing member's `Every`/`Once` is discarded, not scheduled anyway — only the eventual winner's trigger sticks. `All`/`Group` need no such gating: every member's contribution is kept, so committing a member's trigger the moment it's reached is already correct there. `Durable()` nested inside a triggered body checkpoints normally once it fires: the fired body's ctx carries a `Store` keyed to that specific firing (so a retry of the same firing resumes past whatever it already completed), the same promise a normal HTTP-served request makes. ### Which drives what Unlike `Every`/`Once`, firing a webhook needs no background worker — it runs inline in the HTTP handler (synchronously or in its own goroutine), so `bb.Handler` alone serves `Webhook` fully, no `bb.Serve`/`bb.Run` worker loop required. The reverse holds for `bb.Run`: it has no HTTP listener at all, so a registered `Webhook` endpoint there has nothing to ever reach it. `Every`/`Once` schedule against whatever `Store` resolves to — an explicit one, or the in-memory default — and run their worker under `bb.Serve` or `bb.Run` (not a bare `bb.Handler`, which only exposes routes). The in-memory default means triggers fire with zero config, but a restart loses every pending schedule; pass `bb.Store(bb.FileStore(dir))` for anything that must survive one. `bb.Run(ctx, ...)` drives triggers and the engine with **no HTTP endpoint at all** — for a brain that only reacts to crons/timers/internal events, never inbound requests. Same startup wiring as `Serve` (validates trigger chains), minus the listener; `Addr` and request-only options are ignored. Store defaults to in-memory here too — for `Run` in particular, that usually means pairing it with `bb.Store(bb.FileStore(dir))`, since an in-memory-only process with no HTTP surface has nothing to show for itself across a restart. ### Payload and metadata An `Every`/`Once` body replays the request context captured when the trigger was scheduled: `turn.Request()` (the protocol params) and `bb.Payload[T](turn)` (arbitrary trigger data, seeded with `bb.WithSeedPayload(x)` or captured from the originating request) both work in the fired body. A `Webhook` body reads `bb.Payload[T](turn)` too, but its Data is the incoming POST body, fresh on every fire — Chat/Req accumulated up to the `Webhook` node (e.g. via a `Trigger`'s `WithSeedChat`) is what replays unchanged across fires, same role `Every`/`Once`'s captured state plays. `bb.Metadata[T](turn)` is `bb.Payload[T]`'s sibling: out-of-band data alongside the payload, kept as its own channel rather than merged into Payload's `T` — a field name matching by accident across a JSON body and, say, an HTTP header would otherwise silently pull from the wrong source. Seed it on `Every`/`Once`/a boot task with `bb.WithSeedMetadata(x)`, same shape as `WithSeedPayload`. A `Webhook` populates it for you: every request header, flattened to `map[string]string` (canonical casing, first value of a repeat wins — `bb.Metadata[T]` is not HTTP-specific, so the multi-value `http.Header` shape stops at the door), e.g. reading a signature header with `bb.Metadata[map[string]string](turn)["X-Signature"]`. Metadata rides through scheduling/replay exactly like Payload — a `Durable()` retry or a cron refire sees the same metadata the original firing captured. ### Loops are re-triggers, not cycles Loops and recursion are re-triggers: a body scheduling its own id again — each iteration a fresh, durable run. There are no cycles in the static `Next` graph — but a *lineage* of re-triggers (a body whose flow itself reaches another trigger, which reaches another, ...) is capped at 8 nested levels. Past the cap, scheduling fails loudly with `flow.ErrTriggerCycle` instead of spinning forever. A plain recurring `Every`/`Once` ticker never counts against this — the engine re-fires the same registered body directly, without passing back through a trigger node. --- ## Telemetry & cost Every model call bb makes is billed by its provider, and bb tells you exactly what that cost — never an estimate. `reply.Usage()` reports what one ask cost: ```go reply, _ := chat.Ask() u := reply.Usage() // Usage{Input, Output, CacheRead, CacheWrite, Reasoning} ``` For a live (streaming) reply this blocks until the stream completes — providers report usage last, exactly as `reply.ToolCalls()` already does. A provider that reports nothing (some self-hosted OpenAI-compatible endpoints never do) yields the zero `Usage`; bb never fills the gap with a guess. `bb.Spent(ctx)` reports the running total for the WHOLE request so far — every flow, every agent, every tool round, summed: ```go func(ctx context.Context, turn *bb.Turn, chat *bb.ModelChat) error { if bb.Spent(ctx).Total() > budget { return turn.Reply("that's enough for now") } ... } ``` It's a snapshot (calls still in flight aren't counted yet) and reads zero outside a served request. ### The usage block is honest, not flattering **The `usage` block bb sends its own clients is the SUM of every upstream call the run made** — a router's cheap pass, a capability agent's real one, every tool round — not a mirror of the client's own prompt size. A brain that made five model calls really did spend all five calls' worth of tokens, and reporting anything less would make bb the one "model" in the world whose own bill is bigger than what it told you. `prompt_tokens` (OpenAI) / `input_tokens` (Anthropic) will therefore usually exceed the size of the prompt the client actually sent — that's honest, not a bug. Two edge cases worth knowing: - **A resumed durable run reports only what THAT run spent.** A flow whose result was replayed from a checkpoint (`flow.cached`) made no model call, so it contributes zero tokens — the truthful answer to "what did this attempt cost", not "what has this conversation cost across every crash and retry". - **`bb.FixedModel` and a bound `Bound(mock)` report zero usage.** No provider was called, so nothing was billed. ### What this is not - **Not a price table.** A model's price changes per provider, per region, per cache-hit class, faster than a library can track it — turn `model.tokens` into cost with a Prometheus/Grafana rule against your own deployment, not code shipped here. - **Not a benchmark harness.** Every number above is dominated by a third party's network latency, so `go test -bench` would only measure the provider's mood — point a real load generator, like `k6` or `vegeta`, at a running brain instead. ### Where the numbers land, automatically With no author action, the same numbers also land as OTel instruments (`model.tokens`, `model.ttft.seconds`, `model.call.seconds`, `request.seconds`, …) — inert until `BIG_BRAIN_TELEMETRY=stdout` or `=otlp` (with `BIG_BRAIN_OTLP_ENDPOINT`) is set, and as a `Usage` on each flow's `flow.end` trace event at `/v1/diagnostics/trace`, always on, for "which flow spent the tokens" without any config at all. Per-flow attribution sums legally over a **sequential** span (tokens sum across time); it is deliberately not attempted for `Group`/`One` members, whose overlapping intervals would make an individual share a lie. Debugging is a byproduct of running the tree, not a separate subsystem to wire up — see [Serving](/reference/serving) for the diagnostics endpoint and trace backends. --- ## Serving ```go h, err := bb.Handler(flow, opts...) // http.Handler for embedding err := bb.Serve(ctx, flow, // or own the listener + shutdown bb.Addr(":8080"), bb.Trace(bb.JSONL(os.Stdout)), // jsonl trace of every flow bb.Store(bb.FileStore(dir)), // durable checkpointing, survives restarts bb.DefaultFlowName("jarvis"), // reported id, default "brain" ) ``` `Serve`/`Handler`/`Run` default `Store` to an in-memory backend (`bb.MemStore()`) when it's not set — durability and triggers work with zero config, but nothing survives a process restart. Pass `bb.FileStore(dir)` (or another persistent backend) once that matters. `Serve`/`Handler` **validate the whole flow at startup** — modelless default agents, unbuildable models, and declared Select exits with no matching member all fail before the port binds. That is the single place wiring errors surface; the other is `Ask` (schema/transport, at runtime). `bb.DefaultFlowName` only labels a flow served **without** a registry name — the `flow` passed straight to `Serve`/`Handler`, or one added via `bb.WithDefaultFlow`. It sets what `/v1/models` and every response's `model` field report for that flow; it never affects routing. A flow named via `bb.WithFlow(f).As("acme/coder")` (below) already reports that name and ignores `DefaultFlowName`. ### Endpoints | Endpoint | Protocol | |---|---| | `POST /v1/chat/completions` | OpenAI, streaming | | `POST /v1/messages` | Anthropic, streaming | | `GET /v1/models` | Both — lists every served flow id | | `GET /v1/diagnostics/trace` | Always-on diagnostics ring | | `POST /v1/hooks/{endpointID}` | A registered `bb.Webhook` | Sampling parameters a client sends (`model`, `temperature`, `max_tokens`, …) are accepted, never an error, and **reach the flow as request context** — not applied automatically. See [Flows, agents & turns](/concepts/flows-agents-turns#request-parameters-are-input-never-an-override) for how a handler is meant to read and act on them. Caller tools and `` blocks pass through untouched the same way; honoring them is the brain's choice. ### Serving several flows One brain can serve many flows, chosen by the request's `model`: ```go bb.WithFlow(chatFlow) // unnamed → the default flow bb.WithFlow(codeFlow).As("acme/coder") // named → picked by model id bb.WithFlow(mathFlow).As("acme/math"). Serve(ctx) // chainable; Serve ends the chain ``` A request naming a registered model routes to that flow; a request naming no or an unknown model gets the default. Which flow is the default is a precedence (highest wins, last-within-rank wins): 1. `bb.Serve(ctx, f)` — an explicit default passed to `Serve`. 2. `bb.WithDefaultFlow(f)` — an explicit default, no name. 3. `bb.WithFlow(f)` — the last unnamed flow. 4. `bb.WithFlow(f).As(name)` — a named flow, default only if nothing unnamed exists. `WithFlow(f).As(name)` names a flow (calling `As` twice is a compile error). A `RegisterFlow` (unnamed) cannot chain another `WithFlow` — a chain holds one default — but a named flow can. `Serve(ctx)` with no default is valid when at least one named flow is registered. This is a **routing convenience for one owner** publishing several named brains from a single binary — not a tenancy model. Flows share the process, the store, and the trust boundary; memory belongs to whichever flow's handlers write it. See the [introduction](/guide/introduction#why-a-library-not-a-platform) for why multi-tenancy is explicitly out of scope. ### Handler first, runner second The engine exposes an `http.Handler` the author can mount anywhere, and a convenience runner (`bb.Serve`) that owns the listener and graceful shutdown. Author-added routes are served either way — mounting the handler yourself still serves the engine's routes plus yours. --- ## The rules The short list — every other page is a slower explanation of one of these. 1. **Agent configures, Turn/ModelChat act.** No `Ask` at build time, no `WithModel` at runtime — the types won't let you. 2. **`turn` is the client, `chat` is the model.** Direction is carried by which handle you touch, never by an overloaded verb. `chat.Ask` asks the model; `turn.Reply` answers the client. 3. **Select ids are strings** (they come from a model). Declare exits with `Selects` to catch typos at startup. 4. **Nothing about tools is implicit.** `Ask` never runs your handlers; `WithTools`/`ForwardTools` apply to one ask; an unanswered call goes to the client and an answered one stays internal. 5. **Errors surface in two places**: `Serve`/`Handler` (wiring, startup) and `Ask` (schema, transport, and a tool whose handler disagrees with its schema — runtime). Builders never error mid-chain. 6. **Pass `ctx` to your I/O** so a cancelled turn cancels your calls. 7. **`bb.Respond` is a stage boundary, repeatable.** Every `Respond` delivers; only the flows after the *last* one are initiative, not part of the answer. --- ## Testing a flow Build a flow, drive a request through the `http.Handler` from `bb.Handler`, and assert on the reply — or unit-test a handler by constructing an agent with `bb.FixedModel(...)`. For structured output, `bb.Extract[T]` gives you the typed value to assert against. See the package tests under `internal/flow` and `internal/serve` for patterns. ### Mocks for every public interface Every package that exports an interface ships a `mock.go` alongside it, with a `Mock` implementation you can inject in place of the real thing — no need to hand-roll a fake per test file. Notably: - `model.Mock` — a `model.Model` that streams scripted `Chunks`/`ToolCalls` or returns a canned `Fail`/`Reject` error, with `Got`/`Seen` for asserting what was asked. - `model.MockSchema` — a `model.Schema` (tool argument schema) that returns itself from `JSONSchema`. - `agent.MockSchema` — an `agent.Schema` (structured-reply schema) with a settable `Err` so both the valid and invalid `Validate` paths are one field away. - `flow.MockStore` — an in-memory `flow.Store` for durability tests. - `flow.MockScheduler` / `flow.MockWebhooks` — record `Defer`/`Register` calls (in `Calls`/`Hooks`) instead of actually scheduling anything, so a test can fire the captured `Run` itself. - `engine.MockStore` / `engine.RecordTracer` — the same idea at the `pkg/engine` layer. Reach for these before writing a local stub: a test-only reimplementation of an interface that already has a mock is duplication, not isolation. --- ## Reference brains Two complete examples ship in the repo, both `pkg/bb`-only — exactly as an external author writes them, no internal shortcuts. ### `cmd/jarvis-demo` — the runnable smart-home brain Chosen because it exercises both differentiators — memory and initiative — with the fewest heavy dependencies. Runs over a self-contained dummy world (sensors, devices, a notification sink), with **no API key required**. - A keyword router into capabilities: talk / remember / recall / house / briefing. - Memory kept across turns via `bb.FileStore`. - A `Group`-based briefing that reads several sensors concurrently. - A `Notify` flow that fires after the reply. - Durable execution and a jsonl trace. ```sh go run ./cmd/jarvis-demo # smart-home brain: world on :8090, brain on :8080 ``` ### `cmd/marvis-demo` — the API "goal post" The program the `pkg/bb` API was designed to make read well — an intent router that classifies each message with a model + typed schema, then `Select`s a capability. If you want to see the smallest correct multi-flow brain, annotated line by line, start here. - An intent-discovery agent with a typed schema (`enum`-constrained intent field). - `Select` dispatches to `talk` / `remember` / `recall` / `list` / `house`. - Demonstrates both tagged and inline model construction side by side. ### Building on these Both are a good starting skeleton to fork for a new brain: copy the `main.go` structure, swap the persona and capability flows, and keep the model-registration and `Serve` wiring as-is until you have a concrete reason to change it.