Skip to main content
The bundled Textual cockpit is just one consumer of the SDK. This tutorial builds another from scratch: a single-file terminal agent that streams the agent’s activity, answers approval prompts on stdin, cancels gracefully on Ctrl-C, and leaves a replayable journal. Everything it uses is on the exported surface — no internals. It comes in two parts: a four-line one-shot agent to ground the core ideas (Part 1), then a streaming agent built one capability at a time (Part 2). The two are separate programs — Part 1 is the black-box version, Part 2 shows the work. The full listing of the streaming agent is at the end.

Setup

Not yet on PyPI — install from source (quote the extras; zsh glob-expands the brackets otherwise):
Plus rg and git on PATH, and a .env (or exported variables):
The harness resolves what proves the work per session (ADR-0007): an explicit 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:
That one line builds something: the agent inspects the repo, writes 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:
  • outcome is verifier-owned. "success" means the verifier found positive external evidence — for an edit, 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_kind selects the verification contract. edit (used here) requires a verifiable diff with a passing check over it; investigate is grounded Q&A that must leave the tree with zero net diff (transient instrumentation is legal but must be reverted — ADR-0005); test_only requires 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 like run_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_approval decides — nothing on the stream can approve anything.
  • remember=True (the “always” choice) stores a session-scoped ApprovalGrant: later calls sharing the same program (argv[0]) auto-allow, observable as ApprovalResolved(via="grant").
  • A blocking input() would stall the very event loop the run lives on — asyncio.to_thread keeps 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:
Any in-flight approval is denied on cancel, so a run blocked on the gate can’t hang either. (add_signal_handler is POSIX-only; on Windows, catch KeyboardInterrupt around asyncio.run instead and accept the harder stop.)

Journal the run

Pass a JsonlEventJournal to harness.session(...) in main and every event is committed to disk before any subscriber renders it — a crash loses nothing:
Reload it later with load_events(path) — the same typed objects, for replay, debugging, or evals.

The final report

The terminal TaskState carries everything a report needs — swap the bare return in main for a report:
For the richer formatted artifact the batch CLI prints (status + files + commands + diff reference), see 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):
The agent looked for existing Python, found none, wrote 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.) 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:
Swap this 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 Harness constructor kwarg.
  • A real UI: the cockpit (the separate jo-cli package, the jo command) is this same surface rendered with Textual — jo-cli/jo/app.py is the reference implementation, and jo-cli/jo/cli.py shows how a consumer ships its own entry point over the core.