DEV Community

Cover image for Declarative Agent Workflows in Go: What Microsoft Shipped, and What You Build Yourself
Andrew B.
Andrew B.

Posted on • Originally published at andriiboyko.com

Declarative Agent Workflows in Go: What Microsoft Shipped, and What You Build Yourself

In June 2026 Microsoft shipped declarative workflows for its Agent Framework at 1.0. The pitch is good: you describe a multi-agent workflow in a YAML file instead of wiring it in code, so a workflow becomes a config artifact you can read, diff, version, and hand to someone who doesn't write code. The agent-framework-declarative package went 1.0.0 on PyPI, joining the already-stable .NET Microsoft.Agents.AI.Workflows.Declarative.

Then you open the docs, flip the language pivot to Go, and the how-to isn't there. Declarative workflows are a Python and .NET feature. The Go SDK, github.com/microsoft/agent-framework-go, is in public preview, and its own release notes are blunt about the gap: declarative agents, RAG, CodeAct, and functional workflows "are not yet available."

So if you're building agent systems in Go, you're in an interesting spot. The concept Microsoft is selling is genuinely useful. The runtime that would give it to you exists in your language. The thin YAML-to-graph layer on top just hasn't been written for Go yet. That's a gap you can close in about a hundred lines, and by the end of this you'll have. But first, let's be precise about what "declarative" is actually buying you, because half the teams that reach for it don't need it.

What "declarative" actually means here

A programmatic workflow is code. You import a builder, instantiate executors, connect them, and call Build(). A declarative workflow is the same graph expressed as data. Here's the Python-flavored version straight from the docs, a workflow that greets someone by name:

name: greeting-workflow
description: A simple workflow that greets the user

inputs:
  name:
    type: string
    description: The name of the person to greet

actions:
  - kind: SetVariable
    id: build_message
    variable: Local.message
    value: =Concat("Hello, ", Workflow.Inputs.name, "!")

  - kind: SendActivity
    id: send_greeting
    activity:
      text: =Local.message
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing. First, every step has a kind. The framework ships a fixed vocabulary of them, and that vocabulary is the whole point: SetVariable, If, ConditionGroup, Foreach, InvokeAzureAgent, InvokeFunctionTool, InvokeMcpTool, HttpRequestAction, Question, SendActivity, and a dozen more. You're not writing behavior, you're picking from a menu of pre-approved building blocks. That constraint is a feature. It's what lets a non-developer safely edit the file.

Second, look at the value fields that start with =. Those are expressions, evaluated at runtime by an embedded expression engine. Microsoft uses PowerFx here, the same formula language behind Power Apps, so =Concat("Hello, ", Workflow.Inputs.name) is a real function call, not string templating. Variables live in namespaces: Local.* for workflow-scoped state, Workflow.Inputs.* and Workflow.Outputs.* for the contract with the caller, and System.* for framework-provided values like System.ConversationId and System.LastMessage.

The docs include a decision table that's more honest than most vendor tables, so it's worth reproducing:

Scenario Recommended
Standard orchestration patterns Declarative
Workflows that change frequently Declarative
Non-developers need to modify workflows Declarative
Complex custom logic Programmatic
Maximum flexibility and control Programmatic
Integration with existing code Programmatic

Read the right column. The moment your workflow needs anything the action vocabulary doesn't cover, you're back in code. Declarative isn't a more powerful way to build workflows, it's a more constrained one, and the constraint is the value. Keep that in mind, it's the whole argument for when to bother.

There's also a gotcha hiding in that expression engine. On Python the declarative package supports 3.10 through 3.13 and explicitly not 3.14, "due to PowerFx compatibility." When your workflow definition language is coupled to a .NET formula runtime, that runtime's portability becomes your portability. Hold that thought, it's part of why the Go story is what it is.

The catch: Go isn't invited to this party yet

Here's the state of play as of mid-2026. Declarative workflows are 1.0 on Python and .NET. On Go, they don't exist. The Go SDK is public preview and the feature list that's shipped covers agents, providers, tools, and programmatic workflows. The declarative layer, the RAG helpers, CodeAct, and functional workflows are all on the "coming" side of the line.

