just-another-sdk

Events & tracing

One event stream that tracing, metrics, and progress UIs all consume.

Every interesting moment in a run is an event. Tracing, streaming, progress UIs, and cost dashboards are all consumers of this one stream rather than separate features bolted onto the loop.

The built-in tracer

import { consoleTracer } from 'just-another-sdk'

await agent.run('Weather and time in Paris?', { onEvent: consoleTracer() })
▶ run_m9x2k1p  travel-assistant · anthropic/claude-opus-5
  ↳ get_weather {"city":"Paris"}
  ↳ get_time {"city":"Paris"}
    → {"city":"Paris","tempC":18,"summary":"clear"} 118ms
    → {"city":"Paris","localTime":"14:32"} 61ms
    ✗ get_air_quality {"error":"upstream 503","code":"tool_execution_error"} 12ms
✔ finish · 2 turns · 412 in / 63 out · 1.9s

Prop

Type

Tool values are redacted before printing.

Handling events yourself

onEvent is a plain function. Switch on type:

await agent.run(prompt, {
  onEvent: (event) => {
    switch (event.type) {
      case 'tool.end':
        metrics.histogram('tool_duration_ms', event.durationMs, {
          tool: event.toolName,
          error: String(event.isError),
        })
        break

      case 'model.response':
        metrics.increment('tokens_in', event.usage.inputTokens)
        metrics.increment('tokens_out', event.usage.outputTokens)
        break

      case 'run.finish':
        logger.info('run complete', {
          runId: event.runId,
          stopReason: event.stopReason,
          turns: event.turns,
        })
        break

      // Events are additive — always keep a default branch.
      default:
        break
    }
  },
})

New event types will be added over time, so switch with a default branch rather than assuming the union is exhaustive.

Two guarantees

A listener cannot break a run. Handlers are invoked inside a try/catch and their exceptions are swallowed. Instrumentation is not allowed to change program behaviour:

await agent.run(prompt, {
  onEvent: () => {
    throw new Error('my logger is broken')
  },
})
// The run still completes normally.

Emitting is synchronous. The loop never awaits a listener, so adding logging cannot slow down or reorder the agent's work. If you need to do something slow, queue it:

onEvent: (event) => {
  void queue.push(event) // do not block the loop
}

Event reference

Every event carries id, timestamp, runId, and agentName. After a handoff, agentName is the agent that was acting when the event was emitted, so a trace attributes work without joining anything.

TypeWhenNotable fields
run.startAlways firstmodelId, input, toolNames
session.loadHistory loaded, before the first callsessionId, messageCount, droppedCount, durationMs
model.requestBefore each model callturn, messageCount, tools
model.responseAfter each model call, before tools runtext, toolCalls, finishReason, usage, durationMs
tool.startBefore a tool runs, arguments validatedtoolName, toolCallId, input
tool.endAfter a tool finishes or failsresult, isError, durationMs
handoff.startThe conversation moved to another agentfrom, to, toolName, toolCallId, reason?, carriedCount
handoff.refusedA transfer hit a loop-prevention limitfrom, to, cause, reason
session.saveNew messages written back, on completionsessionId, appendedCount, durationMs
run.finishLast event on a successful runstopReason, text, turns, usage, durationMs, agentPath
run.errorLast event when the run throwserror, turn
text.deltaStreamed runs onlydelta
model.retryA failed call will be attempted againattempt, maxAttempts, error, delayMs, discardedText
model.fallbackThe chain moved to the next providerfromModelId, toModelId, index, error
output.invalidAn answer failed outputSchematurn, attempt, maxAttempts, issues, repairing
guardrail.triggeredA guardrail rejected, rewrote, or gatedguardrail, stage, action, toolName?, reason?
approval.requiredThe run suspended for a humanturn, calls
approval.resolvedA decision was applied on resumetoolCallId, toolName, approved, reason?

For a tool-using run the order is deterministic:

run.start
  model.request → model.response
  tool.start … tool.start        (one per call, all before execution)
  tool.end   … tool.end          (one per call)
  model.request → model.response
run.finish

A streamed run is the same sequence with text.delta interleaved, always between the request and the response for that turn:

run.start
  model.request → text.delta* → model.response
  tool.start … tool.end …
  model.request → text.delta* → model.response
run.finish

model.request fires once per turn, not once per attempt — retries and fallbacks announce themselves with their own events instead.

A run that hands off slots the transition in after the transfer tool's result and before the receiving agent's first call:

run.start
  model.request → model.response      (agentName: triage)
  tool.start → tool.end               (transfer_to_billing)
  handoff.start                       (triage → billing)
  model.request → model.response      (agentName: billing)
run.finish                            (agentPath: ['triage', 'billing'])

There is no handoff.end: a handoff is a transition rather than a nested scope, so the only honest close is run.finish.

A run with an outputSchema emits no text.delta at all, and appends its own validation pass after the loop:

run.start
  model.request → model.response
  output.invalid                 (repairing: true)
  model.request → model.response (the repair)
run.finish

output.invalid fires on every failed attempt, including the last one — unlike model.retry, which only fires when another attempt is coming. The final failure is the one worth seeing in a trace, because the run is about to throw.

A run using a session brackets that sequence with two more:

run.start
  session.summarize              (only when a fold happened)
  session.load                   (what the run actually starts with)
  model.request → model.response
  session.save                   (immediately before run.finish)
run.finish

session.load is emitted after any summarizing, so its counts describe the history the run really begins with rather than an intermediate state.

session.save does not fire on a run that throws — nothing is persisted, on purpose. See what gets saved, and when.

Undoing discarded text

When an attempt fails after streaming real tokens, its output is void and the retry starts over. model.retry and model.fallback both carry discardedText: exactly the characters already delivered that a renderer must remove.

if (event.type === 'model.retry') ui.removeLast(event.discardedText.length)

It is empty in the common case — a rate limit or a refused connection fails before any bytes arrive.

Partial state from a failed run

A run that throws returns no RunResult, so events are how you recover what happened before the failure:

const seen: AgentEvent[] = []

try {
  await agent.run(prompt, { onEvent: (event) => seen.push(event) })
} catch (error) {
  const toolCalls = seen.filter((event) => event.type === 'tool.end')
  logger.error('run failed', {
    error,
    completedTools: toolCalls.length,
    tokensSpent: seen
      .filter((event) => event.type === 'model.response')
      .reduce((total, event) => total + event.usage.totalTokens, 0),
  })
}

Typed handlers

EventOfType narrows a handler to one event type:

import type { EventOfType } from 'just-another-sdk'

function onToolEnd(event: EventOfType<'tool.end'>) {
  //                       ^ fully typed: result, isError, durationMs
  if (event.isError) alert(event.toolName)
}

Traces without an event handler

RunResult.steps is the same information, recorded for you:

const result = await agent.run(prompt)

for (const step of result.steps) {
  console.log(
    `turn ${step.turn}: ${step.toolCalls.length} tool(s), ${step.durationMs}ms`,
  )
  console.log(
    `  usage: ${step.usage.inputTokens} in / ${step.usage.outputTokens} out`,
  )
}

Because the loop records every turn as it goes, tracing exporters are a formatter over data that already exists rather than new plumbing.

Coming next

Trace exporters for JSON and OpenTelemetry.

Next

On this page