The previous session closed with a confident "all 32 skills work end-to-end" report. The verification was honest — 31 of 32 produced clean output against a live Merus case. The 32nd, the benefits skill, hit a permissions error on one of its four parallel fetches and bailed out with a generic two-word "ERROR" message. We diagnosed it as a Merus admin issue, fixed the one skill, and moved on.
That diagnosis was correct but the fix was incomplete. The same bug shape was in twelve other skills.
The bug shape
Every parallel-fetch skill ran four to six API calls in background processes, then collected exit codes with a single accumulator:
FAIL=0
for pid in $PID1 $PID2 $PID3 $PID4; do wait $pid || FAIL=1; done
if [ "$FAIL" -ne 0 ]; then echo "ERROR" >&2; exit 1; fi
If any fetch failed, the whole skill bailed. The user got a single line of stderr — "ERROR" or, in better cases, "ERROR: One or more API fetches failed" — with no indication of which endpoint, no preserved error message from the API, and no way to know whether the missing data was a permission gap, a network blip, or a server-side issue.
Twelve skills did this. Some called it "ERROR." Some prefixed "ERROR: One or more API fetches failed." Two skills failed silently because their final echo went to stdout where it got captured into a pipeline.
What the user actually experienced
One firm running this tool has a Merus API token scoped without the caseLedgersOpen permission. Every time anyone there asked for benefits data on a case, the skill ran for ~2 seconds and printed "ERROR." No partial output. No "ledger endpoint unavailable." No telling the user that 3 of 4 endpoints had succeeded and the activity-derived data was usable.
From the firm's perspective, the benefits skill simply didn't work. The whole feature looked broken. The actual situation: the skill was correct, the API token was scoped narrower than the skill assumed, and the bash glue was too brittle to recover.
The fix shape
Across all thirteen parallel-fetch skills (the original benefits fix plus the twelve siblings), the same pattern now applies:
node bin/merus-fetch.mjs /endpoint > "$TMPDIR/data.json" 2>"$TMPDIR/data.err" &
PID1=$!
# ... other fetches ...
declare -A R
wait $PID1 || R[data]=1
# ... track per-endpoint failures ...
# Required: data (no data = no skill output)
for ep in data; do
if [ "${R[$ep]:-0}" = "1" ]; then
echo "ERROR: required endpoint '$ep' failed:" >&2
cat "$TMPDIR/$ep.err" >&2
rm -rf "$TMPDIR"; exit 1
fi
done
# Optional: secondary (just one section's data)
if [ "${R[secondary]:-0}" = "1" ]; then
echo "NOTE: secondary unavailable — that section will be empty" >&2
echo '{"data":{}}' > "$TMPDIR/secondary.json"
fi
Four properties of this pattern:
- Per-endpoint stderr capture — the original API error is preserved in
$TMPDIR/$ep.errand surfaced to the user when the endpoint matters. If Merus said "Your account does not have sufficient privileges," that's what the user sees, not a generic "ERROR." - Per-endpoint success tracking — a bash associative array (
declare -A R) is keyed by endpoint name, not position. The error path doesn't have to count$PIDvariables; it iterates by name and the messages stay readable. - Required vs optional categorization — each skill now declares which endpoints are core to its output and which feed individual sections. Health-check needs the case view; it doesn't need the parties view. Compare needs both case views; the injuries views are nice-to-have. The categorization is explicit and lives in the skill source.
- Optional-endpoint fallback — when an optional endpoint fails, the skill writes a minimal
{}or{"data":{}}placeholder so the downstream node script can keep running. The affected section ends up empty, the user sees a NOTE explaining why, and the rest of the skill ships its normal output.
The categorization itself
Categorizing endpoints as required vs optional turned out to be more interesting than expected. The benefits skill's ledger is obviously optional (activity history covers most payment data anyway). But for other skills, the call was less clear:
- health-check parties — used only for a count. Optional.
- health-check injuries — used for the DOI display line and one apportionment hint. Optional, with a graceful empty state.
- compare injuries — populates the DOI / body parts row of the comparison table. Optional; that row just goes blank.
- brief uploads — feeds the "unprocessed mail count" line. Optional; the count goes to zero.
- batch-check everything — all four endpoints feed the firm-wide ranking score. Partial data would mislead triage. Required.
- sol-scan both — case list is the scan target, events provide the SOL dates. Both required.
The pattern that emerged: endpoints feeding the spine of the skill's output (counts, headers, status decisions) are required. Endpoints feeding individual rows or supplementary sections are optional. Endpoints feeding ranking scores are required because partial scoring is worse than no scoring.
The meta-lesson
The previous round of fixes (4.5.96, 4.5.113, 4.5.114) ended with the rule: grep for the bug expression, not the variable name. This round adds another tightening: per-skill testing surfaces bug shapes you didn't know to grep for.
The benefits skill's permission gap wasn't visible from any code-pattern search. We only saw it because the live test against an actual Merus token hit the actual permissions denial. Once we saw what the skill did when one endpoint failed, the pattern in the other twelve skills became obvious — but only in hindsight.
End-to-end testing against the real backend, one skill at a time, surfaces bugs that no amount of static analysis catches. That's how this release got found.
v4.5.117 ships the thirteen-skill normalization to graceful degradation. Required endpoints fail loud and specific. Optional endpoints fail soft with a NOTE. The user always knows which endpoint needs attention, and the skill ships whatever output it can.