That could read as "Go is behind, come back later." I'd read it differently. The reason declarative is hard to port isn't the graph engine, Go already has that. It's PowerFx. Porting the declarative feature faithfully means either binding a .NET expression runtime into a Go process or reimplementing PowerFx, and neither is a weekend. The valuable part, turning a YAML graph into a running workflow, is the easy part. That's the part you can do yourself today.

So the plan for the rest of this piece: see what the Go SDK's programmatic workflows actually look like, understand the engine underneath them (it's more interesting than you'd expect), and then build a small declarative layer that compiles YAML into that engine. Real Go, runs today.

What the Go SDK gives you today

The programmatic API is clean. You create executors, which are just processing units, and connect them with edges. Here's the canonical example from the Go docs, verbatim, a two-stage pipeline that uppercases a string then reverses it:

import (
    "github.com/microsoft/agent-framework-go/workflow"
    "github.com/microsoft/agent-framework-go/workflow/inproc"
)

uppercase := workflow.NewExecutor("UppercaseExecutor", func(input string) string {
    return strings.ToUpper(input)
}).Bind()

reverse := workflow.NewExecutor("ReverseExecutor", func(input string) string {
    runes := []rune(input)
    slices.Reverse(runes)
    return string(runes)
}).Bind()

wf, err := workflow.NewBuilder(uppercase).
    AddEdge(uppercase, reverse).
    WithOutputFrom(reverse).
    Build()
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

An executor is any function with a shape like func(input) output, wrapped by NewExecutor and registered with Bind(). The builder takes a starting executor, you AddEdge from one to the next, mark which executor's output is the workflow output with WithOutputFrom, and Build(). Then you run it, streaming events as they happen:

stream, err := inproc.Default.RunStreaming(context.Background(), wf, "Hello, World!")
if err != nil {
    return err
}
defer stream.Close(context.Background())

