Files
lda-wf/docs/workflow_drafts.md
T

23 KiB

Workflow Drafts

Workflow drafts are the preferred authoring format for LLM and human clients.

They sit above the raw Workflow model:

WorkflowDraft -> RawWorkflowPlan -> WorkflowArtifact -> Deployment

The draft format is intentionally explicit and patchable. It avoids asking an LLM client to write the full core model directly, while still compiling into the same validated workflow plan used by the runtime.

Raw plans still exist as an escape hatch for advanced clients and compiler outputs. New authoring flows should normally start with drafts.

Why Drafts Exist

The raw workflow model is normalized for execution. That makes it precise, but not always pleasant as an interactive authoring target.

Drafts optimize for:

  • stable JSON shapes that are easy to inspect
  • explicit step ids instead of implicit Python object references
  • targeted fixes through JSON Patch
  • a clear place to validate before saving
  • preserving the existing raw workflow/runtime boundary

Drafts do not change workflow semantics. They compile into the same core plan shape and then run through normal validation.

Draft Shape

A minimal draft looks like this:

{
  "name": "echo",
  "input_schema": {
    "type": "object",
    "properties": {
      "text": {
        "type": "string"
      }
    },
    "required": ["text"]
  },
  "state_schema": {
    "type": "object",
    "properties": {
      "echoed": {
        "type": "string",
        "reducer": "wf.std.replace"
      }
    }
  },
  "output_schema": {
    "type": "object",
    "properties": {
      "echoed": {
        "type": "string"
      }
    },
    "required": ["echoed"]
  },
  "start": "echo",
  "steps": {
    "echo": {
      "use": "demo.personal.echo_tool",
      "input": [
        {
          "target": "text",
          "path": "input.text"
        }
      ],
      "output": [
        {
          "source": "echoed",
          "target": "state.echoed"
        }
      ]
    }
  },
  "routes": {
    "echo": {
      "ok": "__end__"
    }
  }
}

Important details:

  • steps are keyed by stable ids so patches do not depend on array positions.
  • start names one step id.
  • routes map step outcomes to another step id or __end__.
  • top-level outcomes declares public workflow terminal outcomes; if omitted, it defaults to ["ok"].
  • top-level output maps final graph values such as state.result into the public workflow output payload. Step-level output only writes a node result into workflow state.
  • capability may be concrete during exploration, such as demo.personal.echo_tool.
  • When saved with source bindings, concrete refs can be normalized to logical refs such as demo.echo_tool.

Two Outputs, Different Shapes

Drafts have two fields named output, but they do different jobs.

Step-Level steps.<id>.output

Step output writes a node's local return payload into workflow state. It uses source / target:

{
  "source": "text",
  "target": "state.result_text"
}

Read this as:

node output.text -> state.result_text

Top-Level output

Top-level workflow output projects graph values into the final public workflow output payload. It uses input-binding shape: path / target, not source / target. Do not use step output source here; it belongs to step-level node output bindings only.

{
  "path": "state.result_text",
  "target": "result_text"
}

Read this as:

state.result_text -> workflow output.result_text

If top-level output is empty, the runtime keeps the legacy same-name fallback: for every field in output_schema, it copies the top-level state field with the same name when present. That fallback is convenient, but explicit output projection is clearer for new workflows. Clearing canonical output bindings restores this fallback; it does not mean that the public output is always empty.

Canonical workflow-output replacement accepts an ordered union of path and literal bindings. Nested input.* and state.* sources can project missing nested output-schema fields from their declared source schemas. Literal values and context.* paths require an already-declared output target; literals are validated against that target and never used to infer a schema. Equal or ancestor/descendant output targets are rejected so the ordered list has one unambiguous public projection.

Explicit Outputs And Error Outcomes

Use __end__ as the compact terminal path for the normal ok workflow outcome. For any other public terminal outcome, add an explicit end step and route to it. The end step itself is terminal; do not add an edge out of it.

