ADR-0020: Backend Behaviour Architecture

View Source

Status

Accepted

Context

After implementing the unified execution model (ADR-0019) with support for local, Docker, and Anthropic execution modes, we identified an asymmetry in the codebase:

Additionally, we needed to support a new execution mode: Native - Elixir modules that implement a behaviour and execute directly in the BEAM, enabling type-safe, in-process skill execution with full access to application runtime context.

Decision

We will introduce a formal Conjure.Backend behaviour that all execution backends implement, providing a clean, pluggable interface for different execution strategies.

Backend Behaviour

defmodule Conjure.Backend do
  @callback backend_type() :: atom()
  @callback new_session(skills :: term(), opts :: keyword()) :: Session.t()
  @callback chat(Session.t(), String.t(), api_callback(), keyword()) :: chat_result()
end

Available Backends

BackendModuleDescriptionExecution
LocalConjure.Backend.LocalBash commands on hostSystem.cmd
DockerConjure.Backend.DockerBash in containerdocker exec
AnthropicConjure.Backend.AnthropicHosted executionSkills API
NativeConjure.Backend.NativeElixir modulesDirect calls

Native Skill Behaviour

For the Native backend, skills are implemented as Elixir modules:

defmodule Conjure.NativeSkill do
  @callback __skill_info__() :: skill_info()
  @callback execute(String.t(), context()) :: result()
  @callback read(String.t(), context(), keyword()) :: result()
  @callback write(String.t(), String.t(), context()) :: result()
  @callback modify(String.t(), String.t(), String.t(), context()) :: result()

  @optional_callbacks [execute: 2, read: 3, write: 3, modify: 4]
end

Tool Mapping

Native callbacks map to Claude's tool types:

Claude ToolNative CallbackPurpose
bash_toolexecute/2Run commands/logic
viewread/3Read resources
create_filewrite/3Create resources
str_replacemodify/4Update resources

Consequences

Positive

  1. Unified Interface: All backends implement the same behaviour, making them interchangeable
  2. Type Safety: Native skills benefit from compile-time checks and Elixir's type system
  3. No Shell Overhead: Native execution has no subprocess/shell overhead
  4. Application Integration: Native skills can directly access Ecto repos, caches, GenServers
  5. Pluggable Architecture: Easy to add new backends (e.g., WASM, remote execution)
  6. Clean Separation: Each backend encapsulates its own conversation loop logic

Negative

  1. More Modules: Added 6 new modules for the backend abstraction
  2. Slight Indirection: Session now dispatches through backend modules
  3. Native Skill Learning Curve: Developers need to learn the NativeSkill behaviour

Neutral

  1. Existing Code Preserved: Executor behaviour still works for Local/Docker
  2. Backwards Compatible: Session API unchanged for existing users

File Structure

lib/conjure/
 backend.ex                    # Backend behaviour
 backend/
    local.ex                  # Wraps Executor.Local
    docker.ex                 # Wraps Executor.Docker
    anthropic.ex              # Wraps Conversation.Anthropic
    native.ex                 # Native execution
 native_skill.ex               # Native skill behaviour
 session.ex                    # Updated with new_native

Usage Examples

Native Backend

defmodule MyApp.Skills.CacheManager do
  @behaviour Conjure.NativeSkill

  def __skill_info__ do
    %{
      name: "cache-manager",
      description: "Manage application cache",
      allowed_tools: [:execute, :read]
    }
  end

  def execute("clear", _ctx) do
    :ok = MyApp.Cache.clear()
    {:ok, "Cache cleared"}
  end

  def read("stats", _ctx, _opts) do
    {:ok, inspect(MyApp.Cache.stats())}
  end
end

# Usage
session = Conjure.Session.new_native([MyApp.Skills.CacheManager])
{:ok, response, session} = Conjure.Session.chat(session, "Clear the cache", &api_callback/1)

Unified API Across Backends

defmodule MyApp.Agent do
  def chat(message, backend_type, skills) do
    session = case backend_type do
      :local -> Conjure.Session.new_local(skills)
      :docker -> Conjure.Session.new_local(skills, executor: Conjure.Executor.Docker)
      :anthropic -> Conjure.Session.new_anthropic(skills)
      :native -> Conjure.Session.new_native(skills)
    end

    Conjure.Session.chat(session, message, &call_claude/1)
  end
end
  • ADR-0019: Unified Execution Model
  • ADR-0011: Anthropic Executor (Skills API)