# Tools
Tools are the things an agent can do beyond generating text. Condukt ships
with a small set of file and shell tools, plus a behaviour for adding your
own.
## Built-in tool sets
```elixir
def tools, do: Condukt.Tools.coding_tools() # Read, Bash, Edit, Write, Glob, Grep
def tools, do: Condukt.Tools.read_only_tools() # Read, Bash, Glob, Grep
```
You can mix the helpers with extras:
```elixir
def tools do
Condukt.Tools.read_only_tools() ++ [MyApp.Tools.Weather]
end
```
## Built-in tools
| Tool | Description |
| ---- | ----------- |
| `Condukt.Tools.Read` | Read file contents. Supports images. |
| `Condukt.Tools.Bash` | Run a shell command via `bash -c`. |
| `Condukt.Tools.Command` | Run one trusted executable without shell parsing. |
| `Condukt.Tools.Edit` | Surgical file edits using find and replace. |
| `Condukt.Tools.Write` | Create or overwrite files. |
| `Condukt.Tools.Glob` | Find files by glob pattern. |
| `Condukt.Tools.Grep` | Search file contents by regex. |
## Sandboxes
Built-in tools that touch the filesystem or spawn processes route every call
through the active `Condukt.Sandbox`. The default sandbox,
`Condukt.Sandbox.Local`, talks to the host filesystem. The
`Condukt.Sandbox.Virtual` sandbox runs against an in-memory virtual
filesystem and a Rust-implemented bash interpreter, with no host process
spawning by default. The same agent definition works with either.
See the [Sandbox guide](sandbox.md) for details, including how to pick a
sandbox at `start_link/1` time and how custom sandboxes plug in.
## Scoped command grants
`Condukt.Tools.Command` is a safer alternative to `Bash` when you want to
expose a single executable without giving the model a full shell. It also
lets you attach trusted environment variables that the model never sees.
Session secrets configured with `:secrets` are merged into that environment.
`Command` does not currently route through the sandbox: it runs the
configured executable directly on the host with the trusted env you provide.
That is intentional. The point of `Command` is the explicit allowlist on the
host side, and it is meant for cases where the host operator wants to grant a
specific tool independently of the agent's general filesystem isolation.
```elixir
defmodule MyApp.ReviewAgent do
use Condukt
@impl true
def tools do
[
Condukt.Tools.Read,
{Condukt.Tools.Command, command: "git"},
{Condukt.Tools.Command,
command: "gh",
env: [GH_TOKEN: System.fetch_env!("GH_TOKEN")]}
]
end
end
```
You can also resolve the token through a secret provider and keep the tool
definition free of plaintext values:
```elixir
MyApp.ReviewAgent.start_link(
secrets: [
GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"}
]
)
```
See the [Secrets guide](secrets.md) for provider-backed configuration and
redaction behavior.
Each scoped command tool accepts:
* `args` is an array of strings passed directly to the executable
* `cwd` overrides the agent's working directory for this call
* `timeout` caps execution time in seconds
## Defining a custom tool
Implement `Condukt.Tool`:
```elixir
defmodule MyApp.Tools.Weather do
use Condukt.Tool
@impl true
def name, do: "get_weather"
@impl true
def description, do: "Gets the current weather for a location"
@impl true
def parameters do
%{
type: "object",
properties: %{
location: %{type: "string", description: "City name"}
},
required: ["location"]
}
end
@impl true
def call(%{"location" => location}, _context) do
case WeatherAPI.get(location) do
{:ok, data} -> {:ok, "Temperature: #{data.temp}F"}
{:error, reason} -> {:error, reason}
end
end
end
```
The second argument to `call/2` is a context map that includes:
* `:agent` is the agent PID
* `:agent_module` is the agent module for the session
* `:sandbox` is the active `Condukt.Sandbox` struct
* `:cwd` is the project working directory (use `:sandbox` for any file or
command work; `:cwd` is for resolving project-relative paths that aren't
themselves I/O operations)
* `:secrets` contains resolved session secrets for trusted tools
* `:opts` is the keyword list from `{Module, opts}`
## Sandbox-aware tools
If your tool reads or writes files, or runs subprocesses, route through the
`Condukt.Sandbox.*` facade rather than calling `File.*`, `System.cmd/3`, or
`MuonTrap.cmd/3` directly. Direct calls bypass the sandbox and break the
ability to swap one in.
```elixir
defmodule MyApp.Tools.LineCount do
use Condukt.Tool
alias Condukt.Sandbox
@impl true
def name, do: "line_count"
@impl true
def description, do: "Counts lines in a file"
@impl true
def parameters do
%{
type: "object",
properties: %{path: %{type: "string"}},
required: ["path"]
}
end
@impl true
def call(%{"path" => path}, %{sandbox: sandbox}) do
case Sandbox.read(sandbox, path) do
{:ok, content} -> {:ok, content |> String.split("\n") |> length()}
{:error, reason} -> {:error, "cannot read #{path}: #{inspect(reason)}"}
end
end
end
```
Tools that touch unrelated systems (HTTP APIs, databases, in-process state)
have nothing to sandbox and can do their I/O directly.
## Inline tools
Use `Condukt.tool/1` for one-off workflows where defining a module would add
more ceremony than value. Inline tools work anywhere a module tool works,
including an agent's `tools/0` callback and anonymous `Condukt.run/2` calls.
```elixir
weather =
Condukt.tool(
name: "weather",
description: "Returns the weather for a city",
parameters: %{
type: "object",
properties: %{city: %{type: "string"}},
required: ["city"]
},
call: fn %{"city" => city}, _context ->
{:ok, "72F in #{city}"}
end
)
{:ok, response} =
Condukt.run("What is the weather in Berlin?",
tools: [weather]
)
```
The callback receives the same context map as module tools. If it touches the
filesystem or runs commands, use `context.sandbox` through `Condukt.Sandbox`.
## Parameterized tools
Tools can be added more than once with different options. The `name/1`,
`description/1`, and `parameters/1` callbacks receive those options:
```elixir
defmodule MyApp.Tools.Database do
use Condukt.Tool
@impl true
def name(opts), do: "query_#{opts[:table]}"
@impl true
def description(opts), do: "Query the #{opts[:table]} table"
@impl true
def parameters(_opts) do
%{type: "object", properties: %{q: %{type: "string"}}, required: ["q"]}
end
@impl true
def call(args, context) do
table = context.opts[:table]
{:ok, MyApp.Repo.query!(table, args["q"])}
end
end
# In the agent:
def tools do
[
{MyApp.Tools.Database, table: "users"},
{MyApp.Tools.Database, table: "orders"}
]
end
```
## Returning results
`call/2` should return:
* `{:ok, value}` for success. Strings, maps, and lists are all fine. Non
binary values are JSON encoded before being sent to the LLM.
* `{:error, reason}` for failures. The error is reported back to the model
so it can recover.