This complete draft shape:

{
  "name": "echo_with_error",
  "input_schema": {
    "type": "object",
    "properties": {
      "text": { "type": "string" },
      "fail": { "type": "boolean" }
    },
    "required": ["text"]
  },
  "state_schema": {
    "type": "object",
    "properties": {
      "raw": {
        "type": "object",
        "properties": {
          "echoed": { "type": "string" }
        }
      }
    }
  },
  "output_schema": {
    "type": "object",
    "properties": {
      "message": { "type": "string" }
    }
  },
  "outcomes": ["ok", "error"],
  "output": [
    {
      "target": "message",
      "path": "state.raw.echoed"
    }
  ],
  "start": "call",
  "steps": {
    "call": {
      "use": "demo.echo",
      "input": [
        {
          "target": "text",
          "path": "input.text"
        },
        {
          "target": "fail",
          "path": "input.fail"
        }
      ],
      "output": [
        {
          "source": "echoed",
          "target": "state.raw.echoed"
        }
      ]
    },
    "end_error": {
      "end": { "outcome": "error" }
    }
  },
  "routes": {
    "call": {
      "ok": "__end__",
      "error": "end_error"
    }
  }
}

Read it as:

  • call.ok finishes the workflow with outcome ok.
  • call.error executes end_error, which finishes the workflow with outcome error.
  • both terminal paths project state.raw.echoed into public output field message.

Binding Shape

Draft use steps use the same canonical binding structs as core NodeUse:

{
  "input": [
    {
      "target": "message",
      "path": "input.text"
    },
    {
      "target": "limit",
      "value": 3
    }
  ],
  "output": [
    {
      "source": "echoed",
      "target": "state.echoed"
    }
  ]
}

Legacy draft maps in, with, and out are still accepted as parse-only compatibility input. Valid drafts are saved and returned with canonical input and output binding lists.

Graph source paths in in normally start with input., state., or context.. Node-local paths do not use those prefixes; they are paths inside the target capability's input or output payload.

In canonical string paths, segments are joined with dots. For nested objects, use "input.user.name". For literal dots in property names, use TOML quoting: state."person.name".

For example, this canonical input/output pair:

{
  "input": [
    {
      "target": "user.name",
      "path": "input.user.name"
    },
    {
      "target": "job.title",
      "path": "state.job.title"
    }
  ],
  "output": [
    {
      "source": "user.age",
      "target": "state.person.age"
    },
    {
      "source": "job.years",
      "target": "state.experience.years"
    }
  ]
}

Read that as:

  • input.user.name -> local input user.name
  • state.job.title -> local input job.title
  • local output user.age -> state.person.age
  • local output job.years -> state.experience.years

Do not reverse the direction. This is wrong:

{
  "input": [
    {
      "target": "input.text",
      "path": "message"
    }
  ]
}

That asks the runtime to read from graph path message and write into a node-local input field literally named input.text.

Do not put constants in path bindings. This is wrong:

{
  "input": [
    {
      "target": "value",
      "path": "input.CLICKED"
    }
  ]
}

Use an input value binding for static node-local values instead.

Step Kinds

use

Calls a workflow capability.

{
  "use": "demo.personal.echo_tool",
  "input": [
    {
      "target": "text",
      "path": "input.text"
    }
  ],
  "output": [
    {
      "source": "echoed",
      "target": "state.echoed"
    }
  ]
}

Use this for normal node calls, including generated workflow wrappers around MCP tools and local wf.std capabilities.

use steps can also provide static node-local input values. Use this for hardcoded strings, booleans, numbers, and small JSON values that are part of the graph definition:

{
  "use": "wf.std.constant",
  "input": [
    {
      "target": "value",
      "value": "CLICKED"
    }
  ],
  "output": [
    {
      "source": "value",
      "target": "state.wait_text"
    }
  ]
}

