ADR-0020: Backend Behaviour Architecture
View SourceStatus
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:
- Local/Docker used
Conjure.Executorbehaviour +Conjure.Conversation - Anthropic used
Conjure.Conversation.Anthropic(different pattern) - Session dispatched to different code paths based on execution mode
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()
endAvailable Backends
| Backend | Module | Description | Execution |
|---|---|---|---|
| Local | Conjure.Backend.Local | Bash commands on host | System.cmd |
| Docker | Conjure.Backend.Docker | Bash in container | docker exec |
| Anthropic | Conjure.Backend.Anthropic | Hosted execution | Skills API |
| Native | Conjure.Backend.Native | Elixir modules | Direct 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]
endTool Mapping
Native callbacks map to Claude's tool types:
| Claude Tool | Native Callback | Purpose |
|---|---|---|
bash_tool | execute/2 | Run commands/logic |
view | read/3 | Read resources |
create_file | write/3 | Create resources |
str_replace | modify/4 | Update resources |
Consequences
Positive
- Unified Interface: All backends implement the same behaviour, making them interchangeable
- Type Safety: Native skills benefit from compile-time checks and Elixir's type system
- No Shell Overhead: Native execution has no subprocess/shell overhead
- Application Integration: Native skills can directly access Ecto repos, caches, GenServers
- Pluggable Architecture: Easy to add new backends (e.g., WASM, remote execution)
- Clean Separation: Each backend encapsulates its own conversation loop logic
Negative
- More Modules: Added 6 new modules for the backend abstraction
- Slight Indirection: Session now dispatches through backend modules
- Native Skill Learning Curve: Developers need to learn the NativeSkill behaviour
Neutral
- Existing Code Preserved: Executor behaviour still works for Local/Docker
- 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_nativeUsage 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
endRelated
- ADR-0019: Unified Execution Model
- ADR-0011: Anthropic Executor (Skills API)