# Workspace
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, a `git worktree`; for this example a fresh `git init`
directory). Runs once after `init_agent`, before the first
turn. Does not block `start_agent/2` from 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 and
`pre_turn` replaces 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 what
`handle_response/3` decided.
- **`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}` from
`handle_response/3` to drive multiple turns without manager
input.
- **`handle_error/3`** to halt cleanly on backend failures so
`post_run` can still run.
## The pattern
One callback module. The manager just starts the agent and
inspects the workspace after halt.
```elixir
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
end
```
## Using it
```elixir
{: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 add`
instead of `git 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](https://github.com/genagent/gen_agent) (once
extracted) has `create_worktree/3` and `remove_worktree/2`
wrapping this.
- **Tool-use agent.** Swap the backend to `gen_agent_claude`
with `cwd: state.workspace` and the agent gains real file
access via Claude's Read/Glob/Grep/Bash tools. `post_turn`
then commits whatever the LLM actually wrote rather than
materializing text from the response.
- **Per-turn markdown artifacts as review input.** Pair this
with [Checkpointer](checkpointer.md): 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 in `post_run/1` so it only runs on
clean completion, never on crashes.
- **Cleanup on terminate_agent.** Symmetric with the above:
`terminate_agent/2` removes the worktree directory so
interrupted agents don't leave orphans behind. Keep the
workspace for inspection if `phase: :finished`, tear it down
otherwise.