Flows, agents & turns
Building an agent
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.Messagesis the incoming conversation;turn.Last()is the latest.chat.Add(msgs…)chooses what the nextAsksends;chat.AskWith(msgs…)isAdd+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.ctxis 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:
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:
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:
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:
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:
// 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 answerOnly 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:
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:
capabilities := bb.Select(talk, remember, house).
WithModel(bb.NewModel("cheap")). // default for member agents that set none
WithId("capabilities") // name the whole groupModel 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 for exactly how that ladder resolves, or Routing with Select for multi-capability brains.