Skip to content

A workflow an assistant wrote

An assistant will write you an Argo Workflows manifest. What it cannot do is tell you whether the manifest holds together, because it has no parser and no compiler. dagweave has both, and dagweave_validate_workflow puts them behind an MCP tool the model can call as often as it likes.

This page walks one session, start to finish. The faults below are the ones the checker returns for that draft, and the compiled YAML is what comes back with the clean answer.

The draft here is Argo YAML. A draft written as dagweave IR JSON has one more tool ahead of the checker. dagweave_list_node_types returns the node types an IR document may place, and for one named type the JSON Schema its config takes, so the field names come off the registry instead of out of the model. It runs on workflows:read, the same scope as the checker.

Sketch me a nightly ETL as an Argo DAG: extract, then transform, then load. Stub the containers with alpine for now. The extract step should write a row count that transform picks up, and put a region parameter on the workflow, we will need it later.

What came back reads like Argo Workflows and would not run.

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: nightly-etl-
spec:
entrypoint: main
arguments:
parameters:
- name: region
value: eu-west-1
templates:
- name: main
dag:
tasks:
- name: extract
template: extract
- name: transform
template: tranform
dependencies: [extract]
arguments:
parameters:
- name: rows
value: "{{tasks.extrac.outputs.parameters.rows}}"
- name: load
template: load
dependencies: [transfrom]
- name: extract
outputs:
parameters:
- name: rows
valueFrom:
path: /tmp/rows
container:
image: alpine:3.19
command: [sh, -c, "echo 12 > /tmp/rows"]
- name: transform
inputs:
parameters:
- name: rows
contianer:
image: alpine:3.19
command: [sh, -c, "echo transforming"]
- name: load
container:
image: alpine:3.19
command: [sh, -c, "echo loading"]

Three of the mistakes in it are single-character slips inside names that otherwise look exactly right, and one is a key Argo has never had.

Instead of handing the draft to you, the assistant hands it to dagweave. One tool call, the whole document as a single string:

POST /api/v1/mcp HTTP/1.1
Host: api.dagweave.com
Authorization: Bearer dwk_...
Content-Type: application/json
{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"dagweave_validate_workflow",
"arguments":{"workflow":"apiVersion: argoproj.io/v1alpha1\nkind: Workflow\n..."}}}

The answer arrives the way every tool answer does, as one text block inside a tools/call result. That text is the body of POST /api/v1/workflows/validate character for character, so an assistant and a script get the same thing. Decoded and indented, it reads:

{
"valid": false,
"format": "argo-yaml",
"faults": [
{
"severity": "warning",
"rule": "unknown-field",
"path": "spec.templates[2].contianer",
"message": "unknown field \"contianer\" at spec.templates[2]: Argo has no such field, so whatever it was meant to configure will not happen. Did you mean \"container\"?"
},
{
"severity": "error",
"rule": "template-not-found",
"path": "spec.templates[0].dag.tasks[1].template",
"message": "spec.templates[0].dag.tasks[1].template names template \"tranform\", which this workflow does not define. This workflow defines: main, extract, transform, load."
},
{
"severity": "error",
"rule": "task-not-found",
"path": "spec.templates[0].dag.tasks[2].dependencies[0]",
"message": "task \"load\" depends on \"transfrom\", which template \"main\" does not contain. Tasks here: extract, transform, load."
},
{
"severity": "error",
"rule": "task-not-found",
"path": "spec.templates[0].dag.tasks[1].arguments.parameters[0].value",
"message": "this reads the output of \"extrac\", which template \"main\" does not contain. Tasks here: extract, transform, load."
},
{
"severity": "warning",
"rule": "unused-parameter",
"path": "spec.arguments.parameters[0]",
"message": "parameter \"region\" is declared and never read. Reference it as {{workflow.parameters.region}} or drop it."
},
{
"severity": "warning",
"rule": "unused-parameter",
"path": "spec.templates[2].inputs.parameters[0]",
"message": "template \"transform\" declares parameter \"rows\" and never reads it. Reference it as {{inputs.parameters.rows}} or drop it."
},
{
"severity": "error",
"rule": "compile",
"message": "dagweave could not compile this manifest: edge \"e1\" references unknown node"
}
]
}

path is the whole point. Every fault says where it sits in the document that was sent, so the fix has an address. Each message also carries the valid options next to the invalid one, so the correction does not have to be guessed at: this workflow defines main, extract, transform, load, and the tasks in that template are extract, transform, load.

