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 and workspace path when the agent halts. It prints the checked commit log only for a verified :finished phase. It 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.

defmodule Workspace.Agent do
  use GenAgent
  require Logger

  defmodule State do
    defstruct [
      :topic,
      :num_turns,
      :workspace,
      :branch,
      :session_id,
      :git_timeout,
      :commit_log,
      turn: 0,
      paragraphs: [],
      commits: [],
      phase: :running
    ]
  end

  @impl true
  def init_agent(opts) do
    id =
      Keyword.get_lazy(opts, :session_id, fn ->
        Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
      end)

    if is_binary(id) and Regex.match?(~r/\A[a-z0-9][a-z0-9-]{0,62}\z/, id) do
      init_state(opts, id)
    else
      {:error, {:invalid_session_id, id}}
    end
  end

  defp init_state(opts, id) do
    base = Keyword.get(opts, :workspace_root, Path.join(System.tmp_dir!(), "workspace-agent"))

    state = %State{
      topic: Keyword.fetch!(opts, :topic),
      num_turns: Keyword.get(opts, :num_turns, 3),
      session_id: id,
      workspace: Path.expand(Path.join(base, "session-#{id}")),
      branch: "agent/#{id}",
      git_timeout: Keyword.get(opts, :git_timeout, 5_000)
    }

    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), cwd: state.workspace],
     state}
  end

  # ---- Lifecycle hooks ----

  @impl true
  def pre_run(%State{} = state) do
    with :ok <- File.mkdir_p(Path.dirname(state.workspace)),
         :ok <- File.mkdir(state.workspace) do
      result =
        with {:ok, _} <-
               git(state, ["init", "--quiet", "--template=", "--initial-branch=#{state.branch}"]),
             {:ok, _} <- git(state, ["config", "user.email", "agent@example.local"]),
             {:ok, _} <- git(state, ["config", "user.name", "Workspace Agent"]),
             {:ok, _} <- git(state, ["commit", "--quiet", "--allow-empty", "-m", "init"]) do
          {:ok, state}
        end

      case result do
        {:ok, _} ->
          result

        {:error, reason} ->
          case File.rm_rf(state.workspace) do
            {:ok, _} -> {:error, reason}
            {:error, cleanup, path} -> {:error, {reason, {:cleanup_failed, cleanup, path}}}
          end
      end
    else
      {:error, :eexist} -> {:error, {:workspace_exists, state.workspace}}
      {:error, reason} -> {:error, {:workspace_create_failed, reason}}
    end
  end

  @impl true
  def pre_turn(_prompt, %State{phase: {:failed, _}} = state), do: {:halt, state}

  # Finalize after post_turn, before claiming success. post_run cannot change state.
  def pre_turn(_prompt, %State{turn: turn, num_turns: total} = state) when turn >= total do
    case git(state, ["log", "--oneline"]) do
      {:ok, log} -> {:halt, %{state | phase: :finished, commit_log: log}}
      {:error, reason} -> {:halt, failed(state, reason)}
    end
  end

  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"

        with :ok <- File.write(Path.join(state.workspace, filename), paragraph <> "\n"),
             {:ok, _} <- git(state, ["add", filename]),
             {:ok, _} <-
               git(state, [
                 "commit",
                 "--quiet",
                 "-m",
                 "turn #{state.turn}: paragraph #{state.turn}"
               ]),
             {:ok, sha} <- git(state, ["rev-parse", "--short", "HEAD"]) do
          commit = %{turn: state.turn, sha: String.trim(sha), filename: filename}
          {:ok, %{state | commits: state.commits ++ [commit]}}
        else
          {:error, reason} -> {:ok, failed(state, reason)}
        end
    end
  end

  def post_turn({:error, _reason}, _ref, state), do: {:ok, state}

  @impl true
  def post_run(%State{} = state) do
    case state.phase do
      :finished ->
        IO.puts("\n[workspace] finished #{length(state.commits)} turns")
        IO.puts("  log:\n#{String.trim_trailing(state.commit_log)}")

      phase ->
        IO.puts("\n[workspace] #{inspect(phase)}; #{length(state.commits)} recorded commits")
    end

    IO.puts("  workspace: #{state.workspace}")
    IO.puts("  branch:    #{state.branch}")
    :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]}

    {:prompt, "next paragraph", state}
  end

  @impl true
  def handle_error(_ref, reason, %State{} = state) do
    {:halt, failed(state, {:backend_failed, reason})}
  end

  defp failed(state, reason) do
    Logger.error("[workspace] #{inspect(reason)}")
    %{state | phase: {:failed, reason}}
  end

  # ---- Git helper ----

  defp git(state, [step | _] = args) do
    task =
      Task.async(fn ->
        try do
          System.cmd(
            "git",
            ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null" | args],
            cd: state.workspace,
            stderr_to_stdout: true,
            env: [
              {"GIT_CONFIG_GLOBAL", "/dev/null"},
              {"GIT_CONFIG_NOSYSTEM", "1"},
              {"GIT_TERMINAL_PROMPT", "0"}
            ]
          )
        rescue
          error -> {Exception.message(error), :exec_failed}
        end
      end)

    case Task.yield(task, state.git_timeout) || Task.shutdown(task, :brutal_kill) do
      {:ok, {output, 0}} -> {:ok, output}
      {:ok, {output, code}} -> {:error, {:git_failed, step, code, output}}
      nil -> {:error, {:git_failed, step, :timeout, ""}}
    end
  end
