Steps as a tree
Agent progress as nested steps, not interleaved log lines. Anything a step made can be opened from it, without ever entering the model's context.
An agent doing real work runs things in parallel: three questions at once, each writing and running a query. Streamed as flat “thinking” lines, that’s an unreadable interleave, and the queries it ran are gone once the answer shows up.
Make every unit of work a step. A step’s lines, its child steps and anything it made nest under it.
export async function step<T>(ctx, label: string, work: (ctx) => Promise<T>,
{ outcome }: { outcome?: (r: T) => string } = {}) {
const stepId = randomUUID();
const mark = (status, text) =>
ctx.inform(text, { type: "thinking", metadata: { stepId, status, at: Date.now() } });
mark("running", label);
try {
const result = await work(within(ctx, stepId)); // children get parentStepId = stepId
mark("done", outcome?.(result) ?? label);
return result;
} catch (err) {
mark("failed", err.message || label);
throw err;
}
}
await step(ctx, "Checking 3 questions", (ctx) =>
Promise.all(questions.map((q) =>
step(ctx, q, (ctx) => answerQuestion(ctx, q), { outcome: (r) => `${r.rows.length} rows` }))));
✓ Checking 3 questions 4.1s
✓ Which customers ordered this month? 12 rows [open query]
✓ Which of those asked for a refund? 3 rows [open query]
✗ Who owns them? no owner field
Inspectables
inspect(ctx, { kind: "query", v: 1, body: card }); // a transient payload under this step
An inspectable renders like any other payload of its kind, so a query opens like a query card. It never enters the model’s context or the chat history. It’s for the person to check the work, not for the model to read.
Why it works
- Progress reads like the work. Parallel branches stay separate, and each closes with a one-line outcome and how long it took.
- Everything is checkable. Every query the agent ran can be opened from the step that ran it.
- It’s additive on the wire. A client that ignores
stepIdstill gets plain thinking lines.