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:
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 answerA 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
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 shapeThat 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:
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.
WithToolsapplies 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:
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
Askthat 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.
Resolveis 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,
Resolveruns 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:
turn.Request().Tools() // []bb.Tool the client declared
turn.Request().ToolChoice() // "" (auto), "any", "none", or a tool name
turn.ToolResults() // results the client sent backForward them explicitly, and relay what the model asks for:
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.