Observability

Tracing

Every run emits an ordered event stream; sinks decide where it goes.

Every run in Assemble produces an ordered stream of trace events — one event for each thing that happened, from run.started to the final run.completed or run.failed. Workflow runs and standalone agent runs (agent.run() and agent.stream()) both emit the full run lifecycle, which is how the dashboard knows a run has finished. The runtime emits these events as the run executes; sinks decide where they go. A sink is just an object with a writeTraceEvent method, so the same event stream can print to your terminal, append to a local JSONL file, and upload to the Assemble platform at the same time.

Because the trace is the source of truth for a run, everything else builds on it: replay reconstructs a run from its JSONL trace, evals emit their own eval.* events, and the dashboard timeline on the platform renders the uploaded stream.

The trace event model#

Every event shares a base shape — id, runId, workflowName, an ISO timestamp, a parentEventId (or null), and a redacted flag — plus type-specific fields like agentName, toolName, latencyMs, or error. Events form a discriminated union on type:

CategoryEvent types
Runrun.started, run.completed, run.failed
Agentagent.started, agent.completed, agent.failed
Tooltool.requested, tool.approved, tool.blocked, tool.completed, tool.failed
Policypolicy.evaluated
Routingroute.selected
Approvalapproval.required, approval.approved, approval.rejected
Evaleval.started, eval.completed
Replayreplay.started, replay.completed

Tool events trace the full lifecycle of a side effect: a tool.requested event carries the validated input, tool.blocked records which policy stopped it and why, tool.approved links to the approval that unblocked it, and tool.completed or tool.failed closes it out with latency. route.selected records every supervisor decision, including the candidate agents that were not chosen.

agent.completed carries the aggregate tokenUsage and costUsd for that agent call — the same values the run result reports, with costUsd set to null when the model's per-token cost is unknown. An external sink can read spend straight off the event, with no join against run results — see cost attribution.

Every run result also carries the same stream in memory as result.events, so you can inspect it programmatically without any sink at all.

Sinks#

A sink implements a small interface: a name, a writeTraceEvent(event) method, and optional flush() and close() methods. The SDK ships three.

createConsoleSink#

Prints one colorized line per event — timestamps dimmed, event types colored by category, failures and blocks in red. Use it whenever a human is watching the run.

ts
import { createConsoleSink } from "@assemble-dev/sdk";

const consoleSink = createConsoleSink();
OptionTypeDescription
writeLine(line: string) => voidCustom line writer. Defaults to process.stdout.
useColorbooleanDisable ANSI colors with false. Defaults to true.

Output looks like this:

txt
[14:03:12.481] run.started
[14:03:12.502] agent.started agent=classifier
[14:03:13.118] agent.completed agent=classifier 616ms
[14:03:13.240] tool.requested agent=refund-specialist tool=issueRefund
[14:03:13.395] tool.completed agent=refund-specialist tool=issueRefund 155ms
[14:03:13.401] run.completed 920ms

createJsonlSink#

Appends one JSON object per event to <directory>/<runId>.jsonl. This file is the durable local record of the run — it is exactly what replay reads back.

ts
import { createJsonlSink } from "@assemble-dev/sdk";

const jsonlSink = createJsonlSink({ runId: "run_1", directory: ".assemble/runs" });
OptionTypeDescription
runIdstringRequired. Names the output file <runId>.jsonl.
directorystringOutput directory, created if missing. Defaults to .assemble/runs.

createPlatformTraceSink#

Uploads events to your Assemble workspace in batches of 20, with a bounded in-memory queue (1,000 events; the oldest are dropped beyond that) and one retry per failed batch. Every event payload is redacted before it enters the upload queue, so sensitive values never leave the process — see security for how redaction works.

ts
import { createPlatformTraceSink } from "@assemble-dev/sdk";

const platformSink = createPlatformTraceSink({
  apiKey: "asm_dev_your_key_here",
  runId: "run_1",
  workflowName: "supervised-support",
});
OptionTypeDescription
apiKeystringRequired. Your workspace API key.
runIdstringRequired. The run the events belong to.
workflowNamestringRequired. Shown in the dashboard run list.
baseUrlstringOverride the platform API base URL. Defaults to https://ingest.getassemble.dev.
redactionMode"redacted" | "full" | "metadata-only"Defaults to "redacted".
sensitiveFieldNamesstring[]Extra field names to always mask.
batchSizenumberEvents per upload request. Defaults to 20.
maxQueueSizenumberQueue bound before old events are dropped. Defaults to 1000.
onUploadError(error: Error) => voidCalled on failed uploads; uploads never throw into your run.

The returned sink also exposes flush() to drain the queue and getUploadStats(), which reports uploadedEventCount, droppedEventCount, and failedBatchCount.

Passing sinks to a run#

Sinks go through the sinks run option. Pass any combination — every event is delivered to every sink in order.

src/trace-run.ts
import {
  createConsoleSink,
  createJsonlSink,
  createPlatformTraceSink,
} from "@assemble-dev/sdk";

const runId = "run_1";

await supervised.run(
  { ticket: "refund please" },
  {
    runId,
    sinks: [
      createConsoleSink(),
      createJsonlSink({ runId, directory: ".assemble/runs" }),
      createPlatformTraceSink({
        apiKey: "asm_dev_your_key_here",
        runId,
        workflowName: "supervised-support",
      }),
    ],
  },
);

Pass the same runId to the run and the sinks so the JSONL file, the persisted run state, and the uploaded trace all line up. See runtime for the other run options (runStateDirectory, persistRunState).

How the CLI wires sinks#

asm run and asm dev configure all three sinks for you: the console sink streams the live trace, the JSONL sink writes to .assemble/runs/ (override with --trace-dir), and the platform sink is added automatically when you are logged in. Pass --no-upload to skip the platform sink and keep the run fully local — the run still executes and the summary points you at the local JSONL file. See the CLI reference for all flags.

Local trace layout#

Local run artifacts live under .assemble/ in your project:

txt
.assemble/
  runs/
    run_1.jsonl    one trace event per line
    run_1.json     persisted run state (when persistRunState is on)
  last-run         id of the most recent CLI run

The .jsonl and .json pair under .assemble/runs/ is everything replay needs to re-execute the run later, and asm replay uses .assemble/last-run to default its runId argument.

Note: Trace events can contain full agent inputs and tool outputs. The JSONL file is written verbatim, without redaction — add .assemble/ to .gitignore and rely on the platform sink's redaction for anything that leaves your machine. See security.