Current section
Files
Jump to
Current section
Files
lib/cia.ex
defmodule CIA do
@moduledoc """
The CIA (Central Intelligence Agent) is an opinionated library for managing
background agents from an Elixir app.
CIA takes the position that a background agent always consists of:
* the sandbox: where that agent is running
* the workspace: what filesystem scope that work should happen in
* the harness: what agent implementation is running
And can be interacted with by managing threads and turns.
## Getting Started
To create an agent, you must configure a sandbox, a workspace, and a harness:
key = System.fetch_env!("OPENAI_API_KEY")
config =
CIA.new()
|> CIA.sandbox(:local)
|> CIA.workspace(:directory, root: "/tmp/cia-demo")
|> CIA.harness(:codex, auth: {:api_key, key})
Configurations are data-only until the agent is actually started:
{:ok, agent} = CIA.start(plan)
After an agent has been started, you can create threads and submit turns:
{:ok, thread} = CIA.thread(agent, cwd: "/tmp/cia-demo")
{:ok, _turn} = CIA.turn(agent, thread, "Create a README that explains the project.")
## Managing Sandbox and Workspace Lifecycles
CIA treats sandbox and workspace management as explicit concerns instead of
implicitly tying them to the agent.
In practice, CIA supports 3 lifecycle modes for both sandboxes and workspaces:
* `:ephemeral` - sandbox or workspace is created and destroyed with the agent
* `:durable` - sandbox or workspace is created, but persists after the agent dies
* `:attached` - sandboxes or workspace is assumed to exist before the agent starts
and after the agent dies
## Using Lifecycle Hooks
It is common to want to perform some setup work before starting an agent. CIA
exposes lifecycle hooks which you can use to perform setup work, start filesystem
watches, or checkpoint sandbox state after some work has been done:
plan =
CIA.new()
|> CIA.sandbox(:local, lifecycle: :ephemeral)
|> CIA.workspace(:directory, root: "/tmp/cia-demo")
|> CIA.before_start(fn %{sandbox: sandbox} ->
with {_, 0} <- CIA.Sandbox.cmd(sandbox, "mkdir", ["-p", "/tmp/cia-demo"]) do
:ok
end
end)
Supported hooks are `before_start`, `after_start`, `before_stop`, `after_stop`.
These hooks are relative to the *harness process*, meaning they always run after
the sandbox and workspace have been initialized.
Hooks forward user-managed state if returned from within the hook:
plan =
CIA.new()
|> CIA.sandbox(:local)
|> CIA.workspace(:directory)
|> CIA.before_start(fn %{sandbox: sandbox, state: state} ->
tmpdir = "/tmp/dir_#{System.unique_integer([:positive, :monotonic])}"
with {_, 0} <- CIA.Sandbox.cmd(sandbox, "mkdir", ["-p", tmpdir]) do
{:ok, Map.put(state, :tmpdir, tmpdir)}
end
end)
|> CIA.before_stop(fn %{state: %{tmpdir: tmpdir}} ->
with {_, 0} <- CIA.Sandbox.cmd(sandbox, "rm", ["-rf", tmpdir]) do
:ok
end
end)
## Streaming Agent Events
CIA allows you to subscribe to event streams emitted by running agents.
These event streams consist of normalized events for thread lifecycle, turn
lifecycle, sandbox watch activity, and interactive harness requests. Raw
harness events are also available for adapter-specific integrations.
Events have the following shape:
{:cia, agent, event}
You can subscribe to all or a filtered subset of agent events:
:ok = CIA.subscribe(agent, self(), events: [:thread, :turn, :request, :raw])
receive do
{:cia, ^agent, {:turn, :started, %{turn_id: turn_id}}} ->
IO.puts("turn started: \#{turn_id}")
{:cia, ^agent, {:request, :approval, request}} ->
IO.inspect(request, label: "approval needed")
{:cia, ^agent, {:harness, :codex, payload}} ->
IO.inspect(payload, label: "raw codex event")
end
If you subscribe to events within an agent lifecycle hook, they are owned and forwarded
through the managing agent process by default. For example, if you start a sandbox
filesystem watch inside `before_start`, you will receive those messages through an
agent subscription:
plan =
CIA.new()
|> CIA.sandbox(:local)
|> CIA.workspace(:directory, root: "/tmp/cia-demo")
|> CIA.before_start(fn %{sandbox: sandbox} ->
case CIA.Sandbox.watch(sandbox, ["/tmp/cia-demo"], recursive: true) do
{:ok, _watch} -> :ok
{:error, reason} -> {:error, reason}
end
end)
|> CIA.harness(:codex, auth: {:api_key, key})
{:ok, agent} = CIA.start(plan)
:ok = CIA.subscribe(agent, self(), events: [:sandbox])
Once that watch exists, CIA forwards sandbox watch activity as normalized
agent events:
receive do
{:cia, ^agent, {:sandbox, :watch, watch_id, :ready}} ->
IO.puts("sandbox watch ready: \#{watch_id}")
{:cia, ^agent, {:sandbox, :watch, watch_id, {:event, %{type: :write, path: path}}}} ->
IO.puts("sandbox watch \#{watch_id}: wrote \#{path}")
end
## Resolving Input Requests
Some harness actions are interactive. For example, a harness may ask for
approval before running a command or ask for additional user input while a
turn is in progress.
CIA normalizes those requests into events and lets the application answer
them with `resolve/3`.
receive do
{:cia, ^agent, {:request, :approval, %{id: request_id}}} ->
:ok = CIA.resolve(agent, request_id, :approve_for_session)
{:cia, ^agent, {:request, :user_input, %{id: request_id}}} ->
:ok = CIA.resolve(agent, request_id, {:input, "Continue with the migration."})
end
The caller can log, audit, defer, or deny requests without needing direct access
to harness-specific transport details.
## Customizing a Harness
Basic harness configuration happens via `CIA.harness/3`. CIA also supports more
involved customizations:
* `CIA.mcp/3` - configure/attach MCPs to the agent harness
* `CIA.tool/2` - configure tool-policies for the agent harness
For example, you can configure custom MCPs in this way:
plan =
CIA.new()
|> CIA.harness(:codex, auth: {:api_key, key})
|> CIA.mcp(:docs,
transport: :http,
url: "https://mcp.example.com",
headers: %{"Authorization" => "Bearer ..."}
)
And configure custom tool policies:
CIA.tool(plan, allow: [{:mcp, :docs, :all}], approval: :never)
## Extending CIA
CIA is designed to normalize the core runtime model while still allowing
application-specific providers and harness adapters via:
* custom sandbox providers implementing `CIA.Sandbox`
* custom harness adapters implementing `CIA.Harness`
* custom workspace adapters implementing `CIA.Workspace`
"""
alias CIA.{Agent, Plan, Sandbox, Thread, Workspace}
alias CIA.Agent.Server
@hook_names [:before_start, :after_start, :before_stop, :after_stop]
@doc """
Creates a new CIA agent configuration.
Agents are data-only "plans" until started with `CIA.start/2`.
"""
def new do
Plan.new()
end
@doc """
Configures the given plan to use a sandbox with the given `provider`.
Provider must be one of the supported "shortcut" atoms:
* `:local` - to simply use the local machine
* `:sprite` - to use a [Sprite](https://sprites.dev)
Or a module which implements the `CIA.Sandbox` behaviour.
## Options
* `:name` - the name of the sandbox, typically handled by the provider.
* `:lifecycle` - the lifecycle of the sandbox that the running agent
is attached to. One of `:ephemeral`, `:durable`, or `:attached`.
See [Sandbox Lifecycles](#) for more information.
All other options are forwared to the provider, and should be assumed to
be provider-specific.
"""
def sandbox(%Plan{} = plan, provider, opts \\ [])
when is_atom(provider) and is_list(opts) do
Plan.put_sandbox(plan, Keyword.put(opts, :provider, provider))
end
@doc """
Adds workspace configuration to a pipeable CIA configuration.
The first argument selects the workspace kind. All remaining
workspace configuration belongs here, including root paths,
names, and identifiers.
"""
def workspace(%Plan{} = plan, kind, opts \\ [])
when is_atom(kind) and is_list(opts) do
Plan.put_workspace(plan, Keyword.put(opts, :kind, kind))
end
@doc """
Adds an agent lifecycle hook to a pipeable CIA configuration.
Supported hook names are:
- `:before_start`
- `:after_start`
- `:before_stop`
- `:after_stop`
Hook callbacks are unary functions that receive a context map. Hooks may
return either `:ok` or `{:ok, state}` where `state` is a user-defined map
persisted for the lifetime of the agent and threaded through later hook
contexts. Invalid return values from `before_*` hooks abort that agent
operation. `after_*` hooks are observational and receive the final `:result`
for the attempted operation.
These hooks are agent-scoped. `before_start/2` and `after_start/2` are
relative to the CIA agent lifecycle, not sandbox lifecycle. `before_start/2`
receives the live sandbox runtime because sandbox provisioning happens before
the agent is considered started. Hook contexts also include the current
user-defined `:state` map.
"""
def hook(%Plan{} = plan, hook_name, fun) when is_atom(hook_name) and is_function(fun, 1) do
Plan.put_hook(plan, hook_name, fun)
end
for hook_name <- @hook_names do
@doc "Adds a `#{hook_name}` agent lifecycle hook to a pipeable CIA configuration."
def unquote(hook_name)(%Plan{} = plan, fun) when is_function(fun, 1) do
hook(plan, unquote(hook_name), fun)
end
end
@doc """
Adds harness configuration to a pipeable CIA configuration.
The first argument selects the harness implementation. This configuration is
stored on the returned builder state and does not start a live agent on its
own. All remaining harness configuration belongs here,
including harness, auth, instructions, names, and identifiers.
"""
def harness(%Plan{} = plan, harness, opts \\ [])
when is_atom(harness) and is_list(opts) do
Plan.put_harness(plan, Keyword.put(opts, :harness, harness))
end
@doc """
Adds an MCP server declaration to the plan.
`CIA.mcp/3` is additive and upserts by server id. MCP declarations may appear
before or after `CIA.harness/3`. Once a harness is configured, CIA compiles
the accumulated MCP declarations into the runtime harness configuration.
"""
def mcp(%Plan{} = plan, id, opts \\ [])
when (is_atom(id) or is_binary(id)) and is_list(opts) do
Plan.put_mcp(plan, id, opts)
end
@doc """
Adds normalized tool policy to the plan.
Multiple `tool/2` calls accumulate allow/deny rules. Singleton values such as
approval policy use the last declared value. Tool policy may be declared
before or after `CIA.harness/3`; CIA compiles the accumulated policy into the
runtime harness configuration.
"""
def tool(%Plan{} = plan, opts) when is_list(opts) do
Plan.put_tool(plan, opts)
end
@doc """
Starts a managed agent process.
The returned handle is a `%CIA.Agent{}`. `start/1` consumes configuration
built with the pipeable `sandbox/3`, `workspace/3`, and `harness/3` helpers.
Configuration belongs on that builder. `start/1` executes it.
By default, the agent process is started directly. To start it under your own
supervisor, pass `supervisor: MyApp.CIAAgentSupervisor`.
"""
def start(%Plan{} = plan, opts \\ []) when is_list(opts) do
with {:ok, start_opts} <- plan_start_opts(plan),
{:ok, pid} <- start_agent(start_opts, Keyword.get(opts, :supervisor)) do
{:ok, Server.agent(pid)}
end
end
defp start_agent(opts, nil), do: Server.start_link(opts)
defp start_agent(opts, supervisor),
do: DynamicSupervisor.start_child(supervisor, {Server, opts})
defp plan_start_opts(%Plan{} = plan) do
with :ok <- validate_harness_config(plan.harness),
{:ok, sandbox} <- plan_sandbox(plan),
{:ok, workspace} <- plan_workspace(plan, sandbox) do
{:ok,
[
harness: plan.harness,
sandbox: sandbox,
workspace: workspace,
hooks: plan.hooks
]}
end
end
@doc """
Stops a managed agent process.
Stopping an already-exited or unknown agent is treated as a successful no-op.
Stopping an agent tears down the harness session and then asks the sandbox to
clean up its runtime resources.
"""
def stop(%Agent{pid: pid}, timeout \\ :infinity) do
case pid do
nil -> :ok
pid -> Server.stop(pid, timeout)
end
end
@doc """
Subscribes a process to agent events.
If no subscriber PID is provided, the calling process is subscribed.
Subscribers receive messages in the form:
{:cia, %CIA.Agent{}, event}
CIA emits normalized events for requests, threads, turns, and sandbox watch
activity, while still forwarding raw harness events for compatibility:
{:cia, agent, {:request, :approval, payload}}
{:cia, agent, {:request, :user_input, payload}}
{:cia, agent, {:request, :resolved, payload}}
{:cia, agent, {:thread, :started, payload}}
{:cia, agent, {:turn, :status, payload}}
{:cia, agent, {:sandbox, :watch, watch_id, payload}}
{:cia, agent, {:harness, :codex, payload}}
To scope delivery, pass `events: [...]` with any of:
- `:thread`
- `:turn`
- `:request`
- `:sandbox`
- `:raw`
Subscribers are monitored and automatically removed when the subscriber
process exits.
"""
def subscribe(%Agent{pid: pid}, subscriber \\ self(), opts \\ [])
when is_pid(pid) and is_pid(subscriber) and is_list(opts) do
Server.subscribe(pid, subscriber, opts)
end
@doc """
Resolves a pending normalized harness request.
Current normalized decisions are:
- `:approve`
- `:approve_for_session`
- `:deny`
- `:cancel`
- `{:input, value}`
"""
def resolve(%Agent{pid: pid}, request_id, decision) when is_pid(pid) do
Server.resolve(pid, request_id, decision)
end
@doc """
Creates a new thread on an agent.
When creating a new thread with keyword options, the current supported keys
are:
- `:cwd`
- `:model`
- `:system_prompt`
- `:metadata`
`:metadata` is stored by CIA on the returned `%CIA.Thread{}`. The remaining
options are currently forwarded to the active harness. `:system_prompt` is a
thread-scoped inline override, distinct from harness-level `:instructions`
configured through `CIA.harness/3`.
"""
def thread(%Agent{pid: pid}, opts) when is_pid(pid) and is_list(opts) do
Server.start_thread(pid, opts)
end
@doc """
Submits a turn to a thread.
The thread must be provided as a `%CIA.Thread{}` handle returned by CIA.
The returned `%CIA.Turn{}` reflects CIA's local runtime view. In the current
implementation, turns are marked `:running` when submitted and may later emit
additional harness events through `subscribe/2`.
"""
def turn(%Agent{pid: pid}, %Thread{} = thread, input, opts \\ [])
when is_pid(pid) and is_list(opts) do
Server.submit_turn(pid, thread, input, opts)
end
@doc """
Sends additional input to a running turn.
`turn_or_id` may be a `%CIA.Turn{}` or a known turn identifier.
This is intended for live turn steering while the turn is still running.
"""
def steer(%Agent{pid: pid}, turn_or_id, input, opts \\ []) when is_pid(pid) and is_list(opts) do
Server.steer_turn(pid, turn_or_id, input, opts)
end
@doc """
Cancels a running turn.
`turn_or_id` may be a `%CIA.Turn{}` or a known turn identifier.
On success, CIA updates its in-memory turn status to `:cancelled` and moves
the owning thread back to `:active`.
"""
def cancel(%Agent{pid: pid}, turn_or_id) when is_pid(pid) do
Server.cancel_turn(pid, turn_or_id)
end
defp plan_sandbox(%Plan{sandbox: nil}), do: {:error, {:missing_option, :sandbox}}
defp plan_sandbox(%Plan{sandbox: sandbox_config}) when is_map(sandbox_config) do
sandbox_config
|> Map.to_list()
|> Sandbox.new()
end
defp plan_workspace(%Plan{workspace: nil}, _sandbox),
do: {:error, {:missing_option, :workspace}}
defp plan_workspace(%Plan{workspace: workspace_config}, %Sandbox{} = sandbox)
when is_map(workspace_config) do
workspace_config
|> Map.to_list()
|> then(&Workspace.new(sandbox, &1))
end
defp validate_harness_config(nil), do: :ok
defp validate_harness_config(%CIA.Harness{config: config}) do
if Map.has_key?(config, :cwd) or Map.has_key?(config, "cwd") do
{:error, {:invalid_option, {:harness, :cwd}}}
else
:ok
end
end
end