Skip to main content

Overview

PicoClaw’s tool system is extensible - you can create custom tools to give your agent new capabilities. Tools are Go functions that follow a specific interface and get registered with the ToolRegistry.

Tool Interface

Every tool must implement the Tool interface:
type Tool interface {
    Name() string
    Description() string
    Parameters() map[string]interface{}
    Execute(params map[string]interface{}) (*ToolResult, error)
}

Creating a Custom Tool

Step 1: Define Your Tool Struct

package tools

import (
    "fmt"
    "time"
)

type TimezoneTool struct {}

func NewTimezoneTool() *TimezoneTool {
    return &TimezoneTool{}
}

Step 2: Implement the Interface

func (t *TimezoneTool) Name() string {
    return "get_timezone_time"
}

func (t *TimezoneTool) Description() string {
    return "Get current time in a specific timezone"
}

func (t *TimezoneTool) Parameters() map[string]interface{} {
    return map[string]interface{}{
        "type": "object",
        "properties": map[string]interface{}{
            "timezone": map[string]interface{}{
                "type":        "string",
                "description": "Timezone name (e.g., America/New_York, Asia/Tokyo)",
            },
        },
        "required": []string{"timezone"},
    }
}

func (t *TimezoneTool) Execute(params map[string]interface{}) (*ToolResult, error) {
    timezone, ok := params["timezone"].(string)
    if !ok {
        return nil, fmt.Errorf("timezone parameter required")
    }

    loc, err := time.LoadLocation(timezone)
    if err != nil {
        return NewToolResultError(fmt.Errorf("invalid timezone: %w", err))
    }

    now := time.Now().In(loc)
    result := fmt.Sprintf("Current time in %s: %s", timezone, now.Format(time.RFC1123))

    return NewToolResultSuccess(result), nil
}

Step 3: Register the Tool

In your agent initialization code:
package agent

import (
    "github.com/sipeed/picoclaw/pkg/tools"
)

func NewAgentInstance(...) *AgentInstance {
    // ... existing setup ...

    toolsRegistry := tools.NewToolRegistry()
    
    // Register built-in tools
    toolsRegistry.Register(tools.NewReadFileTool(...))
    toolsRegistry.Register(tools.NewWriteFileTool(...))
    // ... other tools ...

    // Register your custom tool
    toolsRegistry.Register(tools.NewTimezoneTool())

    // ... rest of setup ...
}

Tool Result Types

Tools return *ToolResult with different types:
// Success result
return NewToolResultSuccess("Operation completed successfully"), nil

// Error result
return NewToolResultError(fmt.Errorf("something went wrong")), nil

// Custom result
return &ToolResult{
    Type:    "success",
    Content: "Custom content",
}, nil

Example: API Client Tool

Here’s a more complex example - a tool that calls an external API:
package tools

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

type WeatherTool struct {
    apiKey string
    client *http.Client
}

func NewWeatherTool(apiKey string) *WeatherTool {
    return &WeatherTool{
        apiKey: apiKey,
        client: &http.Client{
            Timeout: 10 * time.Second,
        },
    }
}

func (w *WeatherTool) Name() string {
    return "get_weather"
}

func (w *WeatherTool) Description() string {
    return "Get current weather for a city"
}

func (w *WeatherTool) Parameters() map[string]interface{} {
    return map[string]interface{}{
        "type": "object",
        "properties": map[string]interface{}{
            "city": map[string]interface{}{
                "type":        "string",
                "description": "City name",
            },
            "units": map[string]interface{}{
                "type":        "string",
                "description": "Temperature units (metric or imperial)",
                "enum":        []string{"metric", "imperial"},
                "default":     "metric",
            },
        },
        "required": []string{"city"},
    }
}