for evt, err := range stream.WatchStream(context.Background()) {
    if err != nil {
        return err
    }
    if output, ok := evt.(workflow.OutputEvent); ok {
        fmt.Printf("Workflow completed: %v\n", output.Output)
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the for ... range over a function, that's a Go 1.23 range-over-func iterator. The SDK's go.mod actually requires Go 1.25, so it was written for a very recent Go and leans hard into the new language features. There's a non-streaming inproc.Default.Run too, which collects events so you can walk them after the fact with run.NewEvents().

That's a straight line. The interesting cases are branches. The builder exposes conditional routing through a switch, so you can send a message down different paths based on its content. In preview the shape looks like this (treat the exact method names as preview-era, the repo is the source of truth):

wb := workflow.NewBuilder(classify)

wb.AddSwitch(classify).
    AddCase(func(m any) bool { return m.(Ticket).Category == "billing" }, billing).
    AddCase(func(m any) bool { return m.(Ticket).Category == "technical" }, technical).
    WithDefault(general).
    AddToBuilder(wb)

wf, err := wb.WithOutputFrom(billing, technical, general).Build()
Enter fullscreen mode Exit fullscreen mode

There's more in the same family: AddFanOutEdge to broadcast to several executors, AddFanInBarrierEdge to join them back, AddChain for a straight sequence, and RequestPort for human-in-the-loop, where the workflow pauses and raises a request event that you answer from outside. And Build() isn't a rubber stamp. It validates the graph: type compatibility between connected executors, that every executor is reachable from the start, that bindings resolve, and that you haven't declared duplicate edges. You find a disconnected node at build time, not when a message silently goes nowhere in production.

So the Go SDK has the full orchestration surface. What it doesn't have is a way to express that surface as a YAML file. Before we add one, we need to understand what we're compiling to, because the execution model has a sharp edge.

Under the hood: it's a superstep engine

Here's the part most people skip and then get bitten by. The Agent Framework doesn't just walk your graph node by node. It runs a "modified Pregel execution model, a Bulk Synchronous Parallel (BSP) approach with superstep-based processing." Pregel is the graph-processing model Google published in 2010 for computations over massive graphs, and BSP is the decades-older parallel-computing discipline it's built on. Borrowing it for agent workflows is a genuinely good call, and it changes how you reason about the graph.

Execution happens in discrete supersteps. Each one does the same five things:

  1. Collect every message pending from the previous superstep.
  2. Route each message to target executors based on the edges and their conditions.
  3. Run all targeted executors concurrently.
  4. Wait at a synchronization barrier for every one of them to finish.
  5. Queue whatever new messages they emitted, and start the next superstep.

The barrier in step 4 is the whole game. Within a superstep, everything runs in parallel. Between supersteps, nothing does. The workflow will not advance until the slowest executor in the current step returns.

That barrier buys you three things that matter for production agents. Execution is deterministic: same input, same order, every time. Checkpointing is reliable, because a superstep boundary is a globally consistent snapshot, which is exactly why the SDK can save state and resume on a different machine. And reasoning is simpler, because within a step there are no races, every executor sees the same frozen view of messages.

Now the sharp edge. Say you fan out into two branches. One is a single long-running agent call, thirty seconds. The other is a quick three-step chain. You'd expect the quick chain to race ahead. It won't. Each step of that chain is its own superstep, and every superstep waits at the barrier for the thirty-second agent to finish. Your "fast" branch moves in lockstep with your slow one.

The docs are upfront about the fix, and it's counterintuitive the first time you read it: if you need two branches to run truly independently, don't make one of them a chain. Collapse its three steps into a single executor. Then both branches complete inside one superstep and neither blocks the other on intermediate boundaries. In a superstep engine, more nodes is not more parallelism. Sometimes it's less.

Flow diagram of one workflow superstep: collect pending messages, route by edge and condition, run all target executors in parallel, wait at a synchronization barrier on the slowest long-running agent, then queue new messages for the next superstep

Building your own declarative layer in Go

Now the payoff. We want the declarative experience, a workflow described in YAML, running on Go. We're not going to reimplement PowerFx or bind .NET into our process. We'll build a small, honest engine: a fixed action vocabulary, simple ${var} interpolation instead of a formula language, and a pluggable agent interface so you can wire in agent-framework-go, the OpenAI SDK, or a fake for tests. It's the shape of the real thing at a size you can read in one sitting.

Start with the schema. A workflow is a name and a list of actions. Each action carries only the fields its kind needs, which in Go means one struct with omitempty fields and a Kind discriminator:

package flow

// Spec is the whole workflow, parsed from a YAML file.
type Spec struct {
    Name    string   `yaml:"name"`
    Actions []Action `yaml:"actions"`
}

// Action is one step. Only the fields relevant to its Kind are populated.
type Action struct {
    Kind  string   `yaml:"kind"`
    ID    string   `yaml:"id"`
    Var   string   `yaml:"var,omitempty"`
    Value string   `yaml:"value,omitempty"`
    Cond  *Cond    `yaml:"cond,omitempty"`
    Then  []Action `yaml:"then,omitempty"`
    Else  []Action `yaml:"else,omitempty"`
    Agent string   `yaml:"agent,omitempty"`
    Input string   `yaml:"input,omitempty"`
    Text  string   `yaml:"text,omitempty"`
}

// Cond is a deliberately tiny condition: does a variable equal a literal.
type Cond struct {
    Var    string `yaml:"var"`
    Equals string `yaml:"equals"`
}
Enter fullscreen mode Exit fullscreen mode

That *Cond being a pointer matters: a nil Cond means the action isn't a conditional, which is cleaner than a zero-value struct you have to second-guess. This is the Go version of Microsoft's action vocabulary, trimmed to four kinds: set, if, agent, and send. Enough to be real, small enough to follow.

Next, state. Workflow variables and an output sink, with ${name} interpolation standing in for PowerFx expressions:

package flow

import "strings"

// State holds workflow variables and where output goes.
type State struct {
    Vars map[string]string
    Out  func(string)
}

func NewState(inputs map[string]string, out func(string)) *State {
    vars := make(map[string]string, len(inputs))
    for k, v := range inputs {
        vars[k] = v
    }
    return &State{Vars: vars, Out: out}
}

// interpolate expands ${var} references against the current variables.
func (s *State) interpolate(tpl string) string {
    out := tpl
    for name, val := range s.Vars {
        out = strings.ReplaceAll(out, "${"+name+"}", val)
    }
    return out
}
Enter fullscreen mode Exit fullscreen mode

The agent is an interface, and that single decision is what makes this useful in a real project. The engine doesn't know or care what an agent is, only that you can hand it a prompt and get text back:

package flow

import "context"

// Agent is anything that turns a prompt into a reply. Wire in
// agent-framework-go, the OpenAI SDK, an HTTP call, or a fake in tests.
type Agent interface {
    Run(ctx context.Context, prompt string) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

Now the engine. It's a recursive walk over the action list, with a switch on Kind that mirrors the vocabulary. Every branch wraps its errors with enough context to name the failing action, because when a workflow blows up you want the step ID in the message, not a bare nil map panic three frames down:

package flow

import (
    "context"
    "fmt"
)

// Engine runs a Spec against a set of named agents.
type Engine struct {
    Agents map[string]Agent
}

func (e *Engine) Run(ctx context.Context, spec Spec, st *State) error {
    return e.runActions(ctx, spec.Actions, st)
}

func (e *Engine) runActions(ctx context.Context, actions []Action, st *State) error {
    for _, a := range actions {
        if err := e.runAction(ctx, a, st); err != nil {
            return fmt.Errorf("action %q (kind %s): %w", a.ID, a.Kind, err)
        }
    }
    return nil
}

func (e *Engine) runAction(ctx context.Context, a Action, st *State) error {
    switch a.Kind {
    case "set":
        st.Vars[a.Var] = st.interpolate(a.Value)

    case "send":
        st.Out(st.interpolate(a.Text))

    case "if":
        if a.Cond != nil && st.Vars[a.Cond.Var] == a.Cond.Equals {
            return e.runActions(ctx, a.Then, st)
        }
        return e.runActions(ctx, a.Else, st)

    case "agent":
        agent, ok := e.Agents[a.Agent]
        if !ok {
            return fmt.Errorf("no agent registered named %q", a.Agent)
        }
        reply, err := agent.Run(ctx, st.interpolate(a.Input))
        if err != nil {
            return fmt.Errorf("agent %q failed: %w", a.Agent, err)
        }
        if a.Var != "" {
            st.Vars[a.Var] = reply
        }

    default:
        return fmt.Errorf("unknown action kind %q", a.Kind)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Loading is the boring part, one os.ReadFile and one yaml.Unmarshal, both wrapped:

package flow

import (
    "fmt"
    "os"

    "gopkg.in/yaml.v3"
)

func Load(path string) (Spec, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Spec{}, fmt.Errorf("read workflow %s: %w", path, err)
    }
    var spec Spec
    if err := yaml.Unmarshal(data, &spec); err != nil {
        return Spec{}, fmt.Errorf("parse workflow %s: %w", path, err)
    }
    return spec, nil
}
Enter fullscreen mode Exit fullscreen mode

And here's a workflow that engine runs, a support router that's a straight port of the docs' own conditional-routing example, just in our smaller dialect:

name: support-router

actions:
  - kind: set
    id: capture_category
    var: category
    value: "${incoming_category}"

  - kind: if
    id: route
    cond:
      var: category
      equals: billing
    then:
      - kind: agent
        id: billing_agent
        agent: billing
        input: "Resolve this billing question: ${message}"
        var: reply
    else:
      - kind: agent
        id: general_agent
        agent: general
        input: "${message}"
        var: reply

  - kind: send
    id: reply_to_user
    text: "${reply}"
Enter fullscreen mode Exit fullscreen mode

Wiring it together is a main that registers a couple of agents and runs the file. A fake agent stands in here so the example runs with zero credentials, and swapping it for a real one is a one-line change:

package main

import (
    "context"
    "fmt"
    "log"

    "example.com/flow"
)

// fakeAgent echoes a canned reply so the demo runs offline.
type fakeAgent struct{ name string }

func (f fakeAgent) Run(_ context.Context, prompt string) (string, error) {
    return fmt.Sprintf("[%s] handling: %s", f.name, prompt), nil
}

func main() {
    spec, err := flow.Load("support-router.yaml")
    if err != nil {
        log.Fatal(err)
    }

    engine := &flow.Engine{Agents: map[string]flow.Agent{
        "billing": fakeAgent{name: "billing"},
        "general": fakeAgent{name: "general"},
    }}

    st := flow.NewState(
        map[string]string{"incoming_category": "billing", "message": "I was double charged"},
        func(out string) { fmt.Println("OUT:", out) },
    )

    if err := engine.Run(context.Background(), spec, st); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it and you get:

OUT: [billing] handling: Resolve this billing question: I was double charged
Enter fullscreen mode Exit fullscreen mode

Change incoming_category to anything else and the same binary routes to the general agent, no recompile of the logic, because the logic lives in the YAML. That's the declarative property Microsoft is selling, running on Go, in code you fully own and can extend one case at a time.

Diagram mapping a YAML action list (set, if, agent billing, agent general, send) into a directed executor graph where the if node branches to a billing node and a general node before both merge into a send node

Wiring it onto the real engine

The engine above walks the actions directly, which is perfect for getting started and fine for a lot of real workloads. But you lose what the superstep model gave you: deterministic parallelism, checkpointing, resumability, the built-in telemetry. When you want those, you don't throw the YAML away. You change the target. Instead of interpreting the actions, you compile them into an agent-framework-go graph, once, and let its runtime execute.

The mapping is direct. Each action becomes an executor. Sequential actions become AddEdge calls between them. An if becomes an AddSwitch with a case for the then branch and a default for the else. An agent action becomes an executor whose function calls your real provider. Sketched against the preview builder:

func Compile(spec flow.Spec, agents map[string]flow.Agent) (*workflow.Workflow, error) {
    nodes := make([]workflow.ExecutorBinding, 0, len(spec.Actions))

    for _, a := range spec.Actions {
        a := a // capture per iteration for the closure below
        switch a.Kind {
        case "agent":
            ex := workflow.NewExecutor(a.ID, func(input string) (string, error) {
                return agents[a.Agent].Run(context.Background(), input)
            }).Bind()
            nodes = append(nodes, ex)
        // set, if, send map to their own executors / switches...
        }
    }

    b := workflow.NewBuilder(nodes[0])
    for i := 0; i < len(nodes)-1; i++ {
        b = b.AddEdge(nodes[i], nodes[i+1])
    }
    return b.WithOutputFrom(nodes[len(nodes)-1]).Build()
}
Enter fullscreen mode Exit fullscreen mode

That a := a line is load-bearing. Before Go 1.22 changed loop-variable scoping, capturing a in the closure without the shadow would give every executor the last action's values, the single most common bug when you build a slice of closures in a loop. On 1.22 and up it's redundant but harmless, and I'd still write it, because it says "this closure captures a per-iteration value" out loud to the next reader.

The point of this section isn't the exact builder calls, which are preview and will move. It's the architecture: one YAML dialect, two backends. A direct interpreter you own for the simple 80%, and a compile-to-agent-framework-go path for when you need the superstep guarantees. Your workflow files don't change when you switch. That's the real payoff of a declarative layer, and it holds whether Microsoft ships Go support next quarter or not. If they do, you migrate the compiler backend and keep your files. If they don't, you already shipped.

When this is actually worth building

Now the part the vendor tables won't tell you. Don't build any of this to avoid an if statement.

A declarative layer earns its keep under a specific condition: the people who need to change the workflow aren't the people who deploy the binary, and the workflow changes often enough that a redeploy per change is real friction. A support-routing flow that ops tweaks weekly, a content pipeline a PM reorders, an onboarding sequence that shifts with every partner deal. That's where moving the graph out of code and into a versioned YAML file pays for the engine you had to write. The file becomes the interface between the people who change the flow and the runtime that executes it.

If that's not your situation, a plain Go function is better than a YAML interpreter in every way that matters. It's type-checked, it's debuggable with a normal debugger, it's greppable, and it has no expression language to learn. Microsoft's own decision table says as much in its right-hand column: complex logic, maximum control, tight integration with existing code all point back to programmatic. The declarative version is worth it precisely and only when the constraint, a fixed vocabulary that a non-developer can safely edit, is the thing you want.

The Go SDK not having declarative workflows yet turns out to be a clarifying accident. It forces the question most teams skip on Python: do we actually need this, or does it just look tidy in a demo? If you need it, it's a hundred lines and you've now seen them. If you don't, you were always one well-named function away from the same result, and Go quietly kept you honest about it. Either way, you didn't have to wait for a pivot to fill in.

If you liked the "own your building blocks instead of importing a framework" flavor of this, the same instinct shows up in building a REST API in Go that's pleasant to extend, where Go 1.22's standard library removed most reasons to reach for a router.


Originally published at andriiboyko.com.

Top comments (0)