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}
  • :assigns is 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
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.
  • {:ok, value, assigns} for success with state. The assigns map is merged into the session's :assigns so 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.