Skip to content

Tracing

A trace turns everything a run's agents left behind into one timeline. Reach for it after a long run, when you want to see what each agent did and where the time went. It works on any session the backends logged, whether or not a flow drove it.

Try it

In the project you have been running in, type /epics at the prompt. Every run of a flow in this directory is there, newest first — which flow it was, what it was asked to do, how many sessions it opened:

The /epics list: the runs of this directory, newest first, each saying which flow it was, what
it was asked to do and how many sessions it opened

Put the cursor on the run you have just finished, press enter to go into it, and choose export it. Exporting gathers the trace on its way in, and what it wrote is said under the list:

console
/home/you/code/.humanize/20260809T014455.212Z-9f21ab.epic.tar.gz · 812 kB · 3 sessions, 412 slices

The archive, how big it came out, then what went into the trace inside it. The trace also lands in traces/ inside that run's own directory, next to the run's record and the links to its sessions, because a trace of a run is a thing to find again with the run rather than in whatever directory you happened to be standing in:

~/.humanize/epics/-home-you-code-myproject/20260809T014455.212Z-9f21ab/traces/export.trace.json

Now open it. Go to ui.perfetto.dev and drag the file in. Nothing is uploaded; Perfetto opens it in the browser. chrome://tracing works too, as does anything that reads a Chrome JSON trace.

What you get

process   agent          builder · 4 sessions
  track     main ──────────────▶ ▓▓▓ ▓ ▓▓▓▓▓▓ ▓▓  ▓▓▓▓▓  ▓ ▓▓▓▓
  track     subagent · explore ▶      ▓▓▓▓▓▓▓▓▓▓▓
process   agent          reviewer · 2 sessions
  track     main ──────────────▶            ▓▓▓▓        ▓▓▓▓
In the traceIs
a processone agent and everything it drove, called <agent> · <n> sessions — or, for a profiled run, one program it ran, called <program> · <pid>
a trackone row of that agent's sessions: main for the ones somebody started, subagent for what a turn reached for, named after the kind where a row is all of one kind. Sessions of one agent that never run at the same time share a track; root sessions and sub-agents stay apart. For a profiled run, one of a program's threads.
a sliceone action — a tool call, a message, or waiting for reasoning

Click a slice and its arguments are there: the prompt, the reasoning, the tool input, the tool output. As much as the backend wrote down.

On your first trace, look for:

  • A wide gap on every track. Nobody was working. That is the flow sleeping, committing, or reading what the last turn wrote.
  • One very long slice. A single tool call that took minutes — usually a test suite, sometimes a find over the whole disk.
  • A reviewer whose tracks all start after the actor's stop. That is the loop working as designed. If they overlap, it is not.
  • Two hundred short tracks on one process. A Ralph loop, one session per turn.

The first two are guesses until the run is profiled.

Why two agents do not read as one

The backends log a session under an id and never say whose it was. By default an agent in a trace is one configuration: a backend at a model at an effort, plus every sub-agent it started. A Ralph loop of a hundred one-shot sessions reads as one agent, which is right. An actor and a reviewer at the same model and effort would read as one agent, which is not.

That is what an epic is for. A trace of a run reads the run it is of, so rlar traces as actor and reviewer without being told anything.

Driving agents by hand from Python, say so yourself:

python
collect(agents={a.id: a.opened for a in (actor, reviewer)})

Sessions nobody claims are read as the configuration they ran at.

What a run writes down

Every run of a flow is one epic, which is a directory:

~/.humanize/epics/<workspace>/<datetime>-<hex>/
    epic.jsonl                      what happened, a line at a time
    epic.<flow>_<hex>.jsonl         the same, for one flow the run called
    state.json                      what a flow that can be picked up again left behind
    profile.jsonl                   the programs it ran, for a run that was profiled
    sessions/<session>/…            a link per file the backend logged that session to
    traces/export.trace.json        the trace exporting the run gathers, replaced each time
    traces/<datetime>.trace.json    one gathered by hand afterwards, which keeps every one

Not all of it every time: state.json is there for a flow that can be picked up, profile.jsonl for a directory that asked to be profiled, traces/ from the first time the run is exported, and a epic.<flow>_<hex>.jsonl for each flow the run called.

Find the run that just finished and list it:

sh
run=$(ls -dt ~/.humanize/epics/*/*/ | head -1)   # the one that just finished
ls "$run"
console
epic.jsonl  sessions  traces

ls of one run's directory: epic.jsonl, profile.jsonl, sessions and state.json, and no traces
yet

