A workflow is a typed DAG of steps. Authors write the graph in HCL, and Condukt normalizes it to the canonical workflow document that the engine executes, condukt check validates, and visual tools can read.

There is no project layout, manifest, or lockfile. To run a workflow you point the engine at a .hcl or .exs path. HCL workflows use the workflow "name" label as the run name. .exs workflow maps may set name; if they omit it, Condukt falls back to the file basename.

A first workflow

hello.hcl:

workflow "hello" {
  input "name" {
    type = "string"
  }

  cmd "greet" {
    argv = ["echo", "Hello, ${input.name}"]
  }

  output = task.greet.stdout
}

Run it with the standalone engine or with Mix:

condukt run hello.hcl --input '{"name":"world"}'
mix condukt.run hello.hcl --input '{"name":"world"}'

The resolved output expression is printed on stdout. Strings are printed as is, other values are JSON-encoded.

Why HCL

HCL gives workflow authors a purpose-built configuration language instead of an embedded programming language. Blocks declare graph nodes, needs declares edges, and references make data flow visible:

workflow "checks" {
  cmd "lint" {
    argv = ["mix", "format", "--check-formatted"]
  }

  cmd "test" {
    argv = ["mix", "test"]
  }

  cmd "package" {
    needs = ["lint", "test"]
    argv = ["mix", "hex.build"]
  }

  output = {
    lint = task.lint.ok,
    test = task.test.ok,
    package = task.package.ok
  }
}

This graph has two independent roots, lint and test, followed by package. A visualizer can draw it directly from the normalized document:

flowchart LR
  lint --> package
  test --> package

When an HCL step reads task.<id>, that step must also declare <id> in needs. This keeps execution order and data dependencies visible in the authored file:

workflow "release_notes" {
  runtime {
    model = "openai:gpt-4.1-mini"
  }

  cmd "version" {
    argv = ["sh", "-c", "git describe --tags --always"]
  }

  agent "draft" {
    needs = ["version"]
    input = "Draft release notes for ${task.version.stdout}"
  }

  output = task.draft.output
}

Document Shape

The normalized document shape is:

{
  "name": "review-pr",            // from workflow label or optional .exs field
  "inputs": { ... },              // typed input map
  "runtime": { ... },             // optional runtime defaults
  "steps": { "<id>": { ... } },   // map of step id to step definition
  "output": "<expression>"        // optional, what `condukt run` prints
}

A step has the shape:

{
  "kind": "cmd" | "agent" | "http" | "tool" | "map",
  "needs": ["other_step"],        // explicit dependencies
  "when": "<expression>"          // optional gate
}

The normalized document is internal. There is no published workflow JSON Schema, and JSON and YAML are not supported workflow file formats.

HCL syntax

The top level contains one workflow "name" block. Inputs are declared with input "id" blocks, and steps are declared with kind blocks:

workflow "deploy" {
  runtime {
    model = "openai:gpt-4.1-mini"
    sandbox = "local"
    cwd = "."
  }

  input "environment" {
    type = "string"
    enum = ["staging", "production"]
  }

  http "fetch_version" {
    method = "GET"
    url = "https://example.test/version"
    expect_status = 200
  }

  cmd "deploy" {
    needs = ["fetch_version"]
    when = input.environment == "production"
    argv = ["./scripts/deploy", task.fetch_version.body.version]
    env = {
      DEPLOY_ENV = input.environment
    }
  }

  output = {
    deployed = task.deploy.ok,
    version = task.fetch_version.body.version
  }
}

Inside HCL:

  • input.name compiles to ${inputs.name}.
  • task.fetch.body compiles to ${steps.fetch.body}.
  • A bare HCL reference, such as input.name, preserves the referenced value's type.
  • A string template, such as "Hello, ${input.name}", interpolates the value into a string.

The optional runtime block declares file-level defaults:

  • model: default ReqLLM model spec for agent steps that do not set their own model.
  • sandbox: local or virtual. Command steps and built-in tools run through this sandbox when set.
  • cwd: default working directory for command steps, tools, and sandbox initialization.

Runtime values are defaults. Library callers can override them when they call Condukt.Workflows.run/3.

