CLI Runners
This module provides infrastructure for wrapping CLI-based AI tools (Codex, Claude, Droid, Kimi, etc.) as subagents. Inspired by the Takopi project's reliable subprocess management patterns.
Overview
CLI Runners enable you to:
- Spawn AI CLI tools as subprocesses with proper lifecycle management
- Stream JSONL events from the CLI's output
- Maintain long-lived sessions with resume capability
- Integrate external agents as collaborators in your main agent loop
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ CodexSubagent │
│ (High-level API) │
├─────────────────────────────────────────────────────────────────┤
│ CodexRunner │
│ (Codex-specific event translation) │
├─────────────────────────────────────────────────────────────────┤
│ JsonlRunner │
│ (Generic JSONL subprocess GenServer) │
├─────────────────────────────────────────────────────────────────┤
│ Types │
│ (ResumeToken, Action, StartedEvent, ActionEvent, etc.) │
└─────────────────────────────────────────────────────────────────┘
Quick Start
Basic Usage
alias LemonCliRunners.CodexSubagent
# Start a new Codex session
{:ok, session} = CodexSubagent.start(
prompt: "Create a GenServer that manages a counter with increment/decrement",
cwd: "/path/to/project"
)
# Process events as they stream
for event <- CodexSubagent.events(session) do
case event do
{:started, token} ->
IO.puts("Session: #{token.value}")
{:action, %{kind: :command, title: cmd}, :started, _} ->
IO.puts("Running: #{cmd}")
{:action, %{kind: :file_change, title: title}, :completed, ok: true} ->
IO.puts("Changed: #{title}")
{:completed, answer, _opts} ->
IO.puts("Done: #{answer}")
_ -> :ok
end
end
One-Shot Execution
# Run synchronously and get the answer
answer = CodexSubagent.run!(
prompt: "Explain this error: undefined function foo/2",
cwd: ".",
on_event: &IO.inspect/1
)
IO.puts(answer)
Session Continuation
# Start initial session
{:ok, session1} = CodexSubagent.start(prompt: "Create a User struct", cwd: ".")
_events = CodexSubagent.events(session1) |> Enum.to_list()
# Continue the session
{:ok, session2} = CodexSubagent.continue(session1, "Add validation for email field")
_events = CodexSubagent.events(session2) |> Enum.to_list()
# Or resume later using the token
token = CodexSubagent.resume_token(session2)
{:ok, session3} = CodexSubagent.resume(token, prompt: "Now add a changeset function")
Event Types
Events are normalized into a simple format:
| Event | Description |
|---|---|
{:started, token} | Session began, token can be saved for resume |
{:action, action, :started, opts} | Action began |
{:action, action, :updated, opts} | Action has progress |
{:action, action, :completed, ok: bool} | Action finished |
{:completed, answer, opts} | Session ended |
{:error, reason} | Error occurred |
Action Kinds
| Kind | Description |
|---|---|
:command | Shell command execution |
:tool | MCP tool call |
:file_change | File modifications |
:web_search | Web search |
:note | Informational note |
:turn | Conversation turn |
:warning | Warning message |
Integration as Agent Tool
defmodule MyAgent.Tools do
alias LemonCliRunners.CodexSubagent
alias LemonAgent.Types.{AgentTool, AgentToolResult}
alias LemonAi.Types.TextContent
def codex_tool(cwd) do
%AgentTool{
name: "codex",
description: "Delegate a complex coding task to a Codex subagent",
parameters: %{
"type" => "object",
"properties" => %{
"task" => %{
"type" => "string",
"description" => "The coding task to perform"
}
},
"required" => ["task"]
},
execute: fn _id, %{"task" => task}, _signal, on_update ->
{:ok, session} = CodexSubagent.start(prompt: task, cwd: cwd)
# Stream progress updates
answer = session
|> CodexSubagent.events()
|> Enum.reduce("", fn
{:action, %{title: title}, :completed, ok: true}, acc ->
if on_update do
on_update.(%AgentToolResult{
content: [%TextContent{text: "Completed: #{title}"}]
})
end
acc
{:completed, answer, _}, _acc ->
answer
_, acc ->
acc
end)
%AgentToolResult{
content: [%TextContent{text: answer}],
details: %{
resume_token: CodexSubagent.resume_token(session)
}
}
end
}
end
end
Low-Level API
For more control, use the runner directly:
alias LemonCliRunners.CodexRunner
alias LemonCliRunners.Types.ResumeToken
# Start runner
{:ok, pid} = CodexRunner.start_link(
prompt: "Hello",
cwd: "/path/to/project",
timeout: 300_000
)
# Get event stream
stream = CodexRunner.stream(pid)
# Process raw events
for event <- LemonAgent.EventStream.events(stream) do
case event do
{:cli_event, event} -> handle_cli_event(event)
{:agent_end, _} -> :done
_ -> :ok
end
end
Implementing New Runners
To add support for a new CLI tool (e.g., Claude):
defmodule LemonCliRunners.ClaudeRunner do
use LemonCliRunners.JsonlRunner
alias LemonCliRunners.Types.{EventFactory, ResumeToken}
@impl true
def engine, do: "claude"
@impl true
def build_command(prompt, resume, _state) do
args = ["-p", "--output-format", "stream-json"]
args = case resume do
%ResumeToken{value: session_id} ->
args ++ ["--resume", session_id]
nil ->
args
end
{"claude", args ++ ["--input-format", "text"]}
end
@impl true
def stdin_payload(prompt, _resume, _state), do: String.trim_trailing(prompt) <> "\n"
@impl true
def translate_event(data, state) do
# Convert Claude's JSONL events to CLI runner events
# ... implementation ...
end
@impl true
def handle_exit_error(code, state) do
# Handle non-zero exit
end
@impl true
def handle_stream_end(state) do
# Handle normal exit without completion event
end
end
Files
| File | Description |
|---|---|
types.ex | Core types: ResumeToken, Action, events, EventFactory |
jsonl_runner.ex | Base GenServer for JSONL subprocess runners |
tool_action_helpers.ex | Shared helpers for translating tool calls to action events |
codex_schema.ex | Codex JSONL event parsing |
codex_runner.ex | Codex CLI implementation |
codex_subagent.ex | High-level API for using Codex as subagent |
claude_schema.ex | Claude JSONL event parsing |
claude_runner.ex | Claude CLI implementation |
claude_subagent.ex | High-level API for using Claude as subagent |
droid_schema.ex | Droid JSONL event parsing |
droid_runner.ex | Droid CLI implementation |
droid_subagent.ex | High-level API for using Droid as subagent |
kimi_schema.ex | Kimi JSONL event parsing |
kimi_runner.ex | Kimi CLI implementation |
kimi_subagent.ex | High-level API for using Kimi as subagent |
opencode_schema.ex | Opencode JSONL event parsing |
opencode_runner.ex | Opencode CLI implementation |
opencode_subagent.ex | High-level API for using Opencode as subagent |
pi_schema.ex | Pi Coding Agent JSONL event parsing |
pi_runner.ex | Pi CLI implementation |
pi_subagent.ex | High-level API for using Pi as subagent |
Testing
# Run CLI runner tests
mix test apps/lemon_cli_runners/test/lemon_cli_runners/
Design Notes
Session Locking
When resuming a session, the runner acquires a lock (via ETS) to prevent concurrent execution of the same session. This ensures consistency when multiple callers try to resume the same session.
Graceful Shutdown
Subprocess termination follows a graceful pattern:
- Close stdin to signal end of input
- Wait for process to exit naturally
- On timeout: SIGTERM → wait 2s → SIGKILL
Event Translation
Each runner translates tool-specific events to a unified format:
StartedEvent- Session began with resume tokenActionEvent- Action lifecycle with phase trackingCompletedEvent- Session ended with answer and optional resume
This allows the same UI/progress tracking code to work with any CLI tool.
Adding a New CLI Runner
Implement the LemonCliRunners.JsonlRunner behaviour:
defmodule LemonCliRunners.MyEngineRunner do
use LemonCliRunners.JsonlRunner
alias LemonCliRunners.Types.EventFactory
alias LemonCore.ResumeToken
@engine "myengine"
@impl true
def engine, do: @engine
@impl true
def init_state(_prompt, _resume, cwd, _opts) do
%{factory: EventFactory.new(@engine), last_text: nil}
end
@impl true
def build_command(prompt, resume, _state) do
args = ["--json", "--output-format", "jsonl"]
args = if resume, do: args ++ ["--resume", resume.value], else: args
{"myengine", args ++ ["--", prompt]}
end
@impl true
def translate_event(data, state) do
case data do
%{"type" => "init", "session_id" => sid} ->
token = ResumeToken.new(@engine, sid)
{started, factory} = EventFactory.started(state.factory, token)
{[started], %{state | factory: factory}, [found_session: token]}
%{"type" => "done", "result" => result} ->
{completed, factory} = EventFactory.completed_ok(state.factory, result || "")
{[completed], %{state | factory: factory}, [done: true]}
_ ->
{[], state, []}
end
end
@impl true
def handle_exit_error(exit_code, state) do
{event, factory} = EventFactory.completed_error(state.factory, "failed (rc=#{exit_code})")
{[event], %{state | factory: factory}}
end
@impl true
def handle_stream_end(state) do
{event, factory} = EventFactory.completed_error(state.factory, "ended without result")
{[event], %{state | factory: factory}}
end
end