Skip to content
skyl

Declaring Tools

Three fields, and why the description matters more than you think.

A skyl.Tool is three fields. Two are obvious and one is the single biggest lever on whether the model calls your tool at the right time.

You will learn

  • The three fields and what each is for
  • Why the description should state when to call, not only what it does
  • What Validate checks
  • How many tools is too many

The type#

type Tool struct {
	Name        string
	Description string
	Parameters  map[string]any   // a JSON Schema object
}
type Tool struct {
	Name        string
	Description string
	Parameters  map[string]any   // a JSON Schema object
}

Name#

The identifier the model uses. Validate rejects an empty one with ErrBadRequest; everything else is passed through.

Keep it stable — it appears in ToolCall.Name and, on Gemini, in ToolCall.ID as well, since Gemini issues no call IDs.

Description: state the trigger#

This is where most tool-calling problems actually live.

// Weak: describes the tool.
Description: "Gets weather data."

// Strong: describes when to reach for it.
Description: "Get the current weather for a city. Call this whenever the user asks about weather, temperature, rain, or conditions in a named place. Do not call it for historical weather or forecasts beyond today."
// Weak: describes the tool.
Description: "Gets weather data."

// Strong: describes when to reach for it.
Description: "Get the current weather for a city. Call this whenever the user asks about weather, temperature, rain, or conditions in a named place. Do not call it for historical weather or forecasts beyond today."
Deep diveWhy trigger conditions measurably improve selection

The model is choosing between your tools and answering directly, on every turn. A description that says only what the tool does gives it nothing to discriminate on — so it guesses, and the failure modes are both directions: a tool that never fires, and a tool that fires on every vaguely related question.

Stating the trigger — and, just as usefully, the anti-trigger — turns that guess into a match. "Do not call it for forecasts" removes a whole class of wrong calls that no amount of describing the tool would have prevented.

This is prompt engineering, and skyl does not do it for you. But it is the field where effort pays best.

Parameters: a JSON Schema#

Parameters: map[string]any{
	"type": "object",
	"properties": map[string]any{
		"city": map[string]any{
			"type":        "string",
			"description": "The city name, e.g. \"Kampala\".",
		},
		"units": map[string]any{
			"type": "string",
			"enum": []string{"celsius", "fahrenheit"},
		},
	},
	"required": []string{"city"},
}
Parameters: map[string]any{
	"type": "object",
	"properties": map[string]any{
		"city": map[string]any{
			"type":        "string",
			"description": "The city name, e.g. \"Kampala\".",
		},
		"units": map[string]any{
			"type": "string",
			"enum": []string{"celsius", "fahrenheit"},
		},
	},
	"required": []string{"city"},
}

Per-property description fields matter as much as the tool's own — they are how the model knows what format city expects.

A tool with no parameters is legal — Parameters may be nil — and all four adapters handle it. On Anthropic that required a specific fix, because the SDK's schema struct drops itself when every field is zero and the API rejects a tool without a schema.

What Validate checks#

Only that every tool has a name. skyl does not validate the schema itself:

Deep diveWhy not validate the JSON Schema?

Because JSON Schema is large, versioned, and providers accept different subsets of it. A validator strict enough to be useful would reject schemas that a provider happily accepts — the same failure mode as a curated model list.

Instead, an invalid schema comes back as that provider's own 400, which is more specific than anything skyl could say. The cost is a round trip.

How many tools#

There is no skyl limit. Providers have their own, and more importantly the model gets worse at choosing as the list grows — every tool is tokens in every request, and every tool is another option to discriminate between.

If you have more than a dozen, consider a router: one call that picks a category, then a second with only that category's tools.

Recap

  • Three fields: Name, Description, Parameters.
  • The description should state when to call and when not to — it is the biggest lever.
  • Per-property descriptions matter as much as the tool's own.
  • Keep required a plain []string; Anthropic drops non-string entries silently.
  • Tools with no parameters are legal on all four adapters.
  • Validate checks only that a name is present; schema errors cost a round trip.

Try out some challenges

Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.

Build a schema from a Go struct

Writing map[string]any by hand is tedious and drifts from the struct you unmarshal into. Generate it.

Show hint

Reflection over struct tags is enough for the common case. The important part is that the schema and the target type cannot disagree.

Show solution
goCompiles
// Minimal, but the point stands: one source of truth for the shape.
func schemaFor(v any) map[string]any {
	t := reflect.TypeOf(v)
	props := map[string]any{}
	var required []string

	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
		if name == "" || name == "-" {
			continue
		}
		prop := map[string]any{"type": jsonType(f.Type)}
		if d := f.Tag.Get("desc"); d != "" {
			prop["description"] = d
		}
		props[name] = prop
		if !strings.Contains(f.Tag.Get("json"), "omitempty") {
			required = append(required, name)  // []string, as Anthropic needs
		}
	}
	return map[string]any{"type": "object", "properties": props, "required": required}
}
// Minimal, but the point stands: one source of truth for the shape.
func schemaFor(v any) map[string]any {
	t := reflect.TypeOf(v)
	props := map[string]any{}
	var required []string

	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
		if name == "" || name == "-" {
			continue
		}
		prop := map[string]any{"type": jsonType(f.Type)}
		if d := f.Tag.Get("desc"); d != "" {
			prop["description"] = d
		}
		props[name] = prop
		if !strings.Contains(f.Tag.Get("json"), "omitempty") {
			required = append(required, name)  // []string, as Anthropic needs
		}
	}
	return map[string]any{"type": "object", "properties": props, "required": required}
}

Now schemaFor(WeatherArgs{}) and json.Unmarshal(call.Arguments, &args) can never disagree — which removes the most common tool bug, where the schema says city and the struct expects location.

Edit this page on GitHub