Docs/Guides/Control the flow of a plan
Plans9 min read

Control the flow of a plan

A plan's sessions don't just run top to bottom. You describe the dependencies and conditions between them, and Frags resolves the order from those declarations — running independent sessions concurrently, gating others on a condition, and fanning a session out across a list.

This guide covers the four session arguments that shape that flow: after, target, expect, and iterate.

Remember the golden rule: FML is declarative. You don't write the control flow imperatively — you annotate each session with its dependencies, and the runtime runs them in the right order for you.

Prerequisites

Order with after

By default, sessions are independent, and the runtime may run them concurrently. Add after="<session>" to force one session to wait for another to finish:

FML
session("fetch") {
    - Return the raw records to analyze.
    schema {
        records: string[]
    }
}

session("analyze", after="fetch") {
    context "Records:\n{{ json .context.fetch.records }}"
    - Summarize the records into a short report.
    schema {
        summary: string
    }
}

To depend on several sessions, repeat the argument — after="a", after="b". This is how you fan several parallel sessions back into one:

FML
session("report",
        after="analyze_sales",
        after="analyze_dev") {
    - Combine the sales and dev analyses into a single executive summary.
    schema {
        summary: string
    }
}

Here analyze_sales and analyze_dev run in parallel, and report waits for both.

There is no before — invert with after

FML has no before argument, and it doesn't need one. "Run A before B" is the same statement as "run B after A" — you just declare it from the other side. Only ever annotate the session that has to wait.

So to guarantee cleanup finishes before report starts, you don't touch cleanup — you make report depend on it:

FML
session("cleanup") {
    - Normalize and de-duplicate the raw records.
    schema {
        records: string[]
    }
}

session("report", after="cleanup") {
    context "Cleaned records:\n{{ json .context.cleanup.records }}"
    - Write the summary from the cleaned records.
    schema {
        summary: string
    }
}

The session that runs first stays dependency-free; the one that follows names it. Anything with no after is fair game to run concurrently.

Rename the output key with target

Every session publishes its result under its own name — both in the plan's final output and in the context namespace. target="key" renames it, which lets you keep machine-friendly session names while exposing a clean key:

FML
session("extract-sales-signals", target="salesSignals") {
    - Extract the weekly sales signals.
    schema {
        wins: string[]
    }
}

Other sessions now reference this output as context.salesSignals (not context.extract-sales-signals), and the final object uses salesSignals as the key.

Skip a session with expect

expect="<expression>" is a Go Expr condition evaluated at run time. If it's false, the session is skipped entirely — no LLM call, no output. It's the clean way to guard against empty or missing data.

FML
session("summarize",
        after="fetch",
        expect="len(context.fetch.records) > 0") {
    - Summarize the records.
    schema {
        summary: string
    }
}

Common conditions are len(context.x) > 0, context.x.field != null, and params.flag. One rule: if expect reads context.*, you must also set after on the session that produced it, so the value exists when the condition is checked.

Run only when the right data is present

Because expect is a full Expr expression, it branches on what kind of data showed up, not just how much. You can check that a field exists and is non-null, holds a particular value, or is a non-empty collection — and skip the session when it isn't:

FML
# Only summarize if the fetch actually returned a transcript
session("summarize",
        after="fetch",
        expect="context.fetch.transcript != null") {
    - Summarize the transcript.
    schema {
        summary: string
    }
}

# Only escalate when the deal was scored as enterprise
session("escalate",
        after="score",
        expect="context.score.tier == 'enterprise'") {
    - Draft an escalation note for the account team.
    schema {
        note: string
    }
}

When the condition is false the session is skipped cleanly — no LLM call, and its key never appears in the output. A downstream session that depends on it via after sees the value as null, so it can guard against the skip with its own expect.

Gate on a var, not just a session output

expect sees all three scopes — params, context, and vars — so you can require that data exists before the session runs, no matter where it came from. vars is especially useful here: it's where a PreCall fetch lands. A call(...) -> someVar runs before the prompts and saves its result into a var, so expect is the natural guard for "only run if that fetch actually returned something."

In this plan an upstream session's PreCall pulls open tickets into vars.openTickets, and the triage session only runs when that var exists:

FML
session("load_tickets") {
    # PreCall fetches data and routes it into vars.openTickets
    call("list_tickets") -> openTickets {
        status = "open"
    }
    - Confirm the tickets were loaded.
    schema {
        loaded: bool
    }
}

# Only triage if the fetch returned tickets
session("triage",
        after="load_tickets",
        expect="vars.openTickets != null") {
    context "Open tickets:\n{{ json .vars.openTickets }}"
    - Group the open tickets by theme and flag the urgent ones.
    schema {
        themes: string[]
    }
}

A globally set var works the same way — expect="vars.featureFlag" gates a session on a plan-level flag with no upstream session at all.

The ordering rule mirrors the context one: if the var is populated by another session's PreCall, pair expect with after on that session so the fetch has run before the condition is checked. A top-level set var needs no after — it exists from the start.

Fan out with iterate

iterate="<expression>" points at an array and runs the session once per element. Inside the session, {{ .it }} (in templates) or it (in Expr) is the current element. Because it produces one result per item, the session's schema must be an array.

FML
session("read_transcript",
        after="fetch_meetings",
        expect="len(context.fetch_meetings.meetings) > 0",
        iterate="context.fetch_meetings.meetings") {

    - Extract the key points from the meeting titled "{{ .it.title }}".

    schema {
        meeting: string
        key_points: string[]
    }[]
}

Frags runs the session for each meeting and collects the results into an array — focusing the model on one item at a time, which is far more accurate than asking it to process the whole list in a single call.

Putting it together

A common shape is fetch → skip if empty → fan out per item → fan in to a report:

FML
require mcp Avoma

session("fetch_meetings") {
    use mcp Avoma
    + List this week's meetings.
    - Return the meetings found.
    schema {
        meetings: {
            uuid: string
            title: string
        }[]
    }
}

# Runs once per meeting — but only if there are any
session("analyze",
        after="fetch_meetings",
        expect="len(context.fetch_meetings.meetings) > 0",
        iterate="context.fetch_meetings.meetings") {

    - Summarize the meeting titled "{{ .it.title }}" in two sentences.

    schema {
        title: string
        summary: string
    }[]
}

# Fans back in once every meeting has been analyzed
session("report", after="analyze") {
    context "Per-meeting summaries:\n{{ json .context.analyze }}"
    - Write a one-paragraph digest across all the meetings.
    schema {
        digest: string
    }
}

Frags reads these annotations and runs the sessions in dependency order — no orchestration code required.

Next steps

← All guides

Order, gate, and fan out sessions with after, target, expect, and iterate — the declarative flow controls Frags runs for you.