DEV Community

Yatin Davra
Yatin Davra

Posted on

My Pipeline Needed Three Different Models to Hand Off to Each Other Without Losing Validation Along the Way

The task was a moderation pipeline: classify an incoming report, draft a response if it needed one, then have a second pass review that draft before anything went out. Three distinct jobs, and I didn't want one model doing all three badly - a cheap model for triage, a stronger one for the actual drafting, and a strict re-check before anything shipped. Each step had its own schema too: triage returns a category, the drafter returns a response plus a tone flag, the reviewer returns an approval and notes. Every one of those I could already get validated individually with a plain generate() call. What I didn't have was a way to chain three of them together where step two's prompt is built from step one's already-validated output, not step one's raw text.

What I almost built myself

The obvious first move: call generate() three times, JSON.stringify() each result into the next prompt by hand, write my own for loop with a turn cap so a bad router decision couldn't spin forever. Maybe 40 lines. Not hard, but the kind of thing I'd be rewriting slightly differently in every project that needed more than one step.

The other direction I looked at was going the other way entirely - a real agent framework, something like LangGraph or Google's ADK. Both do this properly: arbitrary agent graphs, shared mutable state, tool-calling loops, the works. I backed off almost as fast as I'd looked. My pipeline is three fixed steps in a known order, not an open-ended graph, and pulling in a framework built for arbitrary orchestration to run a sequence I could describe in one sentence felt like the wrong size tool - all the surface area of "build any agent topology" for a job that's "run these three, in order, stop when the last one says done."

What was already sitting there

shapecraft - the same library I was using for each individual step - turned out to have exactly the middle ground: defineAgent() + runAgents(), from a separate /agentic entrypoint so it doesn't cost anything if you never use it.

import { defineAgent, runAgents } from "@aviasole/shapecraft/agentic";
import { openai, anthropic } from "@aviasole/shapecraft";
import { z } from "zod";

const triage = defineAgent({
  model: openai({ model: "gpt-4o-mini" }),
  schema: z.object({ category: z.enum(["spam", "abuse", "false_positive"]) }),
  role: "triage",
});

const draft = defineAgent({
  model: anthropic({ model: "claude-haiku-4-5-20251001" }),
  schema: z.object({ response: z.string(), tone: z.enum(["firm", "neutral"]) }),
  role: "draft",
});

const review = defineAgent({
  model: anthropic({ model: "claude-opus-5" }),
  schema: z.object({ approved: z.boolean(), notes: z.string() }),
  role: "review",
});

const result = await runAgents(
  { triage, draft, review },
  reportText,
  {
    router: (last) => {
      if (!last) return "triage";
      if (last.role === "triage") return "draft";
      if (last.role === "draft") return "review";
      return "done";
    },
    maxTurns: 5,
  }
);

console.log(result.final); // review's validated { approved, notes }
Enter fullscreen mode Exit fullscreen mode

Each step is an ordinary generate() call underneath - same retry loop, same guarantee level per backend, nothing new invented for validation. runAgents() is just the loop I was about to write myself, plus the part I hadn't gotten to yet: threading the validated data forward, never the model's raw response text. The router is a plain function I write and fully control - no DSL, no hidden state machine, no framework deciding what "agent" means.

Where my first attempt actually broke

I let the default handoff do its thing at first - each step's prompt is just the previous step's validated data, JSON-stringified. That's fine between triage and draft. It broke between draft and review: all the reviewer got was {"response": "...", "tone": "neutral"}. No idea what the original report even said. It approved things it had no basis to approve, because it genuinely didn't have the basis.

The fix was buildPrompt, an optional override per agent:

const review = defineAgent({
  model: anthropic({ model: "claude-opus-5" }),
  schema: z.object({ approved: z.boolean(), notes: z.string() }),
  role: "review",
  buildPrompt: (last, _history, input) =>
    `Original report: ${input}\nProposed response: ${JSON.stringify(last?.data)}`,
});
Enter fullscreen mode Exit fullscreen mode

Worth calling out because it's not a bug - the default of "just forward the last validated result" is the right default for a two-step chain. It stops being right the moment a downstream step needs context from more than one hop back, and nothing tells you that until the reviewer starts approving things blind. Once I knew to look for it, the fix was one field.

What this doesn't promise

Each step is validated exactly as strongly as calling generate() on it directly - no weaker, but also no stronger as a chain. Nothing checks that triage → draft → review was the right sequence of steps for this particular report, or that the reviewer's notes are actually about the draft it was shown. That's the router's job and mine, same as it would be if I'd hand-written the loop myself.

Routing is caller-owned, not model-inferred - runAgents() doesn't ship a meta-agent that decides what runs next on its own. If I want the model to influence routing, I put that decision inside an agent's own schema (triage's category field, here) and branch on it in my router function. And it's sequential only - one agent at a time, each seeing only what the router explicitly hands it. If I need several independent things running in parallel, that's generateBatch(), a different axis entirely, not this.

Where that leaves it

Three defineAgent() calls, one router function, one buildPrompt override once I found the gap in the default. No hand-rolled loop, no state machine, no framework I'd have had to learn to run a three-step pipeline I could already describe in a sentence:

import { defineAgent, runAgents } from "@aviasole/shapecraft/agentic";
Enter fullscreen mode Exit fullscreen mode

Repo's at github.com/aviasoletechnologies/shapecraft, package is @aviasole/shapecraft on npm.

Top comments (0)