epic.jsonl is JSON lines, appended and flushed as it goes. A run that died is a run whose epic still says what it got to:

sh
head -3 "$run"epic.jsonl
console
{"event":"began","at":"...","flow":"rlar","task":"...","workspace":"...","resumable":false,"agents":[{"agent":"actor",...}]}
{"event":"opened","at":"...","agent":"actor","backend":"claude","provider":"local","session":"0a1b2c3d-...","name":"actor-claude@local-0a1b2c3d-...","where":"sessions/actor-claude@local-0a1b2c3d-..."}
{"event":"ended","at":"...","how":"done"}
eventWrittenCarries
beganwhen the flow startsflow, task, workspace, whether the flow can be picked up again and which run this one was picked up from, and one entry per agent with its id, backend, model, effort, account, what it may do, whether it could use goals and whether it was the person at the prompt
openedeach time an agent opens a sessionagent, backend, provider, session, the name the run gives it and where inside the epic its links are
calledwhen the flow calls another flowflow, task, and the epic — the record that call was written to
returnedwhen that call returns, however it endedflow and the same epic
endedwhen the flow stopshow: done, failed, or stopped

Each session's own logs are pointed at from sessions/<name>/, under a name that says whose session it was, what took its turns, which account they ran as and what the backend called it: builder-claude@work-0a1b2c3d, and @local where the turns ran as the account this machine is already signed into. Links for reading: humanize reads and writes every log where the backend keeps it. They are made again when the run ends, because a sub-agent's transcript is written whenever that sub-agent ran, and a filesystem that will not make one is a run without links rather than a run that stops.

Being links, they are worth nothing on any machine but this one — so sending a run to somebody else means following them and carrying what is behind them, which is what exporting a run does.

one run's sessions/ directory, its name saying agent, CLI and account, holding a symlink to
Claude Code's own log

/epics is the same list at the prompt: every run of this directory, newest first, with a mark on the ones whose flow says it can be picked up. Enter goes into the run under the cursor, which says where it is written down and offers two things: export it, which gathers a trace of that run and packs the whole of it up, and resuming it — which is picking a run up. Exporting is offered for every run, whatever its flow says.

It is not a transcript. The backend's own log is the turn-by-turn record. An epic is the shape of the run: enough to gather a trace afterwards out of the ids alone. It covers one run and is never reopened, so carrying a flow on is another run, with sessions of its own, written into an epic that says which run it was picked up from.

An agent stopped by hand makes the run stopped rather than failed, whatever the turn under way made of it. A run you stopped by hand is written down as stopped too.

python
from hmz.runtime.epic import epics, opened

for epic in epics():                   # this workspace, oldest first
    print(epic, opened(epic))          # {"actor": ["0a1b…"], "reviewer": [...]}

What a called flow writes down

A flow can call another, and a called flow opens sessions and calls flows of its own. Each call is written to a record of its own beside the run's, named for the flow and for that call of it:

sh
ls "$run"epic.*.jsonl
console
epic.jsonl  epic.gen-plan_0a1b2c.jsonl

The run's own record says what it called and which file to read it in:

console
{"event":"called","at":"...","flow":"humanize1:gen-plan","task":"...","epic":"epic.humanize1-gen-plan_0a1b2c.jsonl"}
{"event":"returned","at":"...","flow":"humanize1:gen-plan","epic":"epic.humanize1-gen-plan_0a1b2c.jsonl"}

A called flow's own record holds the same events, its began says which record is under it, and its ended says how the call ended rather than how the run did. Still one run and still one directory: a called flow is part of the run that called it.

Which run, and what else there is to trace

A trace is of a run. It holds the sessions that run opened and no others, by the ids the run wrote down as it went, so a directory run in fifty times has fifty traces to collect and none of them holds another's work. A run that opened nothing is a trace of nothing. It goes by id rather than by directory, so a flow that ran on a machine of its own is in its own trace too, though the backend logged it under a mirror this directory has never heard of.

Which run is therefore a row to walk to rather than a name to spell out. /epics is that list, newest first, and s searches what each run was asked to do — which is how last Tuesday's is found among fifty:

/epics, the run one of its rows opens into, and the trace exporting gathers into that run's
own directory

0 sessions, 0 slices

Three usual reasons. You are in a different directory from the one the run happened in. The backend was opencode or mimocode, which keep sessions in a database and have nothing to gather. Or the run being traced died before it opened a session. See Troubleshooting.

