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.
The prompt
Section titled “The prompt”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.
The draft
Section titled “The draft”What came back reads like Argo Workflows and would not run.
apiVersion: argoproj.io/v1alpha1kind: Workflowmetadata: 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.
The check
Section titled “The check”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.1Host: api.dagweave.comAuthorization: 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" } ]}Reading the answer
Section titled “Reading the answer”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 correction
Section titled “The correction”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/v1alpha1kind: Workflowmetadata: 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..."}What the draft became
Section titled “What the draft became”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/v1alpha1kind: Workflowmetadata: 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-622ab665c8b8Two 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.
On the canvas
Section titled “On the canvas”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.
Scopes
Section titled “Scopes”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.