Back to Tutorials

Tutorial

When Your Agent Skips the Eligibility Check — Mastra Workflows (Part 2)

Agents decide. Workflows guarantee. Part 2: typed multi-step pipelines with sequencing, parallelism, branching, loops, and human-in-the-loop suspend & resume — for the steps where order isn't up for a vote.

May 19, 202611 min readPart 2 of 7
When Your Agent Skips the Eligibility Check — Mastra Workflows (Part 2)

Picture an agent that processes refunds.

Most days it's fine. Check the order, verify the customer is actually eligible, issue the credit, send a confirmation. Four steps, always in that order, because skipping the eligibility check is exactly how you end up refunding someone who isn't owed anything.

Except nothing is forcing those four steps to happen in that order. The agent is a model deciding what to do next, turn by turn. Most of the time it decides correctly. And then one day, on some input you didn't anticipate, it decides the eligibility check "seemed redundant" and skips straight to issuing the credit.

That's not a bug in the model. That's what an agent is — a loop that lets the model choose its own path. Which is exactly why it's the wrong tool for the parts of your system where the order isn't up for a vote.

In Part 1 I built that kind of agent. This time I want the opposite: not a model deciding what happens next, but a graph I wire together myself, where every step runs when I say it runs, in the order I put it in — no matter what the input looks like.

Mastra calls that a workflow. If an agent gives you flexibility, a workflow gives you a guarantee.

The series

  1. Agents — define an agent, tools, memory.
  2. Workflows (you're here) — orchestrate multi-step logic.
  3. The Harness — the runtime that hosts it all.
  4. Streaming — get the work to a UI live.
  5. RAG — answer from real documents.
  6. Durable agents — survive crashes, run in the background.
  7. Evals — prove the agent is actually good.

Steps are the unit of work

Before you can wire anything together, you need to know what a single link in that chain looks like on its own.

A workflow is built out of steps. A step is a typed function — it declares an input schema and an output schema, and Mastra uses those schemas to check that one step's output can actually feed the next step's input. Get that wrong and you find out at build time, not in production.

steps.ts
import { createStep } from "@mastra/core/workflows";
import { z } from "zod";
 
const formatStep = createStep({
  id: "format",
  inputSchema: z.object({ message: z.string() }),
  outputSchema: z.object({ formatted: z.string() }),
  execute: async ({ inputData }) => {
    return { formatted: inputData.message.trim().toUpperCase() };
  },
});

Notice what execute receives: inputData, already validated against inputSchema before your code ever sees it. There's more available in that object too — state, setState, mastra, and a few others we'll get to — but the core contract is simple. Typed data in, typed data out.

One step alone doesn't do much. The interesting part is what happens when you start chaining them.

Step 1: Sequence steps with .then

The simplest way to chain two steps is also the most common: run one, then run the other, and pass the output of the first straight into the second.

workflows/greet.ts
import { createWorkflow, createStep } from "@mastra/core/workflows";
import { z } from "zod";
 
const formatStep = createStep({
  id: "format",
  inputSchema: z.object({ message: z.string() }),
  outputSchema: z.object({ formatted: z.string() }),
  execute: async ({ inputData }) => ({
    formatted: inputData.message.trim(),
  }),
});
 
const emphasizeStep = createStep({
  id: "emphasize",
  inputSchema: z.object({ formatted: z.string() }),
  outputSchema: z.object({ result: z.string() }),
  execute: async ({ inputData }) => ({
    result: `${inputData.formatted}!`,
  }),
});
 
export const greetWorkflow = createWorkflow({
  id: "greet",
  inputSchema: z.object({ message: z.string() }),
  outputSchema: z.object({ result: z.string() }),
})
  .then(formatStep)
  .then(emphasizeStep)
  .commit();

createWorkflow(...).then(...).then(...) reads almost like pseudocode, which is the point. Note the chain has to end with .commit() — that's what locks the graph in and makes it runnable.

Running it looks like this: create a run, then start it with the input data.

run.ts
const run = await greetWorkflow.createRun();
const result = await run.start({ inputData: { message: "  hello  " } });
 
if (result.status === "success") {
  console.log(result.result); // { result: "hello!" }
}

That result.status check is worth sitting with for a second. A run doesn't just return a value — it comes back tagged as success, failed, or suspended (we'll get to what that last one means). You branch on the status explicitly. An agent loop can fail silently in a dozen creative ways; a workflow hands you back one of three known outcomes, every time.

Sequencing gets you a long way. But real processes aren't always a straight line — sometimes two steps don't depend on each other, sometimes you need to pick a path, sometimes you need to repeat something until it's good enough.

Step 2: Branch, parallelize, and loop

Sequencing is the floor, not the ceiling. The reason to reach for a workflow instead of just calling functions in order is everything else in the control-flow toolkit — and each piece maps to a shape you'd recognize from any flowchart.

inputinputData.thensequential.parallelconcurrent.branchconditional.foreachper itemoutputresult
The four control-flow shapes. Pick the one that matches the work, not the other way around.

Start with the one you'll reach for most often. If two steps don't depend on each other's output — fetching the weather and fetching the news, say — there's no reason to make one wait on the other. Run them in parallel, and the combined output is an object keyed by each step's id, so the next step can tell them apart.

createWorkflow({ /* ... */ })
  .parallel([fetchWeatherStep, fetchNewsStep])
  .then(combineStep)
  .commit();
 
// combineStep sees: { "fetch-weather": {...}, "fetch-news": {...} }

