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, Grep

You can mix the helpers with extras:

def tools do
  Condukt.Tools.read_only_tools() ++ [MyApp.Tools.Weather]
end

Built-in tools

ToolDescription
Condukt.Tools.ReadRead file contents. Supports images.
Condukt.Tools.BashRun a shell command via bash -c.
Condukt.Tools.CommandRun one trusted executable without shell parsing.
Condukt.Tools.EditSurgical file edits using find and replace.
Condukt.Tools.WriteCreate or overwrite files.
Condukt.Tools.GlobFind files by glob pattern.
Condukt.Tools.GrepSearch 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
end

You 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:

  • 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:

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.

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.

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"}
  ]
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.