func (w *WeatherTool) Execute(params map[string]interface{}) (*ToolResult, error) {
    city, ok := params["city"].(string)
    if !ok {
        return NewToolResultError(fmt.Errorf("city parameter required"))
    }

    units := "metric"
    if u, ok := params["units"].(string); ok {
        units = u
    }

    url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?q=%s&units=%s&appid=%s",
        city, units, w.apiKey)

    resp, err := w.client.Get(url)
    if err != nil {
        return NewToolResultError(fmt.Errorf("API request failed: %w", err))
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return NewToolResultError(fmt.Errorf("API returned status %d", resp.StatusCode))
    }

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return NewToolResultError(fmt.Errorf("failed to read response: %w", err))
    }

    var weather map[string]interface{}
    if err := json.Unmarshal(body, &weather); err != nil {
        return NewToolResultError(fmt.Errorf("failed to parse response: %w", err))
    }

    // Format response
    result := fmt.Sprintf("Weather in %s: %v°%s, %s",
        city,
        weather["main"].(map[string]interface{})["temp"],
        map[string]string{"metric": "C", "imperial": "F"}[units],
        weather["weather"].([]interface{})[0].(map[string]interface{})["description"],
    )

    return NewToolResultSuccess(result), nil
}
Register with API key:
toolsRegistry.Register(tools.NewWeatherTool(cfg.Tools.Weather.APIKey))

Tool Safety

Workspace Restriction

If your tool accesses files, respect workspace restrictions:
func (t *CustomFileTool) Execute(params map[string]interface{}) (*ToolResult, error) {
    path := params["path"].(string)

    // Validate path is within workspace
    if t.restrictToWorkspace {
        if !isWithinWorkspace(path, t.workspace) {
            return NewToolResultError(fmt.Errorf("path outside workspace"))
        }
    }

    // ... proceed with operation ...
}

Input Validation

Always validate input parameters:
func (t *Tool) Execute(params map[string]interface{}) (*ToolResult, error) {
    // Type check
    value, ok := params["param"].(string)
    if !ok {
        return NewToolResultError(fmt.Errorf("param must be a string"))
    }

    // Range check
    if len(value) > 1000 {
        return NewToolResultError(fmt.Errorf("param too long (max 1000 chars)"))
    }

    // Pattern validation
    if !validPattern.MatchString(value) {
        return NewToolResultError(fmt.Errorf("param contains invalid characters"))
    }

    // ... proceed ...
}

Timeout Protection

For long-running operations, use contexts:
import "context"

func (t *Tool) Execute(params map[string]interface{}) (*ToolResult, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    result, err := t.doLongOperation(ctx, params)
    if err != nil {
        return NewToolResultError(err)
    }

    return NewToolResultSuccess(result), nil
}

Configuration

Add tool-specific configuration:
{
  "tools": {
    "weather": {
      "enabled": true,
      "api_key": "your-api-key",
      "default_units": "metric"
    }
  }
}
Access in your tool:
func NewWeatherTool(cfg *config.WeatherConfig) *WeatherTool {
    return &WeatherTool{
        apiKey:       cfg.APIKey,
        defaultUnits: cfg.DefaultUnits,
    }
}

Best Practices

Clear descriptions

Write detailed tool descriptions so the LLM knows when to use your tool

Validate inputs

Always validate and sanitize input parameters

Handle errors gracefully

Return meaningful error messages that help the agent understand what went wrong

Use timeouts

Protect against hanging operations with context timeouts

Respect security

Follow workspace restrictions and safety patterns

Keep it focused

One tool, one clear purpose - don’t create Swiss Army knives

Testing Your Tool

Create unit tests for your custom tool:
package tools

import (
    "testing"
)

func TestTimezoneTool(t *testing.T) {
    tool := NewTimezoneTool()

    // Test valid timezone
    result, err := tool.Execute(map[string]interface{}{
        "timezone": "America/New_York",
    })
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result.Type != "success" {
        t.Errorf("expected success, got %s", result.Type)
    }

    // Test invalid timezone
    result, err = tool.Execute(map[string]interface{}{
        "timezone": "Invalid/Timezone",
    })
    if result.Type != "error" {
        t.Errorf("expected error for invalid timezone")
    }
}

Next Steps

Tool Registry

Learn about the tool registry and how tools are discovered

Built-in Tools

Study the built-in tools for examples

Agent Configuration

Configure tool access and restrictions

MCP Tools

Alternative: Use Model Context Protocol for external tools

Build docs developers (and LLMs) love