What Opus Got Wrong Building a Claude Code Dynamic Workflow
The loop already existed and worked. Moving it into Claude Code’s new workflows surfaced everything the model still guesses about them.
More than a year ago I built a translation-quality loop for my Ukrainian big cats news site, by hand, in Laravel. The shape was simple: translate a foreign article into Ukrainian, run an analyzer that reads the result for signs of machine translation, hand its corrections to an editor that applies them, and analyze again, until nothing is left to fix. Up to sixteen passes for a news item, thirty-two for a longer article. I wrote it before “agentic workflow” was a phrase I’d heard anyone use; it was just how I thought a review cycle should compose.
Claude Code now ships that exact shape as a feature. Dynamic workflows are JavaScript scripts that call subagents in a loop, fan them out, pipe one stage into the next. The thing I’d hand-rolled, out of the box. So I moved my loop into one: a small project skill, /translation-qa <news-id>, that reads an article out of production and runs the same translate → analyze → apply cycle as a workflow.
I directed the port; Opus 4.8 wrote the code. It made a lot of mistakes, because the feature is new and the model doesn’t actually know how it behaves yet. The public docs cover workflows at a high level; the script API, the part you actually write, you learn by probing. Where the model didn’t know, it guessed, with the same confidence it has about everything else.
What’s worth writing down isn’t the mistakes, it’s who caught them, because mostly it wasn’t me. The model’s own validation runs caught them: the dry-runs and probes it did instead of assuming the workflow worked. My year with the old Laravel loop told me what a clean translation should look like, so I knew what to aim for, but knowing the target and catching each miss are different jobs, and the second one I mostly delegated, on one condition: verify, never declare yourself done. What was mine in this port was the direction.
The runtime I had to learn by probing
The first wall was syntax the model invented. Opus wrote the script with helper export functions and a guard that checked typeof agent before calling it. Neither exists in this runtime. The script wouldn't even start: SyntaxError: Unexpected keyword 'export'. A workflow script may export exactly one thing: export const meta, the block that describes the workflow. Everything else has to be top-level inline code. There is no module system to hang helpers off.
// the one and only export a workflow script is allowedexport const meta = { name: 'translation-qa', phases: [ { title: 'Translate', detail: 'translate to Ukrainian if not already' }, { title: 'Analyze', detail: 'analyzer returns verdict + corrections' }, { title: 'Apply', detail: 'editor applies the corrections' }, ],};// no other `export`; everything below is plain top-level code
This is also where I told it, in plain terms, to stop guessing how the API works and follow the documented constructs strictly. It is the correction I came back to most. A smaller version of the same trap: the script ends with a top-level return, which is valid here, but if you validate the file with node --check it reports "Illegal return" and tempts you to "fix" working code. The runtime is not plain Node, and checking it with plain Node lies to you.
The next one didn’t crash, which made it worse. Opus assumed args, the input I pass the workflow, arrives as an object. It doesn't. In this runtime it comes in as a JSON string. So A.title and A.content were undefined, the loop analyzed an empty article, and the analyzer cheerfully reported nothing to fix. No error. A clean run that had done nothing. What surfaced it was the validation run: I'd told it to test the workflow rather than assume it worked, so it ran on a sample, saw empty title and content come back, and traced it to args arriving as a string. The fix is one line:
const A = typeof args === 'string' ? JSON.parse(args) : (args || {});
An assumption that throws is a good day. An assumption that silently produces plausible output is the one that ships.
Then the execution model. The analyzer and editor prompts are mine, the ones that run my production pipeline, and I wanted them delivered as real system prompts, not pasted into a user turn. In a workflow the only way to do that is agentType: you point the agent() call at a baked .claude/agents/*.md file whose body becomes the subagent's system prompt.
const a = await agent(analyzerMsg, { agentType: analyzerType, // resolves .claude/agents/<type>.md; its body is the system prompt schema: ANALYZER_SCHEMA, model: 'opus',});
Two things about that cost time. There’s no system parameter on agent() to pass a prompt inline — agentType is the only path — so the first job was proving the agent file's body actually arrives as the system prompt, instead of trusting that it does: a probe carrying a marker token that lived only inside the agent definition, confirmed when the token came back in the output. The second was registration. A .claude/agents/*.md file created mid-session is invisible until you restart Claude Code: project agents are registered only at startup, so a freshly generated one just reports "agent type not found" until the next launch. The probe surfaced that too — its first run failed with exactly that error — and it took more than one restart before all the agents resolved.
One more runtime fact, worth knowing before you lean on it: the workflow sandbox has no filesystem, no shell, no crypto, no Date.now, no Math.random. My Laravel loop tracked oscillation by hashing each version of the article to catch the editor flip-flopping between two phrasings, and my instruction was a feasibility question: can that detection run inside a workflow, and if not, strip it. Opus got this right. The md5 hashing doesn't port, because there's no crypto, but the detection itself is just comparing one cycle's sentences against an earlier cycle's; the hash was only a space optimization, so you store the normalized strings instead and it runs fine. Stripping it was a choice, not a constraint: I'd built that flip-flop machinery for weaker models that thrashed, and Opus converges without it. The limits are real, and the takeaway stands: anything that needs time, randomness, hashing, or a file has to live outside the workflow or be passed in. Oscillation tracking just wasn't what they killed; dropping it was my call.
Knowing when to stop
The loop’s exit condition was the part I had the most production experience with, and the part Opus got least right on its own.
Opus’s version stopped the loop the first time the analyzer returned «Ні» — Ukrainian for “no,” the analyzer’s way of saying nothing is left to fix. One clean verdict and the article was declared done. I knew that was wrong, because the Laravel loop had taught me a single clean pass isn’t proof: the analyzer can sign off once and then catch something on the very next pass over the same text. In production I re-run until I get two clean reads in a row. So I had the workflow do the same. On a «Ні», don’t stop; run one more analyze pass to confirm, and end the loop only when two land clean back to back. A single lucky «Ні» can’t close it out anymore.
The other half of the stop condition was a false alarm, and chasing it taught me how to read a workflow’s own reporting. The loop is capped, sixteen cycles for a news item and thirty-two for a longer article, the same limits the production pipeline uses. On an article run the workflow printed “16/16 agents done,” and I read it as the cap firing at sixteen when an article should be allowed thirty-two. It wasn’t the cap. “16/16” is the runtime’s count of every agent() call the workflow made, analyzer and confirm and editor summed across all the cycles, and the loop had stopped early because it converged, two clean passes, nowhere near the thirty-two ceiling. Hitting the cap is the give-up case: the workflow returns outcome: 'capped', meaning it ran out of tries before the analyzer ever signed off. Converging is the good exit, outcome: 'converged-clean'. The agent tally and the cycle cap are different numbers, and the progress line shows you the one you weren't worried about. There was no bug. I'd flagged the "16" because I knew an article should get thirty-two passes; that part was mine. But the number on the screen wasn't the cap, and the workflow was fine.
Getting the article back out cleanly
Opus’s first editor schema had two fields, title and content — an obvious-looking shape. But the editor's prompt was my production prompt, written to answer in # Title and then the body as Markdown. Schema and prompt disagreed, and the dry-run surfaced it. Forced through a two-field schema, the model mis-filled it: title came back as "Виправлені поля статті", literally "corrected article fields". It described the fields instead of producing the article. The schema won the format and lost the content.
The fix was to stop fighting the prompt. One markdown field, parsed the way production already parses it: first line is the title, strip the #, the rest is the body.
const EDITOR_SCHEMA = { type: 'object', required: ['markdown'], properties: { markdown: { type: 'string' } },};
const md = ed.markdown.trim();const nl = md.indexOf('\n');const title = md.slice(0, nl).replace(/^[#*\s]+/, '').trim();const content = md.slice(nl + 1).trim();
A structured schema isn’t free. Bolt a multi-field schema onto a prompt that was written to produce free-form text and the two will disagree. Match the schema to how the prompt already answers, or rewrite the prompt for the schema, but don’t half-do both.
A related leak: the per-article date can’t be baked into a static agent, since it changes every run, so it rides along in the user turn. The date matters — it’s the article’s own date, the anchor that stops an agent thinking in today’s time from “correcting” a relative reference in a piece written a week ago. But on the scientific-article variant, the editor folded the date line, “Дата статті: …”, into the body of the article. It showed up there and not on the news variant, on the same workflow code. A leak that fires in one path and not another can’t be tested away; it has to be designed out. The fix is to fence the date as explicit out-of-band text and tell the agent not to echo it:
[СЛУЖБОВЕ, не частина статті — НЕ включай у відповідь] Дата статті: <date>
The bracketed prefix means, roughly, “service text, not part of the article — do not include it in your answer,” and the apply instruction repeats the rule in plain terms: don’t add the date or any service line to the body. Once the metadata is fenced and named, the leak stops.
Allowlist versus denylist
The trigger was a run where a subagent wandered off the job. The analyzer ran six web searches (researching cheetah terminology and reserve facts to check the article against) and a subagent shelled into the Laravel codebase it happened to be running inside. The web searches were defensible; checking a Ukrainian term or a proper name against an authoritative source is real translation work. Reading the repo is meaningless for translating an article, and that was the part to stop.
Opus’s first fix was an allowlist, a tools list naming what each agent could use, narrowed to almost nothing. Wrong instrument, for two reasons. It would have blocked the web research the analyzer legitimately needs. And a too-narrow allowlist risks taking out the workflow's own structured-output tool — the mechanism the agent uses to return its schema'd answer, which the runtime injects and the docs don't say is exempt; lose it and the agent can't respond at all. The safer instrument is a denylist: deny the filesystem, shell, and editing tools, leave web search alone, and never risk silencing the structured-output tool by accident.
---name: translation-analyzerdisallowedTools: Read, Write, Edit, NotebookEdit, Bash, Grep, Globmodel: opus---
The same wandering showed up one level higher, in the orchestrator. It decided that a foreign, not-yet-translated article sitting in the review queue was an “unusual state worth verifying” and started investigating, when that is precisely the normal case the skill exists to handle. I had to write the scope down explicitly: do the phases, don’t audit the article’s state, don’t go reading the codebase, don’t spend tokens being suspicious of normal input. The linguistic agents can spend whatever they need on the actual translation; the orchestrator around them stays lean.
The bytes the model shouldn’t carry
The articles are around twenty kilobytes of Ukrainian. Early on, the model carried that text itself: it pasted the article into the workflow input, and base64 into the database-write command. Large language models corrupt long strings that aren’t real language, and base64 of Ukrainian is exactly that — a long run of [A-Za-z0-9+/=] with no linguistic structure to hold onto. A write came back corrupted on a real run. That one I caught, and the question I asked wasn't how to fix the encoding — it was why the model was reproducing base64 by hand at all.
It shouldn’t be. The fix took the model out of the byte path at both ends. Two small helper scripts handle the bytes now: one serializes the article into the workflow input, the other base64s the result in-script and runs the write. The model only passes a file path and an id. base64 stays as the transport, because it sidesteps the triple-nested shell-quoting of apostrophes and guillemets on the way to the database — but it’s generated and consumed by scripts now, never typed out by the model. The write itself is guarded the way a production write should be: an optimistic lock that updates the row only if its content still matches the snapshot read at the start, and an affected-row count that aborts on zero instead of trusting the write landed.
The briefing I should have written first
The deeper mistake was mine. I handed Opus a porting job and almost none of what I knew. Every problem above is something I could have stated in one sentence before it wrote a line. So here is the briefing I should have led with — the one that goes in front of the model next time. It’s short:
- The script exports only
export const meta; everything else is top-level inline. A top-levelreturnis fine even thoughnode --checkdisagrees. argsarrives as a JSON string, so parse it. For anything large, bake it into the script and pass nothing.- The sandbox has no filesystem, shell, crypto,
Date.now, orMath.random. Anything needing those lives outside. - Don’t trust a single clean pass; require two in a row. And the runtime’s agent tally is the count of
agent()calls, not your cycle cap — readoutcome, not the counter. - Subagents get a system prompt through
agentType; there is nosystemparameter, and new agent files need a restart before they resolve. - Match a structured-output schema to how the prompt already answers. Don’t bolt a multi-field schema onto a free-form prompt.
- Constrain tools with a denylist, never an allowlist.
- The model never carries long text or base64; scripts do, and the write is guarded by an optimistic lock on the row it read, not a hope that the bytes survived.
- When the model states framework behavior, verify it against the source. Confident and wrong reads exactly like confident and right.
The last item is the one that makes the briefing necessary at all. The model won’t tell you when it’s guessing, so the gaps never announce themselves. The briefing has to carry what the model won’t admit it’s missing, and writing it, not the code, is the actual work.
I didn’t catch most of the bugs. The validation runs caught the empty article; the dry-runs caught the schema clash and the date leak; a probe caught the agent that wouldn’t register. What I caught was the system misbehaving in the open — the orchestrator treating a perfectly normal article as a state worth investigating, a subagent reading the Laravel codebase instead of translating, a write that came back corrupted. Those were the places I was watching the system behave instead of reading its code. The rest the model found on its own, because I made it verify instead of assume. My year with the old loop gave me the target and the spec — the two strikes, the cap, what to strip, what a clean translation should read like. But spotting the runtime bugs was rarely my job. What I did instead was decide what to build, hold the model to the documentation, and refuse to let it call itself done.