DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.Medium on

Microsoft Turned Agent Orchestration Into a YAML File. I Built the Gate Before I’d Trust It.

A couple of days ago I read a piece on here by Mikhail Petrusheuski about Microsoft’s Agent Framework moving multi-agent orchestration out of C# and into YAML. The diagnosis was right: workflows that used to live buried in a call graph are now plain configuration files, which means production agent behavior can change without a single line of C# or Python being touched. The piece ends on a good instinct too: that this deserves a stricter review gate than a normal config change gets.

What it didn’t do was build one. It stopped at “I’d add a review gate first,” which is a fair opinion to have and not much more than that. I went and read the actual schema, wrote a workflow that does something you would not want to ship by accident, and then wrote the gate. This is that writeup, with the YAML, the script, and the CI wiring, plus a local version that doesn’t depend on Azure at all.

What actually shipped

Agent Framework hit 1.0 with declarative workflows available for both SDKs: agent-framework-declarative on the Python side, Microsoft.Agents.AI.Workflows.Declarative on .NET. The pitch from Microsoft's own devblog is direct: a declarative workflow "loads into the same Workflow type as a code-first one, so it runs, streams, and composes just the same." You give up nothing at runtime.

That sentence is the whole story. It means a YAML file and a hand-written C# workflow are not two different tiers of the same feature, they’re the same feature with two different authoring surfaces. Loading looks like this on either side:

from agent_framework.declarative import WorkflowFactory
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml_path("support_router.yaml")
result = await workflow.run({"task": "research"})

using Microsoft.Agents.AI.Workflows.Declarative;
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>("CustomerSupport.yaml", options);
Enter fullscreen mode Exit fullscreen mode

Once loaded, the YAML defines the same things a hand-written workflow would: which agent gets invoked, what tools get called, where the workflow branches, and where it pauses for a human. Here’s a trimmed real example from Microsoft’s own docs, a triage agent routing into one of two specialists:

kind: Workflow
trigger:
  kind: OnConversationStart
  id: content_workflow
  actions:
    - kind: ConditionGroup
      id: route_request
      conditions:
        - condition: =System.LastMessage.Text = "research"
          id: research_branch
          actions:
            - kind: InvokeAzureAgent
              id: researcher
              agent:
                name: ResearcherAgent
              output:
                responseObject: Local.researchResult
        - condition: =System.LastMessage.Text = "write"
          id: write_branch
          actions:
            - kind: InvokeAzureAgent
              id: writer
              agent:
                name: WriterAgent
      elseActions:
        - kind: SendActivity
          activity:
            text: "Please specify research or write"
Enter fullscreen mode Exit fullscreen mode

This reads like config. It is not config in the sense a reviewer’s brain treats config, a max_retries: 3 line that's hard to get badly wrong. It's a program. It has branches, loops (Foreach, GotoAction), variable state (Local.*, Workflow.Outputs.*), and it can reach outside the conversation entirely through HttpRequestAction, InvokeMcpTool, and InvokeFunctionTool. The schema documentation lists all of this plainly. Nobody is hiding it. The gap is that a YAML diff looks small and safe by convention, and this particular YAML dialect quietly stopped being safe by convention the moment it got wired to production agents.

Where the actual risk sits

I went through the full action list in the docs and sorted it by what it can actually do once a workflow is running against real agents and real conversations.

+---------------------------+------------------------------------------------+
| Action kind | Why it needs a second look |
+---------------------------+------------------------------------------------+
| HttpRequestAction | Outbound network call, any method, any URL, |
| | including POST/PUT/DELETE to internal services |
+---------------------------+------------------------------------------------+
| InvokeMcpTool | Calls a tool on an external MCP server, whatever |
| | that server is configured to do |
+---------------------------+------------------------------------------------+
| InvokeFunctionTool | Runs application code chosen by the workflow |
| | author, not gated by the developer who wrote it |
+---------------------------+------------------------------------------------+
| AddConversationMessage | Can inject a system-role message a model will |
| | treat as trusted instruction, not user input |
+---------------------------+------------------------------------------------+
| CopyConversationMessages, | Moves or reads messages across conversation ids, |
| RetrieveConversationMessages | a plausible cross-session or cross-tenant leak |
+---------------------------+------------------------------------------------+
| GotoAction | Jumps execution to another action id, which breaks |
| | the "read top to bottom" assumption a reviewer |
| | brings to everything else in the file |
+---------------------------+------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

None of these are exotic. They’re the actions that make declarative workflows actually useful, calling out to a billing API, hitting an internal tool over MCP, running a function the rest of your app already has. The problem isn’t that they exist. The problem is that they sit in the same visual register as SetVariable and SendActivity, which do nothing outside the conversation at all, and a fast reviewer skimming a diff has no built-in signal telling them which of these fifteen lines is the one that moves money.

Here’s a small workflow that shows exactly how this goes wrong in practice. It classifies a refund request and, if the amount is over a threshold, calls a billing API directly.