severity splits what has to change from what merely wastes a line. The errors are the three broken references: a task calling a template by a name nothing defines, a dependencies entry naming a task that is not in the template, and a read of an output from a task that is not there either. Each one leaves the imported graph holding an edge with nothing at the far end, which is the compile fault at the bottom, and it is why no manifest came back.

The warnings would have run. contianer is a key Argo has no field for, so the container it was meant to describe would not exist and the step would start nothing. The unread parameters are the other kind of near-miss. region is set on the workflow and rows is declared as an input to the transform template, and no string anywhere in the document reads either name.

valid is false because at least one fault is an error. rule is the machine-readable half; the API reference lists the whole set. Nothing was stored and nothing was dispatched, and the same bytes always produce the same faults in the same order, so an assistant can call this on every edit.

The assistant fixes the three spellings, reads rows inside the transform step, and puts the region parameter to work rather than leaving it declared and dead:

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: nightly-etl-
spec:
entrypoint: main
arguments:
parameters:
- name: region
value: eu-west-1
templates:
- name: main
dag:
tasks:
- name: extract
template: extract
- name: transform
template: transform
dependencies: [extract]
arguments:
parameters:
- name: rows
value: "{{tasks.extract.outputs.parameters.rows}}"
- name: load
template: load
dependencies: [transform]
- name: extract
outputs:
parameters:
- name: rows
valueFrom:
path: /tmp/rows
container:
image: alpine:3.19
command: [sh, -c, "echo 12 > /tmp/rows"]
- name: transform
inputs:
parameters:
- name: rows
container:
image: alpine:3.19
command: [sh, -c, "echo transforming {{inputs.parameters.rows}} rows in {{workflow.parameters.region}}"]
- name: load
container:
image: alpine:3.19
command: [sh, -c, "echo loading"]

Same tool, same argument, second call:

{
"valid": true,
"format": "argo-yaml",
"faults": [],
"manifest": "apiVersion: argoproj.io/v1alpha1\nkind: Workflow\n..."
}

manifest is what dagweave compiles the draft to, and it comes back on any answer where the draft compiled at all. It is dagweave’s output rather than your YAML reformatted, which is worth seeing before you commit to anything:

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: nightly-etl-
spec:
arguments:
parameters:
- name: region
value: eu-west-1
entrypoint: main
templates:
- dag:
tasks:
- name: extract
template: extract
- arguments:
parameters:
- name: rows
value: '{{tasks.extract.outputs.parameters.rows}}'
depends: extract
name: transform
template: transform
- depends: transform
name: load
template: container-622ab665c8b8
name: main
- container:
command:
- sh
- -c
- echo 12 > /tmp/rows
image: alpine:3.19
name: extract
outputs:
parameters:
- name: rows
valueFrom:
path: /tmp/rows
- container:
command:
- sh
- -c
- echo transforming {{inputs.parameters.rows}} rows in {{workflow.parameters.region}}
image: alpine:3.19
inputs:
parameters:
- name: rows
name: transform
- container:
command:
- sh
- -c
- echo loading
image: alpine:3.19
name: container-622ab665c8b8

Two things moved. dependencies came back as depends, which is the form dagweave emits. And the plain container step is now called container-622ab665c8b8: a step dagweave recognises as one of its own node types is rebuilt from that node type under a content-derived name, and every reference to it is rewritten to match. A step it carries through verbatim keeps the name it was given, which is why extract and transform are still extract and transform.

The corrected manifest goes onto the canvas the same way any other Argo manifest does. Import and round-trip walks that loop. What lands there is three steps and two arrows: the shape you asked for, or not. A misspelled name cannot reach it, because a reference that resolves to nothing does not compile.

From there it is an ordinary workflow. Edit it, publish it to git, attach a trigger, dispatch it to a connected cluster.

What the check knows, and what it does not

Section titled “What the check knows, and what it does not”

The draft is read through the same importer and compiler the canvas uses, so a document that passes here is a document the canvas will open. Field names are checked against the Argo Workflows types, and every template, task, dependency and sibling-output reference has to resolve inside the document that was sent.

What it cannot know is anything outside the document. Whether alpine:3.19 pulls, whether the path /tmp/rows exists at the moment it is read, whether the service account can do what the container tries to do. A clean answer means the grammar and the wiring are right, and what is left to go wrong is your infrastructure.

Checking a draft needs workflows:read, the same scope as listing workflows. It reads a document the caller sent and touches nothing stored, so the checker can be granted on its own. Starting a run needs runs:write, which is a separate decision and a separate token if you want it to be. The MCP server page covers connecting a client, the read tools, and what a refusal looks like.