Single agent operating in an isolated git workspace, with per-turn commits and a completion hook. This is the reference example for gen_agent v0.2 lifecycle hooks -- all four of them fire in sequence on the happy path.
When to reach for this
The agent's work is file-based and needs to be committed incrementally, rolled back cleanly, or eventually turned into a PR. You want workspace isolation (the agent cannot stomp the developer's working tree) and a permanent audit trail (each turn is its own commit with a reproducible message).
This pattern is the foundation for any "real code agent" use case: an agent that actually edits files rather than just describing changes. The lifecycle hooks give you the right seams -- setup on start, prompt shaping per turn, artifact materialization after each turn, cleanup summary on halt -- without the agent's core decision logic having to know about git or files.
What it exercises in gen_agent
All four v0.2 lifecycle hooks in one pattern:
pre_run/1-- creates the temporary workspace (for real use, agit worktree; for this example a freshgit initdirectory). Runs once afterinit_agent, before the first turn. Does not blockstart_agent/2from returning.pre_turn/2-- rewrites the prompt to include turn context and prior state. Demonstrates prompt rewriting: the manager sends a generic "next paragraph" instruction andpre_turnreplaces it with the real grounded prompt.post_turn/3-- writes the response to a file, stages it, commits with a descriptive message, and records the SHA on state. Runs after each turn regardless of whathandle_response/3decided.post_run/1-- prints the branch, commit log, and workspace path when the agent halts cleanly. Does NOT fire on crashes, stop, or supervisor shutdown.
Plus:
- Self-chaining via
{:prompt, text, state}fromhandle_response/3to drive multiple turns without manager input. handle_error/3to halt cleanly on backend failures sopost_runcan still run.
The pattern
One callback module. The manager just starts the agent and inspects the workspace after halt.
defmodule Workspace.Agent do
use GenAgent
defmodule State do
defstruct [
:topic,
:num_turns,
:workspace,
:branch,
:session_id,
turn: 0,
paragraphs: [],
commits: [],
phase: :running
]
end
@impl true
def init_agent(opts) do
state = %State{
topic: Keyword.fetch!(opts, :topic),
num_turns: Keyword.get(opts, :num_turns, 3),
session_id: Keyword.fetch!(opts, :session_id)
}
system = """
You are a focused writer producing a multi-paragraph essay
one paragraph at a time. Write exactly one paragraph. No
preamble. No headings. No meta-commentary.
"""
{:ok, [system: system, max_tokens: Keyword.get(opts, :max_tokens, 200)], state}
end
# ---- Lifecycle hooks ----
@impl true
def pre_run(%State{} = state) do
base = Path.join(System.tmp_dir!(), "workspace-agent")
File.mkdir_p!(base)
workspace = Path.join(base, "session-#{state.session_id}")
File.mkdir_p!(workspace)
branch = "agent/#{state.session_id}"
with {_, 0} <- git(workspace, ["init", "--quiet", "--initial-branch=#{branch}"]),
{_, 0} <- git(workspace, ["config", "user.email", "agent@example.local"]),
{_, 0} <- git(workspace, ["config", "user.name", "Workspace Agent"]),
{_, 0} <- git(workspace, ["commit", "--quiet", "--allow-empty", "-m", "init"]) do
{:ok, %{state | workspace: workspace, branch: branch}}
else
{output, code} -> {:error, {:git_init_failed, code, output}}
end
end
@impl true
def pre_turn(_prompt, %State{} = state) do
next_turn = state.turn + 1
context =
case state.paragraphs do
[] ->
"This is paragraph 1 of #{state.num_turns}."
paragraphs ->
prior =
paragraphs
|> Enum.with_index(1)
|> Enum.map_join("\n\n", fn {p, i} -> "Paragraph #{i}: #{p}" end)
"""
This is paragraph #{next_turn} of #{state.num_turns}.
Previously written:
#{prior}
Now write paragraph #{next_turn}. Do not repeat content.
"""
end
rewritten = """
Topic: #{state.topic}
#{context}
"""
{:ok, rewritten, state}
end
@impl true
def post_turn({:ok, _response}, _ref, %State{} = state) do
case List.last(state.paragraphs) do
nil ->
{:ok, state}
paragraph ->
filename = "paragraph_#{state.turn}.md"
File.write!(Path.join(state.workspace, filename), paragraph <> "\n")
with {_, 0} <- git(state.workspace, ["add", filename]),
{_, 0} <-
git(state.workspace, [
"commit", "--quiet", "-m",
"turn #{state.turn}: paragraph #{state.turn}"
]),
{sha, 0} <- git(state.workspace, ["rev-parse", "--short", "HEAD"]) do
commit = %{turn: state.turn, sha: String.trim(sha), filename: filename}
{:ok, %{state | commits: state.commits ++ [commit]}}
else
_ -> {:ok, state}
end
end
end
def post_turn({:error, _reason}, _ref, state), do: {:ok, state}
@impl true
def post_run(%State{} = state) do
{log, _} = git(state.workspace, ["log", "--oneline"])
IO.puts("\n[workspace] finished #{length(state.commits)} turns")
IO.puts(" workspace: #{state.workspace}")
IO.puts(" branch: #{state.branch}")
IO.puts(" log:\n#{String.trim_trailing(log)}")
:ok
end
# ---- Core callbacks ----
@impl true
def handle_response(_ref, response, %State{} = state) do
paragraph = String.trim(response.text)
new_turn = state.turn + 1
state = %{state | turn: new_turn, paragraphs: state.paragraphs ++ [paragraph]}
if new_turn >= state.num_turns do
{:halt, %{state | phase: :finished}}
else
{:prompt, "next paragraph", state}
end
end
@impl true
def handle_error(_ref, _reason, %State{} = state) do
{:halt, %{state | phase: :failed}}
end
# ---- Git helper ----
defp git(cwd, args) do
System.cmd("git", args, cd: cwd, stderr_to_stdout: true)
end
endUsing it
{:ok, _pid} = GenAgent.start_agent(Workspace.Agent,
name: "essay",
backend: GenAgent.Backends.Anthropic,
topic: "why octopuses are extraordinary",
num_turns: 3,
session_id: System.unique_integer([:positive])
)
# Kick off the first turn. pre_run has already created the
# workspace by the time this returns.
{:ok, _ref} = GenAgent.tell("essay", "begin")
# The agent will self-chain for 3 turns, committing each
# paragraph, then halt. post_run prints the summary.
# After halt, inspect the artifacts:
%{agent_state: %{workspace: workspace, branch: branch, commits: commits}} =
GenAgent.status("essay")
IO.puts("workspace: #{workspace}")
IO.puts("branch: #{branch}")
Enum.each(commits, fn c -> IO.puts(" #{c.sha} #{c.filename}") end)
GenAgent.stop("essay")Lifecycle hook ordering
For one happy-path turn, the callbacks fire in this order:
init_agent
-> pre_run
-> pre_turn (called before each dispatch)
-> (backend call)
-> handle_response OR handle_error
-> post_turn
-> transition (idle / self-chain / halt)
-> post_run (only on clean halt)
-> terminate_agent (on process exit)The important distinction: post_run fires when a callback
returns {:halt, state} -- a clean completion signal.
terminate_agent fires on any termination (crash, stop,
supervisor shutdown). If you want "create a PR on clean
completion but not on crash," post_run is where that goes. If
you want "always clean up the workspace directory no matter
what happens," terminate_agent is where that goes.
Variations
- Real worktrees. For production use, use
git worktree addinstead ofgit init. The workspace shares the object store with the source repo, so the agent branch can be pushed to a remote and turned into a PR. The Workspace helper module (once extracted) hascreate_worktree/3andremove_worktree/2wrapping this. - Tool-use agent. Swap the backend to
gen_agent_claudewithcwd: state.workspaceand the agent gains real file access via Claude's Read/Glob/Grep/Bash tools.post_turnthen commits whatever the LLM actually wrote rather than materializing text from the response. - Per-turn markdown artifacts as review input. Pair this
with Checkpointer: after each turn, halt
in
:awaiting_review, let the manager inspect the committed markdown, then approve/revise. The commits are your review history. - Create a PR on post_run. After the last commit, use the
GitHub API (or
gh pr create) to open a PR from the agent's branch. Put that logic inpost_run/1so it only runs on clean completion, never on crashes. - Cleanup on terminate_agent. Symmetric with the above:
terminate_agent/2removes the worktree directory so interrupted agents don't leave orphans behind. Keep the workspace for inspection ifphase: :finished, tear it down otherwise.