Skip to content

Client.Run

Go · package github.com/muthuishere/toolnexus/golang · SPEC §8 · golang/client.go

func (c *Client) Run(ctx context.Context, prompt string, tk *Toolkit) (RunResult, error)

One turn of the agent loop, run to completion. Run posts prompt to the model, and for every tool call the model asks for it executes the tool against tk, feeds the result back, and repeats — up to MaxTurns — until the model stops calling tools or the loop is capped. It returns a single RunResult once the whole thing is over; there is no partial output along the way.

Reach for Run for request/response work: a CLI command, a backend endpoint, a batch job — any place where you want the finished answer and don’t need to show text as it streams in, and don’t need the exchange remembered for a later turn.

All three examples point CreateClient at a local httptest.Server standing in for the LLM endpoint — no network call, no real API key. Run speaks whatever an OpenAI- or Anthropic-shaped /chat/completions / /v1/messages endpoint speaks; the stub only needs to shape its JSON to match.

1. The smallest useful call — a plain text answer

Section titled “1. The smallest useful call — a plain text answer”

No tools, one round trip: the model answers directly and the loop stops.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"Paris"}}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}`))
}))
defer srv.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key",
})
res, err := client.Run(context.Background(), "capital of France?", tk)
if err != nil {
log.Fatal(err)
}
if res.Text != "Paris" || res.Status != "done" || res.Turns != 1 {
log.Fatalf("unexpected: %+v", res)
}
fmt.Println("ok:", res.Text)
}

2. The realistic case — a tool call in the loop

Section titled “2. The realistic case — a tool call in the loop”

The model asks for add, the loop executes it, feeds the result back, and the model answers on the second turn. This is the shape of nearly every real agent call.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
turn := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
turn++
w.Header().Set("Content-Type", "application/json")
if turn == 1 {
// Turn 1: the model wants to call add(a=2,b=3).
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`))
return
}
// Turn 2: the model has the tool result and answers.
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"5"}}],"usage":{"prompt_tokens":12,"completion_tokens":1,"total_tokens":13}}`))
}))
defer srv.Close()
add := toolnexus.NativeTool(
"add", "add two numbers",
toolnexus.JSONSchema{"type": "object", "properties": map[string]any{
"a": map[string]any{"type": "number"}, "b": map[string]any{"type": "number"},
}},
func(_ context.Context, args map[string]any) (string, error) {
a, _ := args["a"].(float64)
b, _ := args["b"].(float64)
return fmt.Sprintf("%v", a+b), nil
},
)
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{add}})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key",
})
res, err := client.Run(context.Background(), "what is 2+3?", tk)
if err != nil {
log.Fatal(err)
}
if res.Text != "5" || res.Turns != 2 || res.ToolCallCount != 1 {
log.Fatalf("unexpected: %+v", res)
}
if res.ToolCalls[0].Name != "add" || res.ToolCalls[0].Output != "5" {
log.Fatalf("unexpected tool call record: %+v", res.ToolCalls[0])
}
fmt.Println("ok:", res.Text, "in", res.Turns, "turns")
}

3. The full surface — options and the whole RunResult

Section titled “3. The full surface — options and the whole RunResult”

SystemPrompt is prepended to the toolkit’s skills prompt; MaxTurns caps the loop and, when hit mid-tool-call with no final answer, the run comes back loudly as "incomplete" rather than silently "done".

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
// The model NEVER stops calling the tool — used to exercise the MaxTurns cap.
loop := toolnexus.NativeTool(
"ping", "ping",
toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}},
func(_ context.Context, _ map[string]any) (string, error) { return "pong", nil },
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ping","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`))
}))
defer srv.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{loop}, Builtins: false})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL,
Style: toolnexus.StyleOpenAI,
Model: "gpt-4o-mini",
APIKey: "test-key",
SystemPrompt: "Be terse.",
MaxTurns: 2, // hit the cap while the model is still mid tool-call
})
res, err := client.Run(context.Background(), "keep pinging", tk)
if err != nil {
log.Fatal(err)
}
if res.Status != "incomplete" || res.Limit != "maxTurns" {
log.Fatalf("expected incomplete/maxTurns, got status=%q limit=%q", res.Status, res.Limit)
}
if res.Turns != 2 || res.ToolCallCount != 2 || res.Model != "gpt-4o-mini" {
log.Fatalf("unexpected: %+v", res)
}
// Partial work is preserved even on a loud stop.
if len(res.Messages) == 0 || res.Usage.TotalTokens == 0 {
log.Fatalf("expected partial transcript + usage to survive: %+v", res)
}
fmt.Println("ok: stopped at", res.Limit, "after", res.Turns, "turns,", res.ToolCallCount, "tool calls")
}
Field Type What it holds
Text string The model’s final answer text ("" on an incomplete run with no text yet).
Messages []any The full transcript — system/user/assistant/tool messages, provider-native shape.
ToolCalls []ToolCall One record per tool call: Name, Args, Output, IsError, Metadata.
ToolCallCount int len(ToolCalls).
Turns int Number of LLM round trips this run took.
Usage Usage PromptTokens / CompletionTokens / TotalTokens, summed across every turn.
Model string The model used.
Status string "done" normally; "pending" iff a tool suspended with no WaitFor configured (§10); "incomplete" iff MaxTurns was hit while the model was still emitting tool calls.
Limit string Set ("maxTurns") only when Status == "incomplete".
Pending *Request Set only when Status == "pending".
  • CreateClient — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
  • Client.Stream — The streaming loop: text deltas, tool-call events, and suspension events as they happen.
  • Hooks — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.
  • Conversation — Keep a transcript across turns so the model remembers what it already did.