end

Using it

{:ok, _pid} = GenAgent.start_agent(Workspace.Agent,
  name: "essay",
  backend: GenAgent.Backends.Anthropic,
  topic: "why octopuses are extraordinary",
  num_turns: 3
)

# 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
  -> backend.start_session (receives cwd)
  -> pre_run
    -> pre_turn   (called before each dispatch)
      -> (backend call)
        -> handle_response OR handle_error
          -> post_turn
            -> transition (idle / self-chain / halt)
              -> post_run   (on halt)
                -> terminate_agent   (on process exit)

The final response self-chains once more so pre_turn/2 can check the commit log and halt without another backend call. A failed add, commit, or SHA lookup records a failure in post_turn/3; the next pre_turn/2 halts with that failure. A failed SHA lookup may occur after a successful commit; the recorded commit list is then incomplete. post_run/1 prints the already checked log only for :finished.

post_run fires on a clean halt, including a halt with a failed phase. Gate publishing or other completion actions on phase == :finished. terminate_agent/2 runs when an initialized server terminates through its termination callback, including stop and ordinary crashes. It does not run after failed initialization or backend startup, or on an untrappable kill/VM exit. Setup cleans up its own partial directory. Keep successful workspaces for inspection and remove them when done; production systems also need orphan cleanup outside the agent. Setup and cleanup callbacks block the agent process. Configure shutdown: above their bounded worst-case duration when starting the agent; the default is 5 seconds, after which stop/1 kills a still blocked agent without running its termination callbacks. Avoid :infinity unless an indefinitely blocked supervisor is acceptable.

This POSIX example requires Git 2.32+ for GIT_CONFIG_GLOBAL. It ignores global/system configuration, disables signing and hooks, and uses an empty template. Each command has a configurable :git_timeout in milliseconds (default 5,000). The task deadline bounds the agent's wait; it does not guarantee termination of the OS process or its descendants. Use a process supervisor with process-group termination when that guarantee is required.

The default ID uses random bytes, independent of VM restarts. Optional IDs must be lowercase ASCII letters/digits/hyphens, start with a letter or digit, and be at most 63 characters. :workspace_root is a trusted application setting. Exclusive directory creation rejects even a pre-existing empty directory; it never opens or deletes an old repository.

init_agent/1 rejects bad IDs before filesystem access. Currently start_agent/2 reports this as {:error, {:backend_start_failed, {:invalid_session_id, id}}}. Setup runs asynchronously after startup: a pre_run/1 error stops the process with {:pre_run_failed, reason}, not a startup error tuple. Callers should monitor the agent (accounting for it exiting before the monitor is installed) or use supervisor/telemetry reporting, and handle a failed tell/2 if setup failed. Git setup errors include the command, exit code or :timeout, and output; partial directories are removed.

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. Implement creation and removal against your source repository as application-specific helpers.
  • Tool-use agent. Swap the backend to gen_agent_claude using the cwd already returned by init_agent/1. The path is computed before backend session startup and created in pre_run/1. This requires a backend that defers directory access until prompts. The agent gains 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: return {:noreply, state} with phase: :awaiting_review to remain idle after each turn, and 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 a verified :finished phase after clean completion.
  • Cleanup on terminate_agent. Symmetric with the above: implement terminate_agent/2 to remove owned workspaces on ordinary interrupted runs. Track ownership only after successful setup, so a rejected existing directory is never removed. Keep finished workspaces for inspection and arrange external orphan cleanup for exits that bypass the callback.