Setup
Not yet on PyPI — install from source (quote the extras; zsh glob-expands the brackets otherwise):rg and git on PATH, and a .env (or exported variables):
AVATAR_TEST_COMMAND/AVATAR_LINT_COMMAND always wins; otherwise it detects the
repo’s declared contract (CI workflows, manifests, Makefile targets). This
tutorial’s workspace starts empty — nothing to detect — so declare the
contract explicitly as above, or the edit run will (correctly) fail
verification with “no verification contract discovered”.
The workspace must be a git repo with no uncommitted tracked changes — the
harness pins HEAD as its diff baseline (see the
quickstart for the allow_dirty
escape hatch).
Part 1 — the one-shot agent
The smallest possible consumer — no streaming, no UI. Complete and runnable exactly as written:chatbot.py, and the harness-owned verifier confirms the result before run()
returns. The diff lands in your workspace — git diff shows it. Two things to
absorb:
outcomeis verifier-owned."success"means the verifier found positive external evidence — for anedit, a real diff that clears a check (lint or tests) run over it, never the model’s say-so. “Done” is only ever a proposal the verifier disposes of.task_kindselects the verification contract.edit(used here) requires a verifiable diff with a passing check over it;investigateis grounded Q&A that must leave the tree with zero net diff (transient instrumentation is legal but must be reverted — ADR-0005);test_onlyrequires new tests that pass.
Part 2 — the streaming agent
Part 1 is a whole agent, but a black box: you get the verdict, never the work. The rest of the tutorial builds a separate program — a single-file terminal agent that shows each step as it happens. From here the code accretes toward the full listing; each section adds one capability.Stream the run
harness.session(...) returns a not-yet-started Session: run() drives the
engine while events() yields a typed stream you render. This split is the
two-plane contract — the stream is observation-only and can never alter the
run. Put the render loop in its own coroutine, and a main() that wires the
goal and lets the two run together:
events() subscribes eagerly — at call time, not first iteration — so
creating the run task before the loop loses nothing. The stream ends by itself
after agent_end. The full event catalog is in the
SDK guide.
Each section below extends this skeleton — a new branch in render, or a few
lines in main — and the imports grow to match (the full listing
has the complete set).
Answer approvals
Gated calls (tier-3 tools likerun_command, or a sensitive-path hit) are
announced on the event stream as ApprovalRequested, and the run blocks until
you decide via the control plane. Add one more branch to render’s loop
(and ApprovalRequested to the imports):
- The event announces; only
resolve_approvaldecides — nothing on the stream can approve anything. remember=True(the “always” choice) stores a session-scopedApprovalGrant: later calls sharing the same program (argv[0]) auto-allow, observable asApprovalResolved(via="grant").- A blocking
input()would stall the very event loop the run lives on —asyncio.to_threadkeeps it breathing.
Creating
chatbot.py won’t prompt: the edit tools (str_replace/write_file/delete_file) are tier 1 and
auto-allow once their target paths validate inside the workspace — the verifier
judges the resulting diff. To see an approval fire, give a goal that needs a
command (a build step, a migration), or target a denylisted path and watch it
get refused.Cancel gracefully on Ctrl-C
session.cancel() trips the run’s cancellation token; the loop observes it at
the next checkpoint, records the interruption as feedback, and still settles to
a terminal state — so await run_task always returns a TaskState, never
hangs. Register the handler in main, before starting the run task:
add_signal_handler is POSIX-only; on Windows, catch
KeyboardInterrupt around asyncio.run instead and accept the harder stop.)
Journal the run
Pass aJsonlEventJournal to harness.session(...) in main and every event
is committed to disk before any subscriber renders it — a crash loses
nothing:
load_events(path) — the same typed objects, for replay,
debugging, or evals.
The final report
The terminalTaskState carries everything a report needs — swap the bare
return in main for a report:
avatar.artifact.ArtifactManager — not
yet on the exported surface, so treat it as internal for now.
The full listing
Verified end-to-end (the live run below, lightly elided):chatbot.py, then the
verifier ran the declared contract: a diff is present and python -m ruff check
passes over the new file (python -m pytest -q skipped — no tests collected), so
the run settles to success.
(Your output will differ — it’s a live model run; the shape is what matters.)
Sidebar — run it offline
ModelClient is a one-method protocol, so the whole tutorial runs without an
API key by injecting a scripted model — exactly how the project’s own test suite
drives the engine. An edit task needs a clean git workspace (the verifier diffs
against HEAD), so the snippet spins up a throwaway repo:
harness into main() and the same flow runs offline. It exercises
the real contract end-to-end: write_file creates a genuine diff, the declared
test command skips with no tests collected (a tolerated skip), and the declared
lint command passes over the new file — the positive signal that settles
verification to success. Real loop, real verifier, zero network.
Keep escape sequences out of the embedded
CHATBOT source — a literal \n
inside the triple-quoted string would become a real newline in the written
file and break its string literals (and fail lint). The echo loop above sticks
to plain strings for exactly this reason.Where to go from here
- Multi-turn: wrap goals in a
ReplSession— history seeding, grant carry-over,/-meta commands, plan mode. See the SDK guide. - Your own tools / model / verifier: every collaborator is a
Harnessconstructor kwarg. - A real UI: the cockpit (the separate
jo-clipackage, thejocommand) is this same surface rendered with Textual —jo-cli/jo/app.pyis the reference implementation, andjo-cli/jo/cli.pyshows how a consumer ships its own entry point over the core.