The interface
internal/tools/tool.go (paraphrased):
type Tool interface {
Name() string
Description() string
InputSchema() map[string]any
Execute(ctx context.Context, input json.RawMessage) (string, error)
}
Four methods, no lifecycle. Tools are stateless from the loop's perspective — any state the tool needs (a compiled regex cache, an in-process index) is a private field on the concrete type.
Skeleton for a new tool
Let's add a hypothetical http_get tool that fetches a URL and returns its body.
Step 1 — The type
package builtin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/sebastienrousseau/rousseau-agent/internal/tools"
)
// HTTPGetTool fetches a URL over HTTPS and returns the response body.
type HTTPGetTool struct {
Timeout time.Duration
client *http.Client
}
// NewHTTPGetTool constructs an HTTPGetTool. Zero timeout uses 30s.
func NewHTTPGetTool(timeout time.Duration) *HTTPGetTool {
if timeout == 0 {
timeout = 30 * time.Second
}
return &HTTPGetTool{
Timeout: timeout,
client: &http.Client{Timeout: timeout},
}
}
Step 2 — Metadata
// Name satisfies tools.Tool.
func (*HTTPGetTool) Name() string { return "http_get" }
// Description satisfies tools.Tool.
func (*HTTPGetTool) Description() string {
return "Fetch an HTTPS URL and return the response body. Input: url (string). Redirects are followed up to 10 hops. Response is capped at 1 MiB."
}
The description is model-facing. It should read like a short docstring for another engineer — what the tool does, what its inputs mean, what the output shape is.
Step 3 — Input schema
// InputSchema satisfies tools.Tool.
func (*HTTPGetTool) InputSchema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{
"type": "string",
"description": "Absolute HTTPS URL to fetch.",
},
},
"required": []string{"url"},
}
}
Keep the schema strict. Every property gets a description. The required array is enforced by the model's tool-use validator — missing fields cause a tool_use retry, not a runtime error.
Step 4 — Execute
type httpGetInput struct {
URL string `json:"url"`
}
// Execute satisfies tools.Tool.
func (t *HTTPGetTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var in httpGetInput
if err := json.Unmarshal(raw, &in); err != nil {
return "", fmt.Errorf("http_get: parse input: %w", err)
}
if in.URL == "" {
return "", fmt.Errorf("http_get: url is required")
}
// Refuse plaintext HTTP; refuse non-http schemes.
if !strings.HasPrefix(in.URL, "https://") {
return "", fmt.Errorf("http_get: only https:// URLs are permitted")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.URL, nil)
if err != nil {
return "", fmt.Errorf("http_get: build request: %w", err)
}
req.Header.Set("user-agent", "rousseau-agent/http_get")
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("http_get: transport: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("http_get: read body: %w", err)
}
return fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, string(body)), nil
}
// Compile-time interface satisfaction check.
var _ tools.Tool = (*HTTPGetTool)(nil)
Step 5 — Register
Wire it up in internal/cli/chat.go (and every other command that constructs a registry — grep for registry.MustRegister to find them):
registry.MustRegister(builtin.NewHTTPGetTool(30 * time.Second))
Once registered, the tool is available to the model on every turn.
Step 6 — Tests
Follow internal/tools/builtin/read_test.go for the pattern:
func TestHTTPGetTool_Execute_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
// The tool refuses plaintext HTTP; wrap the test server behind httptest.NewTLSServer instead
// for a real integration test, or expose an internal seam that permits `http://` in tests only.
// The skeleton here is illustrative.
}
func TestHTTPGetTool_Execute_RejectsPlaintextHTTP(t *testing.T) {
tool := builtin.NewHTTPGetTool(0)
_, err := tool.Execute(context.Background(), json.RawMessage(`{"url":"http://example.com"}`))
require.Error(t, err)
require.Contains(t, err.Error(), "only https")
}
Step 7 — Approval policy
The tool is now available to the model, subject to the approval policy. Recommend a deny rule in the docs for the default posture:
deny:
- {tool: http_get, match: "\"url\":\"https://(169\\.254|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.)"}
This blocks the tool from calling AWS IMDS or private RFC1918 space — a common ask for HTTP-fetching tools.
Step 8 — Docs
Add a section to content/user-guide/tools.md describing the new tool: schema, semantics, safety notes. Follow the shape of the existing five tools.
Contract details
- Statelessness:
Executemust not carry state between calls that isn't explicitly private to the tool's own fields. Two concurrent turns on two sessions may call the same tool simultaneously. - Context respect:
Executemust honourctxcancellation. Long-running work should periodically checkctx.Err()or route the work through a context-aware library call. - No panics: return errors instead. The agent loop converts an error into a
tool_resultwithIsError: true, which the model can adapt to. - Return shape: the output is a plain string, fed back to the model on the next turn. Include enough structure (e.g. line numbers, status codes) for the model to reason about it.
Custom tools without touching the source
If you don't want to fork rousseau, embed the agent loop in your own binary and register your tools there:
registry := tools.NewRegistry()
registry.MustRegister(builtin.NewReadTool())
registry.MustRegister(builtin.NewWriteTool())
// ...
registry.MustRegister(mypkg.NewMyTool())
ag := agent.New(provider, registry, logger, agent.Options{})
See examples/embed-agent/ in the source tree for a complete embedding example.
Common pitfalls
- Over-broad schema. Requiring only
type: objectgives the model no help. Enumerate every property, describe every field. - Blocking on I/O without a deadline. Always use
NewRequestWithContext, always set anhttp.Client{Timeout: ...}, always honourctx. - Returning too much. The output is fed back to the model in the next turn. A 1 MB response burns tokens; cap it.
- Escaping side effects. A tool that mutates the world should log what it did in the return string so the approver's audit trail is complete.
- Forgetting the compile-time interface check.
var _ tools.Tool = (*MyTool)(nil)at package scope catches interface drift at build time.
Next
- User Guide: Tools — the five built-in tools with schemas.
- User Guide: Approval Policies — how to gate the new tool.
- Testing — the pattern for tool tests.