name: refund-workflow
description: Routes a refund request and calls the billing API directly
actions:
  - kind: SetVariable
    id: init
    variable: Local.status
    value: started
  - kind: ConditionGroup
    id: route_request
    conditions:
      - condition: =Workflow.Inputs.amount > 100
        id: high_value
        actions:
          - kind: InvokeAzureAgent
            id: triage
            agent:
              name: TriageAgent
            output:
              responseObject: Local.triageResult
          - kind: HttpRequestAction
            id: issue_refund
            url: https://billing.internal.example.com/api/refunds
            method: POST
            body:
              kind: json
            response: Local.refundResponse
    elseActions:
      - kind: SendActivity
        activity:
          text: "Refund too small to process automatically"
  - kind: EndWorkflow
Enter fullscreen mode Exit fullscreen mode

If this landed as a two-line addition to an existing workflow that used to just classify and notify, the diff would show one new kind: HttpRequestAction block nested inside a branch that already existed. On GitHub that's a green "+8 -0" badge on a file extension your review tooling doesn't syntax-highlight specially. Nothing about the review surface tells anyone that a triage agent just started authorizing outbound refunds with no person in the loop.

The gate

So I wrote one. It’s a static checker, not a runtime policy engine: it reads the action tree the way a human reviewer is supposed to, and fails the build if it finds a side-effecting action with no Question or RequestExternalInput step earlier in the same branch.

#!/usr/bin/env python3
"""
workflow_guard.pyA static reviewer for Microsoft Agent Framework declarative workflow YAML.
It does not run the workflow. It reads the action tree the same way a human
reviewer would, looks for action kinds with a real-world side effect
(network calls, external tool invocation, cross-conversation reads/writes,
raw control-flow jumps), and fails if it does not find a human approval
step (Question / RequestExternalInput) earlier in the same branch.
This is a heuristic, not a full data-flow analysis. It walks each action
list top to bottom and remembers whether a gate has been seen in that
list or an enclosing one. It will not catch a gate that only fires on
one branch of an unrelated If two levels up protecting a different
variable. Treat a pass as "nothing obviously ungated," not as a proof.
Usage:
    python workflow_guard.py path/to/workflow.yaml [more.yaml ...]
Exit code is non-zero if any ungated risky action is found, which is what
you want in CI: a red check on the pull request, not a comment nobody reads.
"""
from __future__ import annotations
import sys
import yaml
from dataclasses import dataclass, field
GATE_KINDS = {"Question", "RequestExternalInput"}
RISKY_KINDS = {
    "HttpRequestAction": "makes an outbound network call",
    "InvokeMcpTool": "calls a tool on an external MCP server",
    "InvokeFunctionTool": "runs application code chosen by the workflow, "
                           "not by the developer reading this file",
    "AddConversationMessage": "can inject a system-role message into a "
                               "conversation the model will treat as trusted",
    "CopyConversationMessages": "moves messages between conversations, "
                                 "possible cross-tenant or cross-session leak",
    "RetrieveConversationMessages": "reads a conversation this branch did "
                                     "not necessarily start",
}
CONTROL_FLOW_RISK_KINDS = {"GotoAction"}

@dataclass
class Finding:
    action_id: str
    kind: str
    reason: str
    path: str

@dataclass
class Context:
    findings: list = field(default_factory=list)

def walk(actions, path, ctx, gated_inherited):
    if not actions:
        return
    gated = gated_inherited
    for action in actions:
        kind = action.get("kind", "<missing kind>")
        action_id = action.get("id", "<no id>")
        here = f"{path}/{action_id}"
        if kind in GATE_KINDS:
            gated = True
        if kind in RISKY_KINDS and not gated:
            ctx.findings.append(Finding(action_id, kind, RISKY_KINDS[kind], here))
        if kind in CONTROL_FLOW_RISK_KINDS:
            ctx.findings.append(Finding(
                action_id, kind,
                "jumps to another action id; static review cannot confirm "
                "it doesn't skip an approval gate",
                here,
            ))
        if kind == "If":
            walk(action.get("then"), here + "/then", ctx, gated)
            walk(action.get("else"), here + "/else", ctx, gated)
        elif kind == "ConditionGroup":
            for cond in action.get("conditions", []):
                walk(cond.get("actions"), here + f"/{cond.get('id', 'branch')}", ctx, gated)
            walk(action.get("elseActions"), here + "/elseActions", ctx, gated)
        elif kind == "Foreach":
            walk(action.get("actions"), here + "/actions", ctx, gated)

def load_actions(doc):
    if "trigger" in doc:
        return doc["trigger"].get("actions", [])
    return doc.get("actions", [])

def check_file(filepath):
    with open(filepath, "r") as f:
        doc = yaml.safe_load(f)
    ctx = Context()
    walk(load_actions(doc), filepath, ctx, gated_inherited=False)
    return ctx.findings