Step kinds

  • cmd: runs an executable on the host, or through the configured sandbox when one is set. Fields: argv (list of strings, required), cwd (optional), env (optional dict). Outputs: stdout, exit_code, ok.
  • agent: runs an LLM-driven step. Fields: input (required, any), model (optional when a runtime or caller model is set), tools (optional list of tool ids), system (optional system prompt), output_schema (optional JSON Schema for structured output). Output: output and ok.
  • http: deterministic HTTP call. Fields: method, url, headers, body, expect_status. Output: status, headers, body.
  • tool: invokes a registered host tool by id. Fields: id, args. Output: output and ok.
  • map: fan-out. Fields: over (expression resolving to a list), as (binding name), do (a nested step definition). Output: a list of the nested step's outputs in input order.

Example fan-out:

workflow "summarize_files" {
  tool "glob" {
    id = "Glob"
    args = {
      pattern = "guides/*.md"
    }
  }

  map "summaries" {
    needs = ["glob"]
    over = task.glob.output
    as = "file"

    tool {
      id = "Read"
      args = {
        file_path = file
      }
    }
  }

  output = task.summaries
}

Expressions

Expressions are evaluated against inputs, steps, and, inside a map step, the as binding. HCL authors normally use the singular aliases input and task; the compiler rewrites them to the canonical expression roots.

Supported:

  • Member access: input.name, task.fetch.body.title
  • Indexing: task.list.items[0], obj["a key"], negative indices
  • Comparisons: ==, !=, <, <=, >, >=
  • Boolean: &&, ||, !
  • Unary minus: -1, xs[-1]
  • Literals: strings, numbers, booleans, null, parens
  • Type-aware formatters in canonical expressions: ${var:json}, ${var:csv}

Not supported:

  • Arbitrary function calls, regex, or arithmetic beyond comparisons. Anything more substantial belongs in a cmd, agent, or tool step.

A when expression must evaluate to a boolean. Member access on null returns null so a reference to a skipped step degrades gracefully; typos against a real value still raise a loud error.

Skipping and cascade

If a step's when evaluates to false, the step is skipped. Any downstream step whose declared or inferred dependencies include a skipped step is also skipped. The step's slot in steps.<id> is set to null.

EXS

HCL is the authored workflow format. For lower-level generation, an .exs file may return a workflow map directly:

%{
  name: "hello",
  inputs: %{name: %{type: :string}},
  steps: %{
    greet: %{
      kind: :cmd,
      argv: ["echo", "Hello, ${inputs.name}"]
    }
  },
  output: "${steps.greet.stdout}"
}

Atom keys and atom values, other than nil, true, and false, are normalized to strings before validation. Use this only when you need Elixir to generate the document programmatically.

condukt run hello.hcl loads and validates the workflow before execution.

Evaluating as a library

Elixir callers can pass HCL content directly:

workflow_source = """
workflow "release_notes" {
  runtime {
    model = "openai:gpt-4.1-mini"
    sandbox = "local"
  }

  cmd "version" {
    argv = ["sh", "-c", "git describe --tags --always"]
  }

  agent "draft" {
    needs = ["version"]
    input = "Draft release notes for ${task.version.stdout}"
  }

  output = task.draft.output
}
"""

{:ok, output} =
  Condukt.Workflows.run(workflow_source, %{},
    model: "openai:gpt-4.1-mini",
    sandbox: {Condukt.Sandbox.Local, cwd: File.cwd!()}
  )

The third argument accepts the same runtime options used by workflow execution: :model, :sandbox, :cwd, :tools, :secrets, :req_options, and :agent_options. These options override the workflow's runtime block, so applications can keep portable workflow files while choosing the model, sandbox, and working directory at the library boundary. If the workflow lives in a file, read the file first and pass the content to Condukt.Workflows.run/3:

workflow_source = File.read!("release_notes.hcl")
{:ok, output} = Condukt.Workflows.run(workflow_source, %{})

Condukt.Workflows.load/1 is only needed when you explicitly want a reusable Condukt.Workflows.Document, or when loading a .exs workflow generator file.

Validating a workflow

condukt check PATH parses and validates the workflow without executing it. It accepts .hcl and .exs paths.

condukt check review-pr.hcl

Use it in CI or as part of an LLM authoring loop: generate, check, fix, repeat.

Future direction

These are planned but not yet implemented:

  • Versioned helper packages for generating repeated HCL fragments.
  • Optional --lock mode that records SHA-256 per fetched URL and verifies on later runs.
  • Triggers (condukt.trigger.webhook, condukt.schedule.cron) and condukt serve PATH to host webhook and cron-driven runs.
  • A visual editor that reads and writes the same normalized document.