Build

Steps

A step is one bounded unit of work inside a loop run. Steps execute in order, checkpoint their state, and write output into run context for later steps.

run input
  step classify      kind=agent
    output saved as triage
  step notify        kind=action
    reads context.triage
  step approval      kind=interaction
    suspends until a response resolves it
  run output

Most loops should start with one agent step or one action step. Add suspending steps only when time, another system, or a person actually needs to answer before the run continues.

Step types

Step typeWhat it doesUse when
agentRuns one bounded agent turn. The agent receives instructions, may call tools, and returns output.The run needs judgment, synthesis, or tool use.
actionCalls one deterministic action with rendered parameters. It does not start an LLM turn.The run needs an exact side effect, such as posting to Slack or writing a table row.
sleepSuspends until a duration or timestamp.Time is the condition.
wait_for_eventSuspends until a matching source event arrives.Another system will send the answer as an event.
interactionCreates an interaction and suspends until the resolution policy resolves.A human or agent must approve, review, or answer.
loopStarts another loop in the same project and completes after the child run is created.One loop should hand work to another loop without waiting for its result.

Mobius also materializes cleanup steps from cleanup configuration. Authored loop specs cannot include cleanup in steps; the compiler rejects it so user steps and system cleanup stay separate.

The step shape

Every authored step has key, kind, and config. Optional fields control rendered input, retries, timeout behavior, and where output is saved.

steps:
  - key: classify
    name: Classify issue
    kind: agent
    input:
      title: "{{ .inputs.event.issue.title }}"
      body: "{{ .inputs.event.issue.body }}"
    config:
      agent_id: agt_triager
      instructions: |
        Classify the issue. Return JSON with label, severity, and summary.
      output_schema:
        type: object
        required: [label, severity, summary]
    retry:
      max_attempts: 2
      delay: 30s
    timeout:
      duration: 10m
      on_timeout: fail
    save_as: triage

key is stable inside the loop spec. name is display copy. kind selects which config parser the compiler uses. config is different for each step type.

Inputs and templates

Step input is rendered when the step starts. String leaves may use Go text/template expressions against the run input and prior step context:

input:
  issue_title: "{{ .inputs.event.issue.title }}"
  triage_label: "{{ .context.triage.label }}"

Use templates for values that change per run. Keep long instructions in the step config, and pass small structured values through input.

Event triggers normalize their payload under inputs.event and routing facts under inputs.meta. That means table events, provider events, email events, and HTTP trigger events all give steps one consistent place to read from.

Saved output

Each step produces an output. Mobius stores that output under save_as; if save_as is empty, it uses the step key.

steps:
  - key: summarize
    kind: agent
    save_as: brief
    config:
      agent_id: agt_scout
      instructions: "Summarize the run input in markdown."
  - key: post
    kind: action
    config:
      action_name: slack.message.post
      parameters:
        channel: "#eng"
        text: "{{ .context.brief }}"

Agent transcripts are not shared between steps by default. If one agent's work should guide a later step, have the producing step return a compact structured value and save it in context. This keeps later prompts inspectable and avoids dragging an entire transcript through the run.

Retries

retry.max_attempts is the total attempt count. 1 means no retry, and the server caps the value at 10.

retry:
  max_attempts: 3
  delay: 30s

Retries are for transient errors. Mark destructive actions carefully and make them idempotent, because Mobius cannot roll back a Slack message, GitHub label, or database write after an earlier attempt already reached the outside system.

Timeouts

timeout.duration bounds a step. Today timeout.on_timeout supports fail.

timeout:
  duration: 15m
  on_timeout: fail

For sleep, wait_for_event, and interaction, the timeout prevents a suspended run from waiting forever. For a run-level bound, use defaults.wall_clock_timeout; that stamps a run deadline and lets the reaper fail a run even if a step executor is still busy.

Suspending steps

Three step types can intentionally suspend a run:

Step typeResume conditionTimeout result
sleepThe timer expires.The run resumes by timer or fails if the run deadline passes first.
wait_for_eventA source event matches event_type, optional source_id, match, and condition.The step fails with wait_timed_out.
interactionThe interaction reaches completed.The step fails if its timeout expires or the interaction is cancelled.

A run in suspended is not stuck. It is waiting on a timer, event, worker job, or interaction. The run timeline tells you which step owns the wait.

Child loops

A loop step starts another loop in the same project:

steps:
  - key: start-review
    kind: loop
    config:
      loop_handle: review-every-pull-request
      inputs:
        pull_request: "{{ .inputs.event.pull_request }}"

The child run records parent lineage back to the parent run and step. The parent step completes once the child run is created, so use this shape when fire-and-forget is acceptable. If the parent needs the child result, wait for a source event such as run.completed instead.

Next