Static values are not path mappings. Use {"target": ..., "value": ...} for literal JSON values. Invalid draft step shapes are rejected instead of being silently replaced with a placeholder step.

Generated MCP tool wrappers are intentionally naive. They normally expose both ok and error outcomes, because MCP tool calls can report transport/provider errors separately from useful output. Drafts should wire both outcomes:

{
  "routes": {
    "call_tool": {
      "ok": "__end__",
      "error": "tool_error"
    }
  }
}

Use a wrapper node or wf.std.runtime_error for the error path. Do not leave the generated error outcome dangling.

foreach

Runs a child body over items. Draft foreach mirrors the core foreach policy model: use item_error and concurrent, not draft-only field names.

{
  "foreach": {
    "over": "state.items",
    "as": "item",
    "mode": "serial",
    "item_error": "fail"
  }
}

Concurrent foreach uses the same canonical policy shape as core:

{
  "foreach": {
    "over": "state.items",
    "as": "item",
    "mode": "concurrent",
    "concurrent": {
      "max_active": 2,
      "max_outstanding": 4
    },
    "item_error": {
      "action": "collect",
      "collect_to": "state.item_errors"
    }
  }
}

item_error accepts "fail" and "skip" as shorthand. collect needs an explicit destination, so item_error: "collect" is invalid; use the object shape and provide collect_to. Deprecated on_item_error and parallel are accepted only as parse-only compatibility and dump back to canonical fields.

interrupt

Declares an interrupting step.

{
  "interrupt": {
    "kind": "input",
    "request": [
      {
        "target": "question",
        "path": "state.question"
      }
    ],
    "resume": [
      {
        "source": "answer",
        "target": "state.answer"
      }
    ],
    "outcomes": ["resumed", "cancelled"]
  }
}

Draft interrupts use the same binding shapes as core interrupt nodes: request builds the public interrupt payload, while resume maps the payload provided on resume back into workflow state. Older map-shaped request and resume values are accepted only as parse compatibility and dump back to the canonical list shape.

Saved interrupting artifacts can pause and resume through deployment runs. The run response includes a durable run_id; pass that to wf.workflow.resume_run with the resume payload. Before advancing a resumed run, the platform revalidates its pinned dependency environment and can return resume_readiness="blocked" without consuming input.

end

Declares an explicit workflow terminal outcome.

{
  "end": {
    "outcome": "error"
  }
}

Use explicit end steps for non-ok workflow outcomes. The legacy __end__ destination remains the shorthand for public workflow outcome ok.

when

Creates one boolean decision step. The condition uses the same JSON shape as wf_core.models.conditions.Condition.

{
  "when": {
    "if": {
      "op": "ge",
      "left": {
        "path": "state.count"
      },
      "right": {
        "value": 1
      }
    },
    "then": "positive",
    "otherwise": "zero"
  }
}

The draft adapter lowers this through WorkflowBuilder.when(). The draft step id becomes the generated condition entry id, so other routes can target it.

match

Matches one graph value against ordered equality cases.

{
  "match": {
    "value": "state.status",
    "cases": [
      {
        "equals": "ready",
        "then": "run"
      },
      {
        "equals": "waiting",
        "then": "pause"
      }
    ],
    "default": "__end__"
  }
}

Cases are a list rather than a JSON object so values such as 1, "1", and true are not silently coerced into object keys. The draft adapter lowers this through WorkflowBuilder.match().

choose

Creates an ordered first-true decision chain.

{
  "choose": {
    "clauses": [
      {
        "if": {
          "op": "gt",
          "left": {
            "path": "state.score"
          },
          "right": {
            "value": 80
          }
        },
        "then": "high"
      },
      {
        "if": {
          "op": "exists",
          "path": "state.fallback"
        },
        "then": "fallback"
      }
    ],
    "default": "__end__"
  }
}

choose lowers through WorkflowBuilder.choose() and expands to generated condition nodes. match, when, and choose replace the deprecated route() concept for draft JSON; there is intentionally no draft route step kind.

