LaufwerkLaufwerk

Visualize a workflow

Add optional maps and see recorded work progress in Studio.

This guide requires an SDK exposing @laufwerk/sdk/visual and its matching runtime/Studio. Older releases do not provide this API.

Studio always offers Workflow and Observed execution on a run. Without metadata, Workflow explains how to add a map; Observed execution still shows retained work, transcripts, logs, human requests and recovery information.

Visualization is optional. Your native Effect/TypeScript code determines what runs, in which order, and with which concurrency. Removing a visual annotation must not change the workflow's result, durable keys or business effects.

Start with one node

import * as Visual from "@laufwerk/sdk/visual";

const result =
  yield *
  calculateTotal(input).pipe(
    Visual.node({ id: "total", label: "Calculate total", kind: "task" }),
  );

calculateTotal can be your existing Activity. Visual.node labels evidence captured by existing runtime boundaries; it does not make arbitrary code durable, create an Activity, or record every Effect expression. A helper without any recorded boundary may have no visible work.

Use kind: 'task' | 'agent' | 'human' | 'control' for declared nodes. The runtime knows the types of supported boundaries and takes precedence over conflicting metadata. Type colors remain stable as status changes: blue square for tasks, violet circle for agents, teal diamond for humans.

Add a stage

const result =
  yield *
  existingProcess(input).pipe(
    Visual.scope({ id: "planning", label: "Plan the change" }),
  );

Scopes accumulate around observed work; nested node annotations select the nearest node. Context is isolated between concurrent Effect fibers. A scope establishes membership, not dependency edges. Studio does not infer sequence from event arrival order.

For repeated work, add a stable instanceKey to the repeated scope:

yield *
  Effect.forEach(
    parts,
    (part) =>
      processPart(part).pipe(
        Visual.scope({
          id: "component",
          instanceKey: part.id,
          label: part.name,
        }),
      ),
    { concurrency: 4 },
  ).pipe(Visual.scope({ id: "components", label: "Process components" }));

Use durable input/business identity, not a random UUID or display position. instanceKey does not make changing input durable. Existing Activity names, Session keys and Human keys still control replay.

Agents, commands and human decisions

const coding =
  yield *
  Session.open({
    key: "implementer",
    agent,
    workspace,
    access: "read-write",
  });

yield *
  coding
    .ask({ key: "implement", prompt })
    .pipe(Visual.node({ id: "implement", label: "Implement changes" }));

yield *
  coding
    .exec({ key: "verify", command: "bun test" })
    .pipe(Visual.node({ id: "verify", label: "Run verification" }));

yield * coding.close();

const approved =
  yield *
  Human.confirm({
    key: "approve",
    title: "Approve this change?",
  }).pipe(Visual.node({ id: "approval", label: "Review change" }));

exec exists on workspace-bound sessions. Standalone Session.run(...) accepts the same pipeable annotation. Opening/closing a session is resource management, not automatically a reasoning node. Human notifications are not blocking gates.

Describe a map before execution

Runtime annotations only describe work when a recorded boundary is encountered. For a static preview on the runs list, attach an optional native workflow annotation:

const review = {
  id: "review",
  label: "Review evidence",
  kind: "agent",
} as const;
const approve = {
  id: "approve",
  label: "Approve decision",
  kind: "human",
} as const;

const definition = {
  schemaVersion: 1,
  title: "Evidence review",
  nodes: [review, approve],
  scopes: [
    {
      id: "process",
      label: "Review process",
      structure: { kind: "sequence" },
      children: ["review", "approve"],
    },
  ],
  root: "process",
} as const satisfies Visual.WorkflowDefinition;

export const workflow = Workflow.make({
  name: "evidence-review",
  payload: { executionKey: Schema.String },
  success: Schema.Boolean,
  error: Schema.String,
  idempotencyKey: (input) => input.executionKey,
}).annotate(Visual.Definition, definition);

export const layer = workflow.toLayer(() =>
  Effect.gen(function* () {
    yield* Session.run({ key: "review", agent, prompt }).pipe(
      Visual.node(review),
    );
    return yield* Human.confirm({ key: "approve", title: "Approve?" }).pipe(
      Visual.node(approve),
    );
  }).pipe(Visual.scope({ id: "process" }), Effect.mapError(String)),
);

Reuse node constants between annotations and the definition. Static scopes must match the recorded scope path to receive live state. One definition drives both large stage bands and the small preview. No coordinates or separate small graph are needed. Merely listing nodes does not connect them.

The definition describes the program; it does not enforce or prove its behavior. Keep the actual concurrency constant shared with metadata when possible.

Structure reference

  • No structure: optional children describe containment only.
  • sequence: ordered children declare dependencies.
  • parallel: children, optional concurrency, optional join.
  • collection: one body reference, optional concurrency and join.
  • loop: one body, optional positive limit and textual rule.
  • choice: routes: [{ id, label, target }] describe alternatives.

concurrency is a positive integer or 'unbounded'. join is 'all-success', 'all-outcomes' or 'unknown'; metadata cannot change native join behavior. Nodes and scopes share a unique ID namespace. References must exist and containment cannot cycle; use a loop body to describe repetition.

Visual.node accepts id, optional label, kind, description and compactLabel. Visual.scope accepts id, optional label, instanceKey and structure. Visual.Definition accepts schema version 1, optional title, description, nodes, scopes and root. The TypeScript types are exported from @laufwerk/sdk/visual; runtime schemas are owned by @laufwerk/protocol.

Display data is bounded: 200 nodes, 100 scopes, 16 observed scope frames, 160-character IDs, 240-character labels, 2,000-character descriptions, and a 64,000-character serialized metadata budget. Use short public-safe labels; metadata is retained with run evidence, not a secret store.

Reading live state

The large map refreshes from retained execution evidence through Studio's existing live connection. Selecting another component path expands it in place. Updates do not intentionally change your selection. Select a node to inspect its recorded operations; nodes with several evidence members offer individual targets.

  • No mapped evidence means not observed, not skipped or queued.
  • Activity start, successful completion and failure can update live.
  • Human waits distinguish pending input from accepted input still being delivered.
  • A completed harness slice does not prove the complete typed agent call succeeded. Continuations and output validation can follow it; unsupported logical completion is shown as partial. Inspect the transcript and run outcome for details.
  • Repeated/cached work without unique mapping remains unknown or unmapped.
  • A concurrency cap is not the total number of items. All visible children finishing does not establish that a collection or loop has finished.
  • Declared routes do not identify the selected route or prove the others were skipped.

The runs-list preview is a static definition, not live run status. It loads on hover or keyboard focus; clicking or tapping opens the run. Scope-only annotations can enrich the run map without producing a before-run preview.

Versions, missing maps and recovery

A description is stored in the immutable workflow bundle when the bundle is built. Old runs keep that version. Adding metadata to current source does not retrofit old runs. Preview requests read stored data and never execute workflow modules.

Invalid metadata must not change execution behavior. Studio displays diagnostics and preserves safe labels when topology is invalid. Missing metadata, unsupported versions and network errors are different conditions; retry a failed request rather than interpreting it as an empty workflow.

The map uses the existing evidence store and live refresh mechanism. It does not introduce a graph executor, new recovery controls or a second event journal. Observed execution and pending decisions remain available if rendering fails.

On this page