Agent sessions often need credentials to do useful work: a GitHub token for gh, an API key for a deploy tool, or a database URL for a local smoke test. The unsafe version is to paste those values into the prompt or check them into an .env file the agent can read. Condukt's secrets API keeps the declaration in trusted code and exposes the resolved values only to tool execution environments.

How other agents handle this

Most agent runtimes have converged on one of a few patterns:

  • CLI agents such as Claude Code and Aider read credentials from environment variables, .env files, or configuration files.
  • Cloud agents such as GitHub Copilot coding agent prepare an ephemeral development environment and let users attach GitHub Actions variables or secrets to that environment.
  • Secret managers such as 1Password avoid plaintext files by resolving secret references at runtime. op run is the canonical example: it makes secrets available as environment variables only for the subprocess it starts.
  • MCP authorization is moving toward OAuth-based delegated access for remote tools. That is the right shape for tools that represent SaaS APIs, but it does not replace local tool credentials like GH_TOKEN.

Condukt follows the same separation of concerns: secrets are resolved by trusted host code, scoped to a session, injected into tool subprocess environments, and kept out of model context.

Configuring secrets

Pass :secrets at start_link/1, return it from an agent module's secrets/0 callback, or configure it through config :condukt, :secrets. Keys are the environment variable names exposed to command tools.

defmodule MyApp.ReviewAgent do
  use Condukt

  @impl true
  def tools do
    [
      Condukt.Tools.Read,
      {Condukt.Tools.Command, command: "gh"}
    ]
  end

  @impl true
  def secrets do
    [
      GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"}
    ]
  end
end

The same declaration can be provided per session:

{:ok, agent} =
  MyApp.ReviewAgent.start_link(
    secrets: [
      GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"},
      DATABASE_URL: {:env, "DATABASE_URL"}
    ]
  )

Built-in provider aliases are:

AliasProviderPurpose
:one_password or :opCondukt.Secrets.Providers.OnePasswordResolves a 1Password secret reference with op read.
:envCondukt.Secrets.Providers.EnvCopies a value from the host process environment.
:staticCondukt.Secrets.Providers.StaticUses a trusted plaintext value. Prefer this for tests.

Later declarations for the same environment variable replace earlier ones.

1Password

The 1Password provider shells out to op read <ref> while the session starts. Authenticate op first, or start Condukt with an OP_SERVICE_ACCOUNT_TOKEN that is scoped to the vaults the agent needs.

{:ok, agent} =
  MyApp.CodingAgent.start_link(
    secrets: [
      GH_TOKEN: {:one_password, "op://Engineering/GitHub/token"},
      STRIPE_API_KEY:
        {Condukt.Secrets.Providers.OnePassword,
         ref: "op://Engineering/Stripe/api-key",
         account: "acme"}
    ]
  )

Secret references stay in code. The plaintext value is loaded into the BEAM process at session initialization and then passed to tool subprocesses as an environment variable.

Tool execution

Condukt.Tools.Bash passes session secrets through Condukt.Sandbox.exec/3 as environment variables:

Condukt.run(agent, "Run the local smoke test that needs DATABASE_URL")

Condukt.Tools.Command merges session secrets with the trusted :env values configured on the tool. Session secrets win if both define the same variable.

def tools do
  [
    {Condukt.Tools.Command, command: "gh", env: [GH_HOST: "github.com"]}
  ]
end

The model cannot add or change environment variables through tool arguments. It can only invoke the tools you configured.

Auditing access

Condukt emits value-free telemetry for secret resolution and access:

  • [:condukt, :secrets, :resolve] when a session resolves secrets.
  • [:condukt, :secrets, :access] when a tool receives resolved secrets.

Both events include count as a measurement and :names metadata with environment variable names such as ["GH_TOKEN"]. Access events also include :tool, and include :tool_call_id when the access comes from a concrete provider-returned tool call. Secret values are never included in measurements or metadata.

Attach a handler if you want an audit trail:

:telemetry.attach(
  "secret-access-audit",
  [:condukt, :secrets, :access],
  fn _event, measurements, metadata, _config ->
    Logger.info("agent secret access",
      count: measurements.count,
      agent: inspect(metadata.agent),
      tool: metadata.tool,
      names: metadata.names
    )
  end,
  nil
)

Custom providers

Implement Condukt.SecretProvider when your secrets live somewhere else:

defmodule MyApp.Secrets.Vault do
  @behaviour Condukt.SecretProvider

  @impl true
  def load(opts) do
    MyApp.Vault.read(Keyword.fetch!(opts, :path))
  end
end

{:ok, agent} =
  MyApp.Agent.start_link(
    secrets: [
      INTERNAL_TOKEN: {MyApp.Secrets.Vault, path: "agents/internal-token"}
    ]
  )

load/1 returns {:ok, value} or {:error, reason}. If any secret fails to load, the session fails to start.

Redaction and persistence

Resolved secrets are not added to:

  • The system prompt
  • User messages
  • LLM request options
  • Session store snapshots

If a tool prints a resolved secret, Condukt exact-match redacts the value from the tool result before it is stored in history, streamed to subscribers, or sent back to the model:

[REDACTED:GH_TOKEN]

Values shorter than four bytes are not redacted because replacing tiny strings causes too many false positives.

Under the hood, resolved session secrets become a Condukt.Redactors.Secrets spec and are composed with the session's configured :redactor. Secret redaction runs first so custom redactors cannot transform a secret before the exact-match replacement has a chance to run.

Redaction is a safety layer, not a permission model. A tool subprocess that receives GH_TOKEN can use it. Scope tokens and 1Password service accounts to the smallest set of resources that the session needs.