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
def tools, do: Condukt.Tools.coding_tools() # Read, Bash, Edit, Write, Glob, Grep
def tools, do: Condukt.Tools.read_only_tools() # Read, Bash, Glob, GrepYou can mix the helpers with extras:
def tools do
Condukt.Tools.read_only_tools() ++ [MyApp.Tools.Weather]
endBuilt-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 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.
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
endYou can also resolve the token through a secret provider and keep the tool definition free of plaintext values:
MyApp.ReviewAgent.start_link(
secrets: [
GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"}
]
)See the Secrets guide for provider-backed configuration and redaction behavior.
Each scoped command tool accepts:
argsis an array of strings passed directly to the executablecwdoverrides the agent's working directory for this calltimeoutcaps execution time in seconds
Defining a custom tool
Implement Condukt.Tool:
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
endThe second argument to call/2 is a context map that includes:
:agentis the agent PID:agent_moduleis the agent module for the session:sandboxis the activeCondukt.Sandboxstruct:cwdis the project working directory (use:sandboxfor any file or command work;:cwdis for resolving project-relative paths that aren't themselves I/O operations):secretscontains resolved session secrets for trusted tools:optsis the keyword list from{Module, opts}:assignsis a map of session-scoped values populated by previous tool calls. See Sharing state across tool calls.
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.
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
endTools 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.
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:
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"}
]
endReturning 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.{:ok, value, assigns}for success with state. Theassignsmap is merged into the session's:assignsso later tools can read it. See the next section.{:error, reason}for failures. The error is reported back to the model so it can recover.
Sharing state across tool calls
Tools in a single agent run often need to refer to facts established by
earlier tools: an account id from a lookup, a parsed config, a session
token. Returning a third element from the success tuple, ExUnit-style,
merges values into the session's assigns map. Subsequent tool calls
read them as context.assigns[:key]:
find_account =
Condukt.tool(
name: "find_account",
description: "Looks up an account by email",
parameters: %{
type: "object",
properties: %{email: %{type: "string"}},
required: ["email"]
},
call: fn %{"email" => email}, _ctx ->
case Accounts.find_by_email(email) do
{:ok, account} ->
{:ok, %{found: true, account: account}, %{found_account_id: account.id}}
:error ->
{:ok, %{found: false}}
end
end
)
store_event =
Condukt.tool(
name: "store_event",
description: "Stores an event on the matched account",
parameters: %{
type: "object",
properties: %{title: %{type: "string"}},
required: ["title"]
},
call: fn %{"title" => title}, ctx ->
case ctx.assigns[:found_account_id] do
nil ->
{:error, "call find_account first"}
id ->
{:ok, Events.create!(account_id: id, title: title)}
end
end
)Returned maps are merged into the session's :assigns last-write-wins,
matching Phoenix.Component.assign/3. Assigns persist for the lifetime
of the session, so they remain visible across multiple Condukt.run/2
calls on the same agent process.
You can also seed assigns when starting a session:
{:ok, agent} =
MyAgent.start_link(assigns: %{tenant_id: "acme"})Within a single batch of tool calls in one assistant message, all tools
see the same start-of-batch snapshot of :assigns; their returned maps
are merged after the batch and visible in the next turn. If the LLM
proposes tools A and B together and B needs A's update, prompt the model
to call them in separate turns.