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 stepId still gets plain thinking lines.