Tools¶
A tool is a function you declare and let the model ask you to call — to fetch
data, run a calculation, or take an action the model cannot perform itself. The
library never executes anything: it turns your Go type into a schema the model
sees, hands back any calls the model makes, and lets you feed the results in. One
round-trip looks like this: the model emits a tool call → you decode and run it
→ you send the result back → the model continues with that result in context.
For example, to answer a weather question the model does not look it up itself —
it emits a get_weather(city=…) call, the caller runs it and sends the result
back, and the model answers from that.
This page covers the two halves of that flow: defining typed tools from Go
structs, then running the request → execute → reply cycle as a loop.
DecodeToolCall and ToolResult are the pieces that connect them.
At a glance¶
| Task | API |
|---|---|
| Define a tool from a struct | NewTool[T] / MustTool[T] → ToolDefinition |
| Attach tools to a request | Context.Tools |
| Read the model's calls back | AssistantMessage.ToolCalls() → []ToolCall |
| Decode a call's arguments | DecodeToolCall[T] |
| Return a result | ToolResult(id, name, text) → ToolResultMessage |
| Validate without a Go type | ValidateToolCall / ValidateToolArguments / ParseToolArguments |
| Force or restrict the choice | StreamOptions.ProtocolOptions |
A ToolDefinition is just Name, Description, and a Parameters JSON Schema.
A ToolCall the model returns carries an ID, a Name, and decoded
Arguments; the ID and Name are what you echo back in the ToolResult.
Typed tools¶
Generate a provider-compatible JSON Schema from a Go struct instead of writing tool parameters by hand. The same type validates, coerces, and decodes the tool call returned by the model.
1. Describe the arguments as a struct. The jsonschema tags become schema
constraints. Fields without omitempty are required. The generated schema is
fully inline and omits document metadata such as $schema, $id, $ref, and
$defs.
type WeatherArgs struct {
City string `json:"city" jsonschema:"description=City name,minLength=1"`
Units string `json:"units,omitempty" jsonschema:"enum=celsius,enum=fahrenheit"`
Days int `json:"days" jsonschema:"minimum=1,maximum=10"`
}
The jsonschema tag understands the constraints the library validates against
the model's returned arguments:
| Constraint | Tag | Applies to |
|---|---|---|
| Required | omit omitempty (add it to make the field optional) |
any |
| Description | description=... |
any |
| Enum | enum=celsius,enum=fahrenheit |
string, number |
| Numeric range | minimum= · maximum= · exclusiveMinimum= · exclusiveMaximum= |
number, integer |
| String length | minLength= · maxLength= |
string |
| Pattern | pattern=^[A-Z] |
string |
| Array length | minItems= · maxItems= |
array |
2. Build the tool from the type and attach it to the request context.
weatherTool := llm.MustTool[WeatherArgs]("get_weather", "Get a weather forecast")
input := llm.Context{
Messages: []llm.Message{
llm.UserText("What's the weather in Shanghai for the next 3 days?"),
},
Tools: []llm.ToolDefinition{weatherTool},
}
3. Send the request and read the calls back. response.ToolCalls() returns
every call the model made; append the assistant message to the history first so
its tool results can follow.
response, err := llm.Complete(ctx, model, input, llm.StreamOptions{})
if err != nil {
log.Fatal(err)
}
messages = append(messages, &response)
4. Decode each call, return a result, and ask again. DecodeToolCall
validates the arguments against the schema and decodes them into WeatherArgs,
so the values are ready to use.
for _, toolCall := range response.ToolCalls() {
arguments, err := llm.DecodeToolCall[WeatherArgs](weatherTool, toolCall)
if err != nil {
log.Fatal(err)
}
result := fmt.Sprintf("%s will be sunny for %d days.", arguments.City, arguments.Days)
messages = append(messages, llm.ToolResult(toolCall.ID, toolCall.Name, result))
}
Sending the tool results back in a second Complete lets the model turn them
into a final answer.
Full program
package main
import (
"context"
"fmt"
"log"
"github.com/ktsoator/or/llm"
_ "github.com/ktsoator/or/llm/openai" // registers the OpenAI-compatible protocol
)
type WeatherArgs struct {
City string `json:"city" jsonschema:"description=City name,minLength=1"`
Units string `json:"units,omitempty" jsonschema:"enum=celsius,enum=fahrenheit"`
Days int `json:"days" jsonschema:"minimum=1,maximum=10"`
}
func main() {
ctx := context.Background()
model := llm.GetModel("deepseek", "deepseek-v4-flash")
weatherTool := llm.MustTool[WeatherArgs](
"get_weather",
"Get a weather forecast",
)
messages := []llm.Message{
llm.UserText("What's the weather in Shanghai for the next 3 days?"),
}
input := llm.Context{
Messages: messages,
Tools: []llm.ToolDefinition{weatherTool},
}
response, err := llm.Complete(ctx, model, input, llm.StreamOptions{})
if err != nil {
log.Fatal(err)
}
messages = append(messages, &response)
toolUsed := false
for _, toolCall := range response.ToolCalls() {
if toolCall.Name != weatherTool.Name {
continue
}
arguments, err := llm.DecodeToolCall[WeatherArgs](weatherTool, toolCall)
if err != nil {
log.Fatal(err)
}
result := fmt.Sprintf(
"%s will be sunny for the next %d days (%s).",
arguments.City,
arguments.Days,
arguments.Units,
)
messages = append(messages, llm.ToolResult(toolCall.ID, toolCall.Name, result))
toolUsed = true
}
if !toolUsed {
log.Fatal("model returned no weather tool call")
}
response, err = llm.Complete(ctx, model, llm.Context{
Messages: messages,
Tools: []llm.ToolDefinition{weatherTool},
}, llm.StreamOptions{})
if err != nil {
log.Fatal(err)
}
fmt.Println(response.Text())
}
MustTool panics when the type cannot produce a valid schema, which suits
tools declared at startup. Use NewTool, which returns an error instead, when a
tool is built dynamically and a failure must be handled rather than crash.
Run the tool loop¶
The example above handles a single round for clarity. A real application loops:
the model may call tools, read the results, then call more tools before it
answers. StopReason tells you which case you are in, so gate the loop on it
rather than on the presence of tool calls alone.
flowchart TD
A(["Complete / Stream"]) --> B{"StopReason?"}
B -->|ToolUse| C["append assistant message"]
C --> D["for each ToolCall:<br/><small>DecodeToolCall → run → ToolResult</small>"]
D --> E["append tool results"]
E --> A
B -->|Stop| F(["done — use Text()"])
B -->|Error / Aborted| G(["stop — do not run tools"])
classDef ok stroke:#16a34a,stroke-width:2px;
classDef bad stroke:#dc2626,stroke-width:2px;
class F ok;
class G bad;
StopReasonToolUse— the model wants tool results. Execute the calls, append each result, and call the model again.StopReasonStop— the model answered. Returnresponse.Text().StopReasonLength— output hit the token cap; the turn is truncated.StopReasonError/StopReasonAborted— the request failed or was cancelled. Never execute tool calls from such a response.
A request may declare several tools in Context.Tools, and one turn may contain
more than one ToolCall. Iterate over ToolCalls() and route by call.Name;
the inner loop below appends one ToolResult per call, in order.
for {
response, err := llm.Complete(ctx, model, llm.Context{
Messages: messages,
Tools: []llm.ToolDefinition{weatherTool},
}, llm.StreamOptions{})
if err != nil {
log.Fatal(err) // a failed response may still carry partial content
}
if response.StopReason != llm.StopReasonToolUse {
fmt.Println(response.Text())
break
}
// The assistant message must precede its tool results in the history.
messages = append(messages, &response)
for _, toolCall := range response.ToolCalls() {
arguments, err := llm.DecodeToolCall[WeatherArgs](weatherTool, toolCall)
if err != nil {
// Return the error to the model so it can correct the call.
result := llm.ToolResult(toolCall.ID, toolCall.Name, err.Error())
result.IsError = true
messages = append(messages, result)
continue
}
// runWeather is your own code that does the work and returns result text.
messages = append(messages, llm.ToolResult(
toolCall.ID, toolCall.Name, runWeather(arguments)))
}
}
Running the tool itself — runWeather in the example — is your application code;
llm does not execute it. The library only hands back the model's calls and
folds each ToolResult into the history, so there is no separate execution step
to document.
Production tool-loop checklist
- Gate on
StopReason, not on the presence of tool calls. Loop while it isStopReasonToolUse; stop onStopReasonStop. - Append the assistant message before its tool results. The order in the
history must be assistant turn, then each
ToolResult. - Match every tool call with a result. Send one
ToolResultper call, so no call is left unanswered in the next request. - On decode failure, return a tool error, don't crash. Set
result.IsError = trueand feed the message back so the model can correct its arguments. - Bound the loop. Cap the number of rounds so a misbehaving model cannot spin forever.
- Inspect diagnostics before side effects. Decline
partialorinvalidarguments before executing a tool that writes or spends. See stream diagnostics.
Validate before executing¶
DecodeToolCall validates arguments against the tool schema and decodes them
into your struct in one step; it is the path most applications use. When you do
not have a Go type for the arguments, validate into a generic map instead:
ValidateToolCall(tools, call)— find the matching tool by name, then validate and coerce; returns the arguments asmap[string]any.ValidateToolArguments(tool, call)— validate against one known tool.ParseToolArguments(raw)— best-effort parse of raw argument JSON with no schema check; pair withParseToolArgumentsModeto learn whether the JSON was strict, repaired, partial, or invalid.
Tool arguments streamed by a provider may be recovered from incomplete JSON.
A safe application declines partial and invalid arguments and returns a tool
error so the model can retry. See
stream diagnostics before
executing tools with side effects.
Protocol-specific tool choice¶
Tool choice retains each protocol's native vocabulary. Supply it through
ProtocolOptions; the client validates that its type matches the selected
model protocol and that a named tool exists in the request context.
OpenAI-compatible Chat Completions uses required and function choices:
options := llm.StreamOptions{
ProtocolOptions: &llm.OpenAICompletionsStreamOptions{
ToolChoice: llm.OpenAIToolChoiceRequired,
// To force one function instead:
// ToolChoice: llm.OpenAIToolChoiceFunction{Name: "get_weather"},
},
}
Anthropic Messages uses any and tool choices:
options := llm.StreamOptions{
ProtocolOptions: &llm.AnthropicStreamOptions{
ToolChoice: llm.AnthropicToolChoiceAny,
// To force one tool instead:
// ToolChoice: llm.AnthropicToolChoiceTool{Name: "get_weather"},
},
}
Both protocols expose Auto and None constants. Any explicit tool choice
requires at least one tool in Context.Tools.
Automating the loop¶
The request → execute → reply loop above is what or/agent
runs for you. Instead of hand-writing the StopReason gating, message
bookkeeping, and dispatch, you give each tool an Execute function and call
Prompt once — the tool's llm.MustTool[T] definition carries over verbatim:
weather := agent.AgentTool{
Definition: llm.MustTool[WeatherArgs]("get_weather", "Get a weather forecast"),
Execute: func(ctx context.Context, callID string, args json.RawMessage,
onUpdate func(agent.ToolResult)) (agent.ToolResult, error) {
// decode args, do the work, return the result
},
}
assistant := agent.New(agent.Options{Model: model, Tools: []agent.AgentTool{weather}})
err := assistant.Prompt(ctx, "What should I pack for Beijing?")
On top of the loop, the agent adds what an application needs around it:
- Streaming events —
Subscribeto text, reasoning, tool, and lifecycle events. - Steering and follow-ups — inject messages mid-run with
Steer/FollowUp. - Cancellation and state —
Aborta run; read aSnapshotof its state. - Per-turn control — swap the model, system prompt, or tools between turns.
Reach for agent when you want these handled; stay with llm when you want full
control of the loop itself. See the agent guide to start.