This document covers everything about how Yantra's tool system works — how tools are defined, registered, secured, and executed.
A tool is a function the LLM can call. The LLM doesn't execute code directly — it outputs a structured request ("I want to call read_file with path=main.go"), and the runtime executes it on the LLM's behalf.
This is the fundamental mechanism that turns a chatbot into an agent. Without tools, an LLM can only generate text. With tools, it can interact with the world.
Every tool in Yantra implements this interface:
type Tool interface {
Name() string
Description() string
Decl() FunctionDecl
Execute(ctx context.Context, input json.RawMessage, execCtx ToolExecutionContext) (string, error)
SafetyTier() SafetyTier
Timeout() time.Duration
}Let's break down each method:
A unique string identifier like "read_file" or "shell_exec". This is what the LLM uses in its tool call. Must be unique across the registry.
A human-readable sentence explaining what the tool does. This goes to the LLM — it reads this to decide when to use the tool. Good descriptions matter. A vague description means the LLM won't know when to reach for the tool.
Returns the FunctionDecl — the complete package sent to the LLM:
type FunctionDecl struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"` // JSON Schema
}The Parameters field is a JSON Schema object describing the tool's inputs. For example, read_file's schema:
{
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path (relative to workspace or absolute)"},
"offset": {"type": "integer", "description": "Line number to start reading from (1-based, default 1)"},
"limit": {"type": "integer", "description": "Maximum number of lines to read (default 2000)"}
},
"required": ["path"],
"additionalProperties": false
}The LLM reads this schema and generates valid JSON arguments when calling the tool.
The actual implementation. Receives:
ctx— Go context with cancellation and deadline (the registry sets the timeout here)input— raw JSON matching the Parameters schemaexecCtx— workspace directory, session ID, progress channel
Returns a string result (what the LLM sees) or an error.
One of three values:
ReadOnly— no side effects (reading files, listing directories)SideEffecting— changes state (writing files, making HTTP requests)Privileged— potentially dangerous (running shell commands)
The runtime uses these to decide execution strategy. Contiguous ReadOnly tools run in parallel; SideEffecting and Privileged tools run sequentially at their original position in the call list. This preserves model-provided ordering for cross-tool dependencies (e.g., write_file then read_file) while maximizing parallelism where safe.
How long the tool gets before the context is cancelled. Each tool sets its own timeout:
- File operations: 10 seconds
- Shell commands: 60 seconds (builds are slow)
- HTTP requests: 30 seconds
Building JSON Schema by hand is tedious and error-prone. The Schema() function does it declaratively:
Schema(
Prop{Name: "path", Type: TypeString, Description: "File path", Required: true},
Prop{Name: "limit", Type: TypeInteger, Description: "Max lines"},
Prop{Name: "tags", Type: TypeArray, Description: "Tags", Items: &TypeString},
Prop{Name: "mode", Type: TypeString, Description: "Write mode", Enum: []string{"overwrite", "append"}},
)Available types: TypeString, TypeInteger, TypeNumber, TypeBoolean, TypeArray.
For arrays, set Items to the element type. For enums, provide the valid values list.
The output is a json.RawMessage ready to drop into FunctionDecl.Parameters.
The ToolRegistry is the central hub:
registry := tool.NewRegistry(policy)
tool.RegisterBuiltins(registry, config.Tools)Tools register at startup. Duplicate names are rejected — this catches bugs where two tools accidentally use the same name.
registry.Register(NewReadFile()) // ok
registry.Register(NewReadFile()) // error: "read_file" already registered// All tool schemas (for the main agent)
allSchemas := registry.Schemas(nil)
// Filtered schemas (for a specialist agent that only gets file tools)
fileSchemas := registry.Schemas([]string{"read_file", "write_file", "list_files"})This is how per-agent tool filtering works. In the config, an AgentDefinition has a Tools field listing which tools it can use. The registry's Schemas method accepts that list directly.
registry.Execute(ctx, "read_file", '{"path":"main.go"}', execCtx)
│
├── 1. Look up tool by name (fail if not found)
│
├── 2. Policy check
│ policy.CheckExecution(tool, input, execCtx)
│ - For file tools: validate path is inside workspace
│ - For shell: validate command against allow/deny lists
│ - Returns error if blocked
│
├── 3. Emit progress event
│ execCtx.Progress <- ProgressEvent{Kind: "tool_execution", Tool: "read_file"}
│
├── 4. Apply timeout
│ ctx, cancel = context.WithTimeout(ctx, tool.Timeout())
│
├── 5. Execute
│ output, err = tool.Execute(ctx, input, execCtx)
│
└── 6. Truncate output
if len(output) > 128KB: cut at last newline, append "[output truncated]"
LLMs have context limits. If a tool returns a 5MB log file, it would blow the context budget. The registry truncates output at 128KB by default (configurable via SetMaxOutputBytes).
Truncation is line-boundary-aware — it finds the last newline before the limit and cuts there, appending \n... [output truncated]. This avoids sending the LLM a partial line that might confuse it.
The SecurityPolicy interface has one method:
type SecurityPolicy interface {
CheckExecution(t types.Tool, input json.RawMessage, execCtx types.ToolExecutionContext) error
}Return nil to allow, return an error to block. The registry calls this before every tool execution.
The default policy, WorkspacePolicy, enforces three categories of rules.
File tools (read_file, write_file, list_files) must operate within the workspace:
Workspace: /home/user/project
"src/main.go" → /home/user/project/src/main.go ALLOWED
"./test/../src/main.go" → /home/user/project/src/main.go ALLOWED
"../../etc/passwd" → /home/etc/passwd BLOCKED
"/etc/shadow" → /etc/shadow BLOCKED
"/home/user/project/ok" → /home/user/project/ok ALLOWED
The path is cleaned (filepath.Clean resolves . and ..), then checked with strings.HasPrefix against the workspace path. An absolute path is allowed only if it's inside the workspace.
The shell_exec tool extracts the base command name and checks it against two lists:
Default allowlist (~40 commands):
ls, cat, head, tail, grep, find, git, go, node, python, curl, make, docker,
mkdir, cp, mv, touch, tar, jq, tree, ...
Default denylist:
sudo, su, doas, rm, mkfs, fdisk, dd, shutdown, reboot, halt, poweroff, init
The rule: deny always overrides allow. If a command is in both lists, it's blocked.
You can customize via config:
[tools.shell]
allow = ["mycustomtool"] # add to allowlist
deny = ["docker"] # add to denylist
replace_defaults = false # true = start from scratch instead of extending defaults
allow_operators = false # true = permit |, &&, ||, etc.By default, these shell metacharacters are blocked:
| && || ; > >> < $( `
This prevents command chaining that could bypass the command filter. Without this, an LLM could write ls | rm -rf / — ls is allowed, but the pipe chains it with a destructive command.
Set allow_operators = true in config if your use case needs pipes and redirects.
Purpose: Read file contents so the LLM can understand code.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| path | string | yes | — | File path relative to workspace or absolute |
| offset | integer | no | 1 | Start reading from this line (1-based) |
| limit | integer | no | 2000 | Maximum lines to return |
Output format:
1 package main
2
3 import "fmt"
4
5 func main() {
6 fmt.Println("hello")
7 }
Line numbers are right-aligned in a 6-character field, separated by a tab. This format lets the LLM reference specific lines ("the bug is on line 42").
Edge cases:
- Empty file →
"(empty file or offset beyond end of file)" - Offset past end → same empty message
- Binary files → will output garbage (not handled yet)
Purpose: Create or modify files.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| path | string | yes | — | File path |
| content | string | yes | — | Content to write |
| append | boolean | no | false | Append instead of overwrite |
Behavior:
- Creates parent directories automatically (like
mkdir -p) - New files get
0644permissions, directories get0755 - Overwrite mode truncates the file first
- Returns:
"wrote 1234 bytes to src/main.go"or"appended 56 bytes to log.txt"
Purpose: Explore directory structure.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| path | string | yes | — | Directory path |
| recursive | boolean | no | false | Recurse into subdirectories |
| max_depth | integer | no | 3 | Max recursion depth |
Output format (flat):
main.go
go.mod
go.sum
internal/
cmd/
Directories have a trailing /.
Output format (recursive):
main.go
go.mod
internal/
internal/types/
internal/types/config.go
internal/types/tool.go
cmd/
cmd/yantra/
cmd/yantra/main.go
Depth limiting: max_depth=1 shows only immediate children. max_depth=2 goes one level deeper. Default is 3 to prevent huge outputs on deep trees.
Purpose: Run arbitrary commands for builds, tests, git operations, etc.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
| command | string | yes | Shell command to execute |
Execution: Runs via sh -c "<command>" in the workspace directory.
Output format:
exit_code: 0
stdout:
hello world
stderr:
Non-zero exit codes are not treated as errors — the result is still returned to the LLM so it can read error messages and fix the problem. Only actual execution failures (binary not found, context cancelled) return errors.
Security: This is the only Privileged-tier tool. It goes through command allowlist/denylist and operator checks before execution.
Purpose: Fetch data from URLs.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | yes | — | URL to fetch |
| method | string | no | GET | HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD) |
| body | string | no | — | Request body |
| headers | string | no | — | Custom headers as JSON object string |
Output format:
status: 200
{"data": [1, 2, 3]}
Limits: Both request and response bodies are capped at 1MB.
Headers example: {"Authorization": "Bearer xxx", "Content-Type": "application/json"}
Current limitations:
- Returns raw response body (HTML pages come back as raw HTML)
- No HTML-to-markdown conversion (planned for later)
- No cookie handling
- No redirect following configuration (uses Go's default: follows up to 10 redirects)
Purpose: Persist knowledge for future recall across sessions.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
| content | string | yes | The knowledge to store |
| tags | array (string) | no | Optional tags for categorization |
Behavior:
- Calls
mem.Store()with source"user_saved" - If an embedding backend is configured, computes and stores the embedding alongside the content
- Returns:
"Saved to memory (id: <hex>)"
Safety tier: SideEffecting (15s timeout)
Purpose: Search persistent memory using hybrid retrieval (vector + full-text).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| query | string | yes | — | Search query (used for both semantic and keyword matching) |
| top_k | integer | no | 5 | Maximum number of results to return |
Output format:
1. [score: 0.85] Content of the memory chunk
Tags: tag1, tag2
2. [score: 0.72] Another memory chunk
Tags: general
Returns "No matching memories found." when no results match.
Safety tier: ReadOnly (15s timeout)
Note: Both memory tools are conditionally registered — they only appear in the tool list when a MemoryRetrieval instance is provided to RegisterBuiltins. If memory is disabled or the database fails to open, the agent simply doesn't have these tools.
To add a new tool, implement the Tool interface:
package tool
import (
"context"
"encoding/json"
"time"
"github.com/hackertron/Yantra/internal/types"
)
type myTool struct{}
func NewMyTool() types.Tool { return &myTool{} }
func (t *myTool) Name() string { return "my_tool" }
func (t *myTool) Description() string { return "Does something useful" }
func (t *myTool) SafetyTier() types.SafetyTier { return types.ReadOnly }
func (t *myTool) Timeout() time.Duration { return 10 * time.Second }
func (t *myTool) Decl() types.FunctionDecl {
return types.FunctionDecl{
Name: t.Name(),
Description: t.Description(),
Parameters: Schema(
Prop{Name: "query", Type: TypeString, Description: "Search query", Required: true},
),
}
}
func (t *myTool) Execute(ctx context.Context, input json.RawMessage, execCtx types.ToolExecutionContext) (string, error) {
var args struct {
Query string `json:"query"`
}
if err := json.Unmarshal(input, &args); err != nil {
return "", fmt.Errorf("invalid input: %w", err)
}
// Do work here...
return "result", nil
}Then register it:
registry.Register(NewMyTool())The LLM will automatically see it in its tool list and can call it by name.