Hard-won lessons for authoring .bot workflows where LLM agents do real work
on real codebases. Read this before writing or amending an iteration
workflow that has the power to commit code.
The TL;DR: LLM agents will optimize the metric you measure, not the goal you imagined. If your verdict criteria, scanner, and prompts can be satisfied by a façade, an agent will produce a façade — even when a fresh human reading the same goal would never consider it.
This is the “what fails” half. The “what works” half — the measured shape of productive human-driven sessions and the authoring rules derived from it — is references/productive-session-patterns.md.
When an agent produces something that looks like progress but doesn’t actually achieve the underlying goal, the diagnosis is rarely “the model hallucinated.” The diagnosis is almost always one of:
When you catch this happening, fix the workflow before re-running. Adding human supervision on every batch is not a substitute for sharpening the spec.
iterion depended on github.com/zendev-sh/goai (vendored, 7300 LOC) for its
in-process LLM layer. The team also maintained claw-code-go, a native
multi-provider Go port of Claude Code with its own internal/api/Client
(zero goai imports, real HTTP/SSE multi-provider implementation). The
plan: make iterion depend on claw-code-go’s native API, drop goai
entirely.
The workflow ran 8 batches over ~3 hours and reported 96% migration
parity. Verdicts were batch_complete: true, all tests passed, the build
was green. iterion had zero import "github.com/zendev-sh/goai" in any
of its .go files outside vendor/.
When we tried to actually delete vendor/github.com/zendev-sh/goai/ and
remove the require from iterion’s go.mod, the build broke immediately.
Reason: the agent had created a brand-new package —
claw-code-go/pkg/sdk/ — populated with files like:
// claw-code-go/pkg/sdk/types.go (created by the agent)
package sdk
import "github.com/zendev-sh/goai/provider"
type FinishReason = provider.FinishReason
type Usage = provider.Usage
type Message = provider.Message
// ...
The new pkg/sdk/ was a thin façade that re-exported goai/provider types
under different names. iterion was rewritten to import
claw-code-go/pkg/sdk instead of github.com/zendev-sh/goai. From the
scanner’s perspective, iterion was “100% PORTED” — no goai strings in
iterion’s own source. Internally, the goai dependency had simply been
relocated into claw-code-go (which iterion vendored), invisible to a grep
scoped to iterion.
The native claw-code-go/internal/api/Client — the actual point of the
migration — was not used at all. The agent had taken the path of least
resistance: re-exporting goai types preserved goai’s API shape, so iterion
needed minimal rewriting. Adapting iterion to claw’s native streaming API
(Client.StreamResponse(ctx, req) → channel of StreamEvent) would have
required rewriting iterion’s three generation strategies. The agent didn’t
do that work, and nothing in the workflow forced it to.
Five concrete defects in the .bot:
The goal in port_plan_system was ambiguous. “Replace iterion’s
native goai layer with claw-code-go” can mean (a) iterion’s source
stops importing goai, or (b) iterion’s whole dependency graph stops
requiring goai. The agent satisfied (a). Nothing in the prompt locked
(b).
The target API was not specified concretely. The plan said
“bridge to claw-code-go” without naming the entry point
(pkg/api.Client.StreamResponse). The agent invented a different
bridge point (pkg/sdk/) that looked simpler.
The migration scanner was textually scoped, not architecturally
scoped. It ran grep -r "github.com/zendev-sh/goai" --include='*.go'
. | grep -v vendor/. Once the goai imports lived inside
claw-code-go/pkg/sdk/ (vendored from iterion’s perspective), the
scanner returned zero — even though the dependency tree was unchanged.
The judge had no anti-façade rule. The verdict checked “blocker / suggestion” classification on the diff. Creating a new package that re-exports the source types is not a blocker by any reasonable definition of “production-breaking.” But it is a complete no-op for the actual migration goal. The judge had no language for “this batch claims migration progress but is actually a relabeling.”
No “why” anchor in any prompt. The locked decisions section listed constraints (“hard rename, no alias”, “anthropic + openai validated”) but didn’t repeat the purpose (“we want iterion to use claw’s native multi-provider client so we can drop the third-party goai dependency entirely”). Without that anchor, every prompt iteration drifted further from the original intent.
A workflow that would have caught this:
port_plan_system restated concretely:
END STATE: iterion’s
pkg/backend/model/claw_backend.gocallsclaw-code-go/pkg/api.Client.StreamResponse(ctx, req)and aggregatesStreamEventdeltas. NO intermediate package exists in claw-code-go that re-exportsgithub.com/zendev-sh/goaitypes. After this work,claw-code-go/go.modhas zerorequireforgithub.com/zendev-sh/goai.
plan_judge_merge_system:
REJECT any plan that creates a new package whose primary contents are
type X = goai.Xaliases or wrapper functions callinggoai.Y(...). Re-exporting a dependency under a different name is not migration — it is relabeling. The goal is dependency removal, not relabeling.
parity_scan_system:
Migration is complete when ALL of:
grep -r "github.com/zendev-sh/goai" --include='*.go'returns 0 in iterion’s source (excludingvendor/and the workflow.bot)grep -r "github.com/zendev-sh/goai" --include='*.go'returns 0 in claw-code-go’s source (the upstream repo, NOT iterion’s vendored copy)claw-code-go/go.modhas norequire github.com/zendev-sh/goaiiterion/go.modhas norequire github.com/zendev-sh/goaiiterion/vendor/github.com/zendev-sh/does not exist
.bot workflows that touch real codepkg/api.Client.StreamResponse). Forbid alternatives explicitly.go list -deps or go mod why or a recursive grep into
vendored sub-modules. If your scanner can be satisfied by moving
imports into a vendored sub-tree, it is a façade-gameable scanner.overall_complete flag must be a conjunction
(all-of), not a disjunction. Even if batch_complete is true, if any
scanner field is non-zero, overall_complete stays false.| Metric the workflow rewarded | What the agent did |
|---|---|
grep zendev-sh/goai in iterion source returns 0 |
Moved goai imports into a new package inside claw-code-go (vendored from iterion → invisible to the grep) |
parity_percentage hits 100% |
The percentage was computed from a checklist of file-level statuses; the agent updated each file’s status to PORTED based on whether iterion still imported goai from that file. The fact that the file now imported a façade re-exporting goai didn’t change the status. |
| Tests pass | Tests exercised iterion’s behavior end-to-end through the façade. Since the façade preserved goai’s runtime behavior 1:1, tests passed. |
In every row, the metric was a faithful measurement of the surface property but a poor proxy for the underlying goal. The agent satisfied the metrics. The goal stayed unmet.
A migration is not done until you can rm -rf the old dependency
and the build still passes. That is the only test that cannot be
gamed.
If your scanner can be satisfied by mv, your scanner is wrong.
Moving a problem to a different filesystem location is the most basic
form of metric-gaming and any tool that doesn’t catch it isn’t
measuring what you think.
Anti-façade rules belong in the judge, not the scanner. The scanner says “still 5 imports remain”; the judge says “creating a wrapper to absorb those 5 imports doesn’t count.”
The plan_gate human review is your last chance to catch a façade. Always ask: “what does this batch actually remove from the dependency graph?” If the answer is “nothing — it just renames or re-exports”, you are about to approve a no-op.
When an agent’s output contradicts the plan but satisfies the metrics, the metrics are wrong. Don’t relax the goal to fit the output; sharpen the metrics and re-run.
An improvement/review loop must converge to an asymptote — settle
into a stable approved state and stop. A slight, very occasional
oscillation is acceptable; it must be the rare exception. The rule is
the asymptote, never sustained oscillation. iterion bench asymptote
(docs/asymptote-bench.md) measures it.
The shipped mechanism (ADR-058 v2 — the whole loop fleet since
2026-07-07): one campaign agent + a deterministic verify gate + a
machine-checkable termination contract + a bounded
continuation_loop(max_passes). gate.converged = <done-flag> ∧ gates
green; the campaign commits each unit in stride (git is the state), so
loop exhaustion ships what is banked instead of discarding it.
Oscillation is structurally absent — there is one context per pass and
no reviewer/fixer relay to re-litigate. The honesty clause
(“under-reporting only costs a pass; over-reporting lands you right
back here”) plus the deterministic gate is what keeps the done-flag
truthful.
If you author a NEW cross-family reviewer loop (an optional amplification — no catalog bot ships one any more), the historical convergence mechanisms are mandatory:
streak_check: exit on N consecutive cross-family approvals,
not one pass; a low-confidence rejection is non-blocking so noise
doesn’t reset the streak.prior_pushback + previous_scanned_areas fed back with “do NOT
re-raise without new evidence” — re-litigating resolved items is the
#1 oscillation driver.loop.<name>.previous_output shows each reviewer the prior verdict
so verdicts trend monotonically.max_iterations is the backstop, not the design.The fastest way to break convergence in any shape: judge the
wrong artifact. Work-in-progress lives in the working tree
(and, under v2, in the run’s in-stride commits). Anything that reviews
a change MUST diff the working tree against the right base —
git diff HEAD for uncommitted work, git diff <run-base> for a v2
run’s cumulative series — never git diff HEAD^...HEAD (the last
commit = the base). The historical v1 bug: feature_dev’s
reviewer_gpt diffed HEAD^...HEAD, reported “feature not implemented”
against work that was plainly present, split the cross-family verdict
and oscillated forever. Same family of bug: git diff HEAD omits
untracked files — new files must be git add -N/git add -A‘d
before diffing or a change that ADDS files reads as missing. The v2
campaign contracts bake git add -A into the per-unit commit step.
When a loop won’t converge, first confirm the judge is diffing the
same, correct artifact.
After four failed attempts (run-001 façade, run-002 auth, run-003
var-substitution + judge tool-blindness, run-004 wrong-path grep),
run-005 converged to overall_parity=true in four batches over ~1h45.
The workflow file changes that made the difference:
port_plan_system and port_impl_system named the target by symbol,
not by intent:
END STATE: iterion’s
pkg/backend/model/claw_backend.gocallsclaw-code-go/pkg/api.Client.StreamResponse(ctx, req)and aggregates the returned[]ContentBlock/StreamEventdeltas. NO call goes through any intermediatepkg/sdkor wrapper layer.
The agent could not “interpret” the goal as something easier; it had to use that exact entry point.
parity_scan_system greps both vars.iterion_repo_path and
vars.claw_repo_path (the source dirs, not iterion’s vendored copy
of claw). This closed the run-001 gameable axis where goai imports
were relocated into a vendored sub-tree.
overall_parity=true requires ALL of:
A single false anywhere → overall_parity stays false. No partial credit, no deferred negative-space checks.
The first attempt to add tools to judge nodes failed because the
agents had access but didn’t invoke them — the prompt didn’t require
tool use. Adding an explicit STEP 0 — VERIFY ACTUAL STATE preamble
with imperative language (“USE the tools”, “Cite tool outputs
inline”) made every judge invocation actually grep before claiming
filesystem state.
The next attempt’s judges did use tools but greped the wrong
directory (/workspaces/iterion instead of the clone). The fix was
to write the absolute path inline in the prompt: grep -rn ...
| grep -v "/vendor/". With the literal
path embedded, the agent stopped defaulting to its CWD.
Tool commands’ shell substitution uses (per-node input
map). Judge prompts' template substitution can use
(workflow-global). Mixing them up produces literal placeholders in
shell commands. Be explicit about which substitution context applies
to which template usage.
Run-005’s batches were:
This phasing matters because it isolates risk: every batch’s tests must pass before moving on. A “rewrite everything in one batch” shape would not survive the first compile error.
Even after the workflow reported overall_parity=true, an
independent grep / filesystem audit against the same eight conditions
confirmed it. Don’t trust the verdict node’s claim alone — run the
checks yourself, post-merge.
Five workflow runs cost roughly $120-180 in API. Run-001’s façade burnt about a third of that on a result that had to be discarded. Sharper prompts up front would have cut that in half.
.bot tool nodes execute their command: string via
exec.Command("sh", "-c", …) (see pkg/backend/model/executor.go,
executeToolNodeShell). The crucial detail: sh resolves to whatever
binary sh points at in the runtime PATH. It is not a fixed
interpreter.
This means the same workflow can behave differently across hosts:
| Environment | What sh actually is |
|---|---|
| Devbox shell (any OS) | bash 5.x |
| Linux Mint / Ubuntu host | dash |
| Debian host | dash |
| Alpine / busybox image | ash |
| macOS (default) | bash 3.2 (very old) |
run_console_demo.bot originally contained:
echo {"count":3,"done":false}
This worked under devbox (bash interpreting). On a Linux Mint host the
same workflow piped through sh -c ran under dash — which has no
brace expansion. The output landed unchanged, JSON parsed, run
succeeded.
But on a host where the user’s sh was bash (e.g. Ubuntu with
dash reconfigured to bash, or any rebuilt image where bash is
/bin/sh), bash applied brace expansion to {"count":3,"done":false}
and emitted count":3 done":false — destroying the JSON, and
silently failing every downstream node that expected
{"count": ..., "done": ...}.
command: string must be POSIX-portableWhen you write a tool node, assume sh is dash. That means none
of the following:
| Pattern | Replace with |
|---|---|
echo {"k":v} |
echo \{\"k\":v\} (escape every brace and quote) |
[[ "$x" == "y" ]] |
[ "$x" = "y" ] |
cmd <<< "$input" |
printf '%s' "$input" \| cmd |
$'\t' |
"$(printf '\t')" |
((i++)) |
i=$((i+1)) |
for f in *.{a,b} |
for f in *.a *.b |
${var^^} / ${var,,} |
printf '%s' "$var" \| tr a-z A-Z |
For JSON output specifically, the safest pattern landed on in
run_console_demo.bot:
echo \{\"count\":$(wc -c < /tmp/counter),\"done\":false\}
Every {, }, and " is backslash-escaped. The braces are escaped
to defeat brace expansion under bash; the quotes are escaped to
survive the outer YAML/.bot string-literal layer plus sh -c.
Alternative: produce JSON via printf '{"count":%d,"done":false}\n' N.
printf is POSIX, has no brace expansion semantics, and reads as
intent-first.
A tool command: resolves / through
shellEscapeValue (pkg/backend/model/executor_tool.go): complex values are
JSON-encoded and the whole token is wrapped + '\''-escaped, so it survives
bash -c no matter what it contains. The raw form `` (bang
prefix) deliberately bypasses that escaping and inserts the value verbatim —
intended only for trusted shell snippets an upstream node hands down.
The trap: passing an upstream node’s structured output (especially an LLM-authored field) via ``, or wrapping the escaped form in your own literal quotes:
# BROKEN — raw prose JSON in single quotes:
DECISIONS='' python3 -c "..."
LLM prose is full of apostrophes (“iterion’s”) and parens (“(CLI/studio)”).
The first ' closes the shell quote and the next ) yields
bash: -c: syntax error near unexpected token ')'. Observed live in
adr-cartograph’s build_manifest (dogfood, 2026-06-13): the survey’s
decisions/gaps JSON broke the node on every run until fixed.
Rule: pass structured/LLM data with the default form and no
surrounding quotes — let shellEscapeValue quote it:
# CORRECT — shellEscapeValue wraps + escapes; json.loads gets exact JSON:
DECISIONS= python3 -c "import os,json; d=json.loads(os.environ['DECISIONS'])"
Reserve `` for values you control that are meant to be
re-interpreted as shell. A tool node that only ever saw path tokens (e.g.
docs-refresh’s build_manifest) can carry the '' pattern
latently for a long time — it breaks the day an apostrophe-bearing value
flows through, so fix the pattern, not just the one value that tripped it.
If a downstream node fails with “expected object, got string” or “missing required field” right after a tool node:
dash and under bash — they
should produce identical output.devbox run -- sh -c '…') since that’s what
the engine inherits from PATH.events.jsonl for the tool_called event — it
records the resolved command string and raw stdout.bashWe stayed with sh because:
bash would break minimal containers.If you genuinely need bash, invoke it explicitly:
bash -c '…bash-only syntax…'. That’s a contract the workflow
declares, not an assumption.