Draft Tools

The workflow MCP surface exposes these draft tools:

Tool Purpose
wf.workflow.validate_draft Validate draft shape and compiled workflow without saving.
wf.workflow.compile_draft Return the compiled raw plan plus dependency summaries.
wf.workflow.patch_draft Apply RFC 6902 JSON Patch and validate the patched draft.
wf.workflow.create_artifact_from_draft Compile, normalize, and save a workflow artifact.
wf.workflow.branch_draft Update routes for an existing step in one revision.
wf.workflow.handle_draft Route multiple source step outcomes to a common target.
wf.workflow.compile_draft_workspace Return compiled plan plus capabilities without mutation.

Use validate_draft before saving. Use patch_draft when an LLM client needs a small targeted correction instead of rewriting the whole workflow.

Draft Workspaces

Stateless draft tools require the caller to resend the whole draft. Draft workspaces are the preferred LLM authoring flow when a client will patch a workflow over several turns.

Capability-Free CLI Flow

Create an empty workspace when the workflow begins with control flow, an interrupt, an end step, or a subgraph rather than a capability:

wf draft create report_ws --name report_workflow
wf draft add end report_ws --revision 1 --step finish --outcome error
wf draft set-start report_ws --revision 2 --step finish
wf draft set-contract report_ws --revision 3 --outcome error
wf draft validate report_ws

Revision 1 is intentionally incomplete and therefore invalid. Draft edits persist representable intermediate states together with diagnostics, so an entry point or route may refer forward to a step that a later revision adds. The final validation is the gate before saving an artifact.

Pass --input-schema-file, --state-schema-file, or --output-schema-file to draft create or draft set-contract when the workflow contract is explicit. Each file must contain one JSON object. These options replace the complete selected schema; repeated --outcome flags replace the complete public outcome list. State-schema replacement preserves JSON Schema annotations such as reducer metadata exactly as supplied.

Use capability-backed creation when the first step should derive its contract and wrapper hints from a known capability:

wf draft create report_ws --capability local.lda_docs.read_documents

For selected fields from a capability contract, prefer wf draft add capability or wf draft bind so schema projection remains tied to that node. Use set-contract for deliberate whole-schema/outcome replacement. Reserve JSON Patch for field-level schema surgery that focused commands do not cover.

The capability-bootstrap MCP workspace flow is:

  1. wf.workflow.create_minimal_draft_workspace
  2. wf.workflow.get_draft_workspace
  3. wf.workflow.patch_draft_workspace
  4. repeat get/patch until valid
  5. wf.workflow.create_artifact_from_workspace for a full workflow, or wf.workflow.create_wrapper_from_workspace for a reusable callable wrapper

Workspaces are mutable and revisioned. Artifacts are immutable and versioned. Patch calls must include the current revision; stale revisions return revision_conflict and do not mutate the workspace.

create_minimal_draft_workspace is intentionally only a bootstrapper. For naive MCP wrappers with an error outcome, it wires wf.std.runtime_error with a static default message unless error_message_source is explicitly provided. It does not guess that a normal output state path is also an error message. Provider-specific error envelopes still belong in saved wrapper artifacts or follow-up patches.

error_message_source accepts the same canonical string path shape used by other mapping fields, for example "state.error_message". Legacy structural shapes remain accepted for compatibility.

In MCP Inspector, workspace mutation tools accept a single request object. This is deliberate: the request object carries descriptions and validation for the authoring envelope while raw JSON Schema fields remain plain JSON objects.

create_wrapper_from_workspace is intentionally just the wrapper-specific save path. It validates and compiles the same draft workspace, but fixes the saved artifact kind to wrapper so clients do not need to pass kind manually.

Focused Edit Commands

For routine edits, prefer focused commands over hand-written JSON Patch:

