The agent spawned an agent and I could not see it
A subagent is a black box that spends your money. Making it inspectable took no new instrumentation at all — the events were already firing, into nothing.
VEX had a decent trace view. Every agent, every step, every tool call, the token count and the dollar amount, streaming live while it works. I was pleased with it.
Then agents started spawning subagents, and the trace view said:
▸ subagent_spawn general-purpose
▸ subagent_result (2m 14s)
Two minutes of work, some number of tokens, an outcome that affected the diff — represented as two lines with a gap between them. Whatever happened in there, it happened to somebody else.
That is not a trace. That is a receipt for a trace.
The instrumentation already existed
Here is the part that should be embarrassing and is actually just instructive.
The SDK fires SubagentStart and SubagentStop hooks. VEX was already handling
both. They were already publishing to vex.agent.{id}.hooks. The frontend was
already receiving them via onAgentHook(), and a utility already converted them
into subagent_spawn / subagent_result step types — which is why those two
lines rendered at all.
Every hook carried what I needed the whole time:
SubagentStart → { subagent_id, subagent_type }
SubagentStop → { subagent_id, subagent_type, transcript_path }
transcript_path. Pointing at a JSONL file. Containing every message the
subagent sent and received. Being handed to me, on every stop, and thrown away.
The data was not missing. It was arriving and being dropped on the floor because nothing downstream had been built to catch it.
Lesson: before adding instrumentation, read what your existing hooks already carry. Twice now on this project the “missing” telemetry has turned out to be present, unpersisted, and one line from being useful.
Do not copy the transcript into the database
The obvious move on SubagentStop is to parse the transcript and write the steps
into the trace_steps table, so subagent traces are queried exactly like agent
traces.
I did not, for two reasons.
The transcript file is the authoritative record and it is written by the SDK, not by me. Copying it into SQLite creates a second version that can disagree with the first, and when they disagree the wrong one is the one I built. The file is also written whether or not I read it — there is no possible drift if I never duplicate it.
The other reason is where the cost lands. SubagentStop fires on a hook, in the
path of an agent that is still running. Parsing a potentially large JSONL file
there adds latency to live work, to prepare data for a view nobody may open.
So the persisted record is deliberately thin — a subagent_metadata row holding
id, type, description, and the path — and the transcript is parsed on demand,
server-side, when somebody actually opens the drill-down. Then it is converted
into the same TraceStep shape the frontend has always rendered.
A new table rather than a column on agent_traces, because traces are
batch-scoped and carry batch_id, agent_model, cost — fields with no meaning
for a subagent. Widening a model until it fits two things is how you end up with
a table where half the columns are null and the schema no longer tells you what a
row is.
Two thousand lines I did not write
The drill-down page renders the same things the trace page renders: steps, tool
calls, results, errors. AgentStepItem.tsx alone is about 1,500 lines of step
rendering, and the surrounding view adds more.
The tempting shape is SubagentTrace.tsx, a sibling page. It is also 95%
identical code with a different fetch, which means every future step type gets
implemented twice, and the second implementation is always the one that gets
forgotten.
Instead the existing view gained a subagent mode, triggered by a route param:
/project/:id/agent/:agentId
/project/:id/agent/:agentId/subagent/:subagentId
The differences are genuinely small: a different endpoint, a breadcrumb back to the parent, and the follow-up bar hidden — you cannot continue a conversation with a subagent, because the subagent is not the thing you are talking to.
Everything else is the code that was already there, and every improvement to step rendering now lands in both places by construction.
Where I stopped
A subagent can spawn a subagent. The data model handles it — subagent_metadata
references a parent agent, and nothing cares how deep that goes. The route
pattern handles it too.
The UI supports exactly one level, and says so when there is more.
That is a deliberate stopping point, not an oversight. Recursive drill-down means recursive breadcrumbs, recursive loading states, and a testing surface that grows with a number I have never actually seen exceed one in practice. The data is captured, so the day depth-two matters, the work is UI work and not a migration.
Lesson: capture at full depth, render at the depth you have evidence for. Data you did not record is gone forever; a view you did not build is an afternoon.
Why this is worth an afternoon at all
An agent you cannot inspect is a thing you either trust completely or stop using. There is no middle setting, and neither of those is where you want to be with something that edits your repository and bills you per token.
The drill-down did not make the subagents better. It made them arguable — I can look at what one did, disagree with it, and change the prompt that produced it. That loop is the entire value of tracing, and it was two lines and a discarded file path away the whole time.