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.