def main(argv):
    if not argv:
        print("usage: workflow_guard.py <workflow.yaml> [...]")
        return 2
    total = 0
    for filepath in argv:
        findings = check_file(filepath)
        if not findings:
            print(f"OK {filepath}: no ungated side-effecting actions found")
            continue
        print(f"FAIL {filepath}: {len(findings)} action(s) need a human gate or a reviewer override")
        for f in findings:
            print(f" - [{f.kind}] id={f.action_id} at {f.path}")
            print(f" why: {f.reason}")
        total += len(findings)
    if total:
        print(f"\n{total} finding(s) across {len(argv)} file(s). "
              "Add a Question/RequestExternalInput step before the flagged "
              "action, or add an explicit reviewer override comment and "
              "extend the script to honor it.")
        return 1
    return 0

if __name__ == " __main__":
    raise SystemExit(main(sys.argv[1:]))

Enter fullscreen mode Exit fullscreen mode

Run it against the refund workflow above, next to the same workflow with a Question step inserted before the HttpRequestAction:

$ python3 workflow_guard.py risky_workflow.yaml safe_workflow.yaml
FAIL risky_workflow.yaml: 1 action(s) need a human gate or a reviewer override
      - [HttpRequestAction] id=issue_refund at risky_workflow.yaml/route_request/high_value/issue_refund
        why: makes an outbound network call
OK safe_workflow.yaml: no ungated side-effecting actions found
1 finding(s) across 2 file(s). Add a Question/RequestExternalInput step before
the flagged action, or add an explicit reviewer override comment and extend
the script to honor it.
Enter fullscreen mode Exit fullscreen mode

I want to be honest about what this is and isn’t. It’s a heuristic that walks action lists in order and tracks whether a gate has appeared in the current branch or any enclosing one. It will not catch every clever way to route around a gate, and I’m not claiming it does. What it does catch, reliably, is the ordinary case: someone adds one new side-effecting action to an existing branch and nobody notices because the diff is four lines in a YAML file. That’s the actual failure mode I set out to stop, not a formal proof of workflow safety.

Wiring it into review, not just into CI

A failing CI check that anyone can still merge past isn’t a review gate, it’s a suggestion. The two pieces that make it real:

# .github/workflows/agent-workflow-guard.yml
name: agent-workflow-guard
on:
  pull_request:
    paths:
      - "workflows/**/*.yaml"
      - "workflows/**/*.yml"
jobs:
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pyyaml
      - run: python3 scripts/workflow_guard.py workflows/**/*.yaml
Enter fullscreen mode Exit fullscreen mode

And a CODEOWNERS line so the check being green doesn't make the merge button clickable by just anyone:

# CODEOWNERS
/workflows/ @your-org/agent-platform-reviewers
Enter fullscreen mode Exit fullscreen mode

That’s the actual mechanic behind “harder to merge, not easier.” Not a mood, a required status check plus a required reviewer group on the specific path where behavior lives now.

This isn’t an Azure-only problem

It’s worth being clear that none of this is specific to Azure-hosted agents, even though InvokeAzureAgent is the action name in the schema. On the Python side, the name in agent: name: resolves against whatever you registered in the WorkflowFactory, and you can register a completely local agent backed by Ollama instead of anything Microsoft hosts:

# ollama pull llama3.1
# ollama serve
from agent_framework import Agent
from agent_framework.ollama import OllamaChatClient
from agent_framework.declarative import WorkflowFactory
triage_agent = Agent(
    client=OllamaChatClient(), # defaults to http://localhost:11434
    name="TriageAgent",
    instructions="Classify the refund request and extract the amount.",
)
factory = (
    WorkflowFactory()
    .register_agent("TriageAgent", triage_agent)
)
workflow = factory.create_workflow_from_yaml_path("workflows/refund_workflow.yaml")
Enter fullscreen mode Exit fullscreen mode

Same YAML, same workflow_guard.py, zero calls to Azure. The governance problem doesn't go away because you self-hosted the model, because the risk was never in which model answers, it was in what the workflow is allowed to do once it decides. That's also why the guard script works as well against this setup as against a fully Azure-hosted one: it never looks at which chat client is behind an agent, only at what the action graph can reach.

What I’d still want from Microsoft directly

Having actually built this, my honest wishlist for the SDK itself, not a workaround:

A schema-level requiresApproval: true flag on side-effecting actions that the runtime enforces even if a static check gets skipped, so the guarantee doesn't live only in someone's CI config. Signed or hash-verified checkpoints, since the same SuperStepCompletedEvent mechanism that lets a workflow resume after a human answers a Question is also the mechanism that would let someone replay or tamper with a paused workflow if the checkpoint store isn't locked down, and the current docs don't say much about that surface. And ideally, per-action-kind RBAC that lives with the agent registration rather than the YAML file, so a workflow author who can write HttpRequestAction in a YAML file still can't make it call a URL their service account isn't allowed to reach.

None of that is present today as far as the current schema documentation shows, and I’d rather say that plainly than pretend I found something Microsoft hasn’t already thought about. A static guard in CI is the thing you can ship this afternoon. Runtime enforcement is the thing I’d actually want by the time a second team at your company starts writing these files without you in the room.

Petrusheuski’s instinct in the original piece was right: this is a governance problem now, not a readability upgrade. It just needed a script, not a sentence.

Tags: agent-framework, ai-agents, devops, yaml, ci-cd, azure, mlops, python

Top comments (0)