A directory also holds sessions no run of a flow ever opened: your own afternoon at a coding agent. Those are not in /epics — it is a list of runs, and a session no run drove has nothing there to hang on — so they are asked for by id, from Python:

python
from hmz.sdk import Hmz

document = Hmz("~/code/myproject").epics.trace(
    sessions=["0a1b2c3d"],     # a leading part of an id will do; none at all is all of them
    output="trace.json",       # omit and nothing is written
    start="3 days ago",
)

Naming sessions and no directory — Hmz().epics.trace(sessions=…) — collects them wherever they were recorded; naming a directory with them keeps only the ones recorded there. A session is named by its whole id, by the key the trace shows it under, or by a leading part of either, and the sub-agents it started come with it. start and end take anything dateparser understands, and the document comes back whether or not a file was written.

A run you have already picked out of the list is the other call, traced — the one export it makes on its way in: that run's sessions by the ids it wrote down, the programs it ran if it was profiled, and an output for when you want it somewhere other than the run's own traces/, a trace being a thing to attach to an issue as well as to read. Left alone it is named after the UTC moment it was collected, so gathering one by hand twice keeps both; the one export it writes has a name of its own, export.trace.json, so exporting twice leaves one trace as it leaves one archive.

Profiling a run

An agent's turn is mostly other programs: the tests, the build, the greps. None of them is in a backend's log, which records the tool call rather than the process. So a directory may ask for its runs to be profiled as well as traced. The switch is the profile row on the second page of /settings, which is the page for this directory.

the /settings page for this directory, with the profile row switched on beside workspace,
flow and forget

While a flow runs there, every process underneath it is sampled as each is seen: the agent CLIs themselves, and the tests, the builds and the greps their turns start. Each sample says what it was, what started it, and how long it took, into profile.jsonl in that run's epic. Collecting the run draws them in the same document as its sessions, at the same scale, so what was this run doing at 09:41 has one answer. A trace of a profiled run counts them: 3 sessions, 412 slices, 61 programs.

process   agent          builder · 4 sessions
  track     main ──────▶ ▓▓▓ ▓ ▓▓▓▓▓▓ ▓▓  ▓▓▓▓▓  ▓ ▓▓▓▓▓▓▓▓▓▓
process   program        pytest · 41207
  track     main ──────▶       ▓▓▓▓▓▓▓▓▓▓

Off until a directory asks for it: it is a sampler running for as long as the flow does, and a repository whose tests take an hour is a different question from one whose tests take a minute. What it costs is a thread reading the process tree twenty times a second, and two lines of JSON per program — one when it is first seen, one when it has gone.

Sampled rather than intercepted: nothing goes between an agent and what it runs, a program that lived for thirty milliseconds may be missed, and a machine whose processes cannot be read is a run with no profile rather than a run that stops.

The switch is read where a run starts, so turning it on holds from the next run rather than the one under way. A run hmz exec starts in that directory is profiled too: the switch says nothing about what runs, only about whether what runs is watched. From Python it is one property and one call:

python
from hmz.runtime.settings import Settings

Settings().profiling            # whether a run in this directory is profiled
Settings().profiles(on=True)    # written down for it, from now on

Where it reads from

The backends' own home directories, which humanize only ever reads:

BackendVariableDefault
Claude CodeCLAUDE_CONFIG_DIR~/.claude
CodexCODEX_HOME~/.codex
DeepSeek HarnessDSH_HOME~/.dsh
Kimi CodeKIMI_CODE_HOME~/.kimi-code

Those four, and no others. opencode, mimocode and Antigravity keep a session in a database rather than in a log file, and nothing here reads pi's, Grok Build's, Qwen Code's or ZCode's own logs yet. So there is nothing to gather for those: a run of theirs is watched as it happens rather than collected after.

A home that does not exist is skipped rather than being an error. So is a backend humanize has no reader for; its home being there changes nothing.

A flow that ran on a machine of its own worked in a mirror rather than in this directory. Its run's own trace holds those sessions all the same, since they are asked for by the ids the run wrote down; outside a run, name the sessions rather than the workspace that never saw them.

Watching instead

A trace is for after. While a run is going, /monitor shows the same shape live. It is read off the turns going past, never by asking the flow.

A trace holds what the agents did, not what it cost: it is one process per agent, one track per row of its sessions, one slice per thing the agent did, and the summary line says sessions, slices and programs. What a run cost — tokens and money alike — is the live reading, on /monitor and on the lines above the editor. See Cost and rate.

See also

Released under the Apache-2.0 licence.