Home / Docs / Workflows & runs

Workflows & runs

A workflow is a manifest: you declare, in order, the steps an intent should travel through. When you trigger it, the core instantiates a run — a concrete execution, with state, a step-by-step history and an identifier you can inspect. The workflow is the recipe; the run is the dish that came out of the oven.

Anatomy of a workflow

A workflow's spec has four main pieces: a trigger (how it can be started — manual, schedule or event), an input_schema (the inputs it accepts, with types and required fields), defaults (values applied when an input isn't provided) and an ordered list of steps.

Each step has an id, a use block that says what that step triggers, and a with block carrying the arguments. The use.kind picks the step type:

integration
Dispatches an operation to an integration. You resolve the target by family + operation (resolution by contract), or pin an instance with instance_ref, or break ties with provider_ref.
product
Runs a product operation (materialize, installation.apply, installation.observe…) from a with.product_ref.
yggdrasil
Executes in-process on the core itself, with no adapter — for example apply_manifest to persist a manifest loaded in with.manifest.

Steps can chain: depends_on enforces order, condition decides whether a step runs (a step whose condition is false ends up skipped), retry configures max_attempts/backoff_seconds, timeout_seconds caps the duration and for_each fans the step out over a list. Values flow between steps through templates — {{ inputs.x }} and {{ steps.y.metadata.z }}.

deploy-service.yaml
trigger:
  mode: manual
dispatch_mode: async      # time budget > gateway timeout
input_schema:
  required: [image_repo, image_tag]
  properties:
    image_repo: { type: string, minLength: 1 }
    image_tag:  { type: string, minLength: 1 }
steps:
  - id: render-overrides
    use: { kind: yggdrasil, operation: control_plane.render }
    with:
      control_plane_ref: "payments-api"
  - id: apply
    depends_on: [render-overrides]
    use:
      kind: integration
      family: gitops
      operation: ensure_kustomize_source
    with:
      image_repo: "{{ inputs.image_repo }}"
      image_tag:  "{{ inputs.image_tag }}"
    retry: { max_attempts: 3, backoff_seconds: 10 }
    timeout_seconds: 180

Dispatching a run

You trigger a workflow by name with yggdrasil run. Inputs go in as --input key=value pairs (repeatable); the types are coerced by the input_schema on the server, so the CLI always sends strings. By default a run is synchronous — the CLI blocks and prints progress step by step. With --async, the core returns a run id immediately and runs the workflow in the background.

terminal
$ yggdrasil run deploy-service \
    --input image_repo=registry.example/payments-api \
    --input image_tag=v1.8.3
 render-overrides [succeeded] control_plane.render
 apply [succeeded] ensure_kustomize_source

$ yggdrasil run deploy-service --async \
    --input image_repo=registry.example/payments-api --input image_tag=v1.8.3
 dispatched global/deploy-service (run 9f3c…e21, status pending)
  follow: yggdrasil logs 9f3c…e21   |   yggdrasil ops get 9f3c…e21

Use -n/--namespace to target a namespace other than global. Workflows whose step budget exceeds the gateway timeout (≈30s) should declare dispatch_mode: async in the manifest, so that even a run without --async returns a run id instead of blowing past the gateway.

Lifecycle of a run

An async run starts out pending, moves to running when execution begins, and ends in a terminal state — succeeded or failed. An aborted run becomes cancelled. These are exactly the states a workflow run can hold.

Two useful details: the status committed is treated as a terminal success (equivalent to succeeded); and the ops surface renames some states for display — pending shows as scheduled, running as active and cancelled as aborted. Inside a run, each step also has its own status: succeeded, failed or skipped (when the condition resolved to false).

The ops list filters understand both the underlying names and the displayed ones: --status active matches running, --status scheduled matches pending and --status aborted matches cancelled.

Inspect & manage

The yggdrasil ops family is the operational view over runs. logs streams a specific run until it finishes.

ops list
Lists runs. Filters: --status (repeatable), --integration, --search, --limit; output -o table|json.
ops get <run-id>
Detail of a single run — steps, results, errors. Output -o yaml|json.
ops retry <run-id>
Re-runs a run; accepts --reason to record the audit trail. Returns a new run id.
ops abort <run-id>
Marks an in-flight run as cancelled (displayed as aborted).
ops replay <run-id>
Re-runs from a previous run, optionally with new inputs.
logs <run-id>
Follows the status transitions of the steps until the run reaches a terminal state — the same feel as kubectl logs -f for a single run.
terminal
$ yggdrasil ops list --status active --integration gitops
RUN_ID    STATUS  WORKFLOW         INTEGRATION  TRIGGER
9f3c…e21  active  deploy-service   gitops       manual

$ yggdrasil logs 9f3c…e21
 render-overrides [succeeded] control_plane.render
 apply [running] ensure_kustomize_source

$ yggdrasil ops retry 9f3c…e21 --reason "fixed tag"
 retry 9f3c…e21 → new run 4b7a…c90

Reactors

Not every run is dispatched by hand. A reactor wires a domain event to an integration capability: the declaration says, in essence, "when event X happens in the core, call my capability Y". Reactors are declared in an integration_type manifest, inside a reactors block — each entry ties an event_type (from the canonical catalog of lifecycle events, such as collaborator.created or team_membership.added) to a capability that must exist in that type's action_catalog.

The mechanism is decoupled and durable. When a canonical event is recorded, the core materializes a pending reaction per interested integration. A background dispatcher claims those reactions in batches and calls the adapter's capability, injecting a _context block with event_id and attempt for idempotency. Each reaction has its own lifecycle: pendingin_progresssucceeded or failed, and goes to dead_lettered after exhausting its attempts.

Workflows with trigger.mode: event react to the same event stream by another path: instead of calling an adapter capability, they trigger a whole run when events match the declared types and payload_filters — with debounce to consolidate bursts.
Manifests are the starting point — a workflow is just another versioned manifest. And the ones that actually execute each integration-kind step are the adapters: continue to Integrations to see how a capability turns into a call to your system.