wf draft set-start <workspace_id> --revision <n> --step <step_id>
wf draft set-contract <workspace_id> --revision <n> --input-schema-file input.schema.json --state-schema-file state.schema.json --output-schema-file output.schema.json --outcome ok
wf draft set-name <workspace_id> --revision <n> --name <name>
wf draft set-route <workspace_id> --revision <n> --step <step_id> --outcome ok --to <target_step_or___end__>
wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.text=text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --bindings-file output-bindings.json
wf draft set-output <workspace_id> --revision <n> --step <step_id> --clear
wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other
wf draft set-workflow-output <workspace_id> --revision <n> \
  --map state.value=result --value format='"markdown"'
wf draft set-workflow-output <workspace_id> --revision <n> \
  --bindings-file output-bindings.json
wf draft set-workflow-output <workspace_id> --revision <n> --clear
wf draft set-workflow-output <workspace_id> --revision <n> \
  --merge --map state.other=other
wf draft add capability report --revision 3 --step publish \
  --capability local.report.publish --description "Publish report" \
  --retry 2 --timeout-seconds 30 \
  --input state.report.title=request.title \
  --value request.format='"markdown"' --route ok=__end__
wf draft update capability report --revision 4 --step publish \
  --clear-description --retry 0 --clear-timeout
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=tool_error
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id>

set-output replaces the step's complete ordered canonical binding list. Repeat --map LOCAL_SOURCE=STATE_TARGET to preserve order and fan one local source out to multiple state targets, or use --bindings-file with the exact JSON list exported by draft inspect --include-draft. --clear is the explicit empty replacement. The legacy --merge --map path is compatibility-only and potentially lossy because a map cannot preserve ordering or repeated-source fan-out.

set-workflow-output follows the same canonical replacement model for the top-level WorkflowDraft.output list. Use repeated --map and --value flags for a small ordered edit, or --bindings-file for an exact round trip from draft inspect --include-draft. --clear writes an empty list and restores the implicit same-name state fallback. Literal and context.* bindings need declared output targets; nested input.* and state.* paths may project their declared source schema into missing nested output fields. The compatibility --merge --map form remains available only for lossy map edits and cannot preserve literals, order, or repeated-source fan-out.

add capability accepts metadata plus ordered path and literal input bindings. update capability is presence-aware: omitted fields remain unchanged, while --clear-description, --clear-retry, and --clear-timeout remove only the selected metadata. Any --input/--value update replaces the complete input list; --clear-input writes an empty list. Use --bindings-file when path and literal records must retain their exact interleaving.

The focused update preserves the step's use, routes, and outputs. Route and output changes remain separate focused operations. Replacing use is not an update operation; remove and re-add the capability step explicitly.

Use draft patch when these focused commands do not cover the structural edit.

Drafts are not raw workflow plans. Drafts use steps, routes, and step field use. Raw plans use nodes, edges, and node field node.

Patching Drafts

patch_draft accepts JSON Patch operations.

Example:

[
  {
    "op": "replace",
    "path": "/steps/echo/in/input.text",
    "value": "message"
  },
  {
    "op": "add",
    "path": "/routes/echo/error",
    "value": "__end__"
  }
]

Patch the draft, not the compiled raw plan. The raw plan is an implementation boundary and may be harder for an LLM client to repair correctly.

Saving And Running

The normal path is:

wf.workflow.validate_draft
wf.workflow.create_artifact_from_draft
wf.workflow.save_deployment
wf.workflow.validate_deployment
wf.workflow.run_deployment

create_artifact_from_draft may return suggested bindings for local system sources such as:

{
  "wf.std": "wf.std",
  "wf.mcp": "wf.mcp"
}

Keep those explicit when deployment validation reports binding_missing.

Raw Plan Escape Hatch

Use wf.workflow.create_artifact_from_plan only when the caller already has a compiled raw workflow plan or is intentionally bypassing the draft layer.

For normal interactive authoring, prefer:

draft -> validate -> patch if needed -> create artifact from draft

That keeps errors local and gives the author a smaller object to reason about.