Sometimes the steps do depend on the data, just not in a way that runs them one after another — they're alternatives. That's what branching is for. Each entry is a [condition, step] pair, and only the first branch whose condition matches actually runs.

createWorkflow({ /* ... */ })
  .then(scoreStep)
  .branch([
    [async ({ inputData }) => inputData.score > 0.8, autoApproveStep],
    [async ({ inputData }) => inputData.score <= 0.8, humanReviewStep],
  ])
  .commit();

Then there's the case where one pass isn't enough — you want to keep refining something until it clears a bar. That's a loop, and Mastra gives you two flavors: .dountil runs a step and keeps repeating it until a condition becomes true, while .dowhile keeps going while it stays true.

createWorkflow({ /* ... */ })
  .dountil(
    refineStep,
    async ({ inputData }) => inputData.qualityScore > 0.9,
  )
  .commit();
Run steprefineCheckscore > .9?Repeatif false
`.dountil` runs a step, checks the condition, and repeats — the workflow equivalent of an agent loop, but with an explicit exit you control.

It's worth pausing on that diagram's caption, because it's the whole pitch for workflows in miniature. .dountil looks a lot like the loop an agent runs internally — try, check, try again. The difference is the exit condition lives in your code, not in the model's judgment about when to stop.

Last shape: sometimes you don't have two known steps to run, you have one step and a list of items to run it against. .foreach runs a step once per item in an input array, and you can cap how many run at once.

createWorkflow({ /* ... */ })
  .foreach(processItemStep, { concurrency: 5 })
  .commit();

These compose. A real workflow might .then a fetch, .parallel several enrichments, .branch on a quality score, and .foreach over the results. The schemas keep every junction type-checked, so a refactor that breaks a hand-off fails at build time, not at 2am.

Four shapes, and you can combine all of them in one graph. What you can't do with any of them, on their own, is let the model actually think about something mid-process — for that you still need an agent. So how do the two work together?

Step 3: Calling an agent from a workflow

Workflows and agents aren't competing approaches. They compose — and the natural pattern is a workflow that owns the structure while handing off judgment to an agent at exactly one point in the graph.

From inside a step, that handoff is nothing special. It's just a function call, wrapped in the same shape as any other step.

steps/summarize.ts
import { createStep } from "@mastra/core/workflows";
import { z } from "zod";
 
export const summarizeStep = createStep({
  id: "summarize",
  inputSchema: z.object({ article: z.string() }),
  outputSchema: z.object({ summary: z.string() }),
  execute: async ({ inputData, mastra }) => {
    const agent = mastra.getAgent("assistant");
    const res = await agent.generate(
      `Summarize this in one sentence:\n\n${inputData.article}`,
    );
    return { summary: res.text };
  },
});

That mastra argument is what makes it possible — every step gets it, and through it, a step can reach any agent registered in your app. So now you get both worlds at once inside a single graph: fetch deterministically, hand the one part that needs judgment to a model, then store the result deterministically again. The scaffolding stays predictable; only the middle step gets to think.

That covers the steps that run cleanly, start to finish, in one pass. Not every real process works that way — some of them need to stop halfway through and wait for a person.

Step 4: Pause for a human with suspend & resume

This is the feature that makes workflows feel production-grade rather than academic.

A long-running process often needs to stop and wait — for an approval, a payment, a human editing a draft — and then pick back up later. Possibly much later. Possibly after the server that started it has restarted. Mastra handles this with what it calls suspend & resume.

The mechanics: a step can suspend itself instead of returning a result. When it does, the run comes back with status suspended instead of success, and it just... waits. Nothing is polling, nothing is blocking a thread.

steps/approval.ts
const approvalStep = createStep({
  id: "approval",
  inputSchema: z.object({ draft: z.string() }),
  resumeSchema: z.object({ approved: z.boolean() }),
  outputSchema: z.object({ published: z.boolean() }),
  execute: async ({ inputData, resumeData, suspend }) => {
    // First pass: no resume data yet, so pause and wait for a human.
    if (!resumeData) {
      await suspend({ draft: inputData.draft });
      return { published: false };
    }
    // Resumed: we now have the human's decision.
    return { published: resumeData.approved };
  },
});

Later — could be seconds, could be hours — you resume the run with the data it was waiting on. resumeSchema describes the shape of that data, the same way inputSchema describes what the step expects on its first pass.

run.ts
const run = await publishWorkflow.createRun();
const first = await run.start({ inputData: { draft } });
 
if (first.status === "suspended") {
  // ...hours later, after a human clicks "approve" in your UI...
  const final = await run.resume({
    step: "approval",
    resumeData: { approved: true },
  });
  console.log(final.result); // { published: true }
}
WorkflowStorageHumansuspend(draft)await reviewresume(approved)published
Suspend & resume turns a workflow into a durable, human-in-the-loop process — the run survives the wait.

This is the line between a script and a process. A script that waits for a human holds a thread open and dies the moment the server restarts. A suspended Mastra workflow persists its state to storage and picks up exactly where it left off, whenever the resume data actually arrives.

Where this leaves us

Put it together and you can now express real logic with real guarantees: sequence with .then, run independent work in .parallel, .branch on the data in front of you, loop with .dountil until something clears a bar, delegate the one part that needs judgment to an agent mid-workflow, and pause for a human with suspend & resume — all without ever wondering whether a step got skipped.

So now we have two halves of the story: agents that decide, and workflows that guarantee. What's still missing is the thing that actually hosts either of them — something that holds a live session open, switches behavior based on context, streams progress to a UI, and knows to gate a risky tool call behind approval instead of just running it. Mastra calls that runtime layer the Harness, and it's Part 3.