If you read the Orbiter post, you'll know one of the features is service discovery — a port scanner that fingerprints open services and tells you what they are. I mentioned the fingerprint dictionary was built by a multi-agent workflow that scraped the awesome-selfhosted dataset in parallel.
This post is the full story of that workflow: the exact agent structure in each run, what broke, what got fixed, and what the numbers looked like at every stage.
## The Problem
Service fingerprinting needs a dictionary. For each self-hosted application, you need:
- name and icon for display
- category to organise the UI
- description (one line)
- bodyPatterns — regex strings to match against the HTTP response body
- headerPatterns — regex matches against response headers (e.g.
x-plex-protocolfor Plex)
I started with 39 hand-written entries covering the obvious ones: Grafana, Portainer, Jellyfin, Pi-hole. That covered maybe 5% of what people actually run. The awesome-selfhosted-data repository has structured YAML for ~2,000 self-hosted projects, organised as one file per starting letter:
https://raw.githubusercontent.com/awesome-selfhosted/awesome-selfhosted-data/master/software/s.yaml
Each file is a list of YAML records:
- name: Stirling PDF
website_url: https://stirlingpdf.io
description: Locally hosted web application for PDF manipulation.
tags:
- Document Management
19 of the 26 letters have files with actual content. The goal: fetch all of them in parallel, extract fingerprint rules for every service with a web UI, deduplicate, and write TypeScript.
## The Workflow Tool
The workflow runs as a JavaScript script inside Claude's multi-agent harness. The key primitives:
parallel(thunks)— spawn N agents concurrently, capped at ~14 simultaneous, queue the restagent(prompt, { label, schema })— spawn a sub-agent; with a JSON Schema it's forced to call aStructuredOutputtool before completingphase(name)— group subsequent agents under a progress label in the UI- Journal caching — completed
agent()calls are stored by a hash of(prompt, opts); a resume replays them instantly without re-running
The script is a plain JS file. Edit it on disk, re-invoke with resumeFromRunId, and only calls whose prompt changed will actually run. Everything else is a cache hit.
## Run 1 — The First Architecture
Task ID: w3a4f00dd
Agent structure:
Phase: Fetch YAMLs
└─ 26 parallel agents (fetch:a.yaml … fetch:z.yaml)
Phase: Extract Fingerprints
└─ 7 parallel agents, 4 letters per batch
batch-0: a,b,c,d batch-1: e,f,g,h batch-2: i,j,k,l
batch-3: m,n,o,p batch-4: q,r,s,t batch-5: u,v,w,x batch-6: y,z
Phase: Synthesize
└─ 1 agent: write discover.ts + tsc + commit
Total: 34 agents
Each extraction agent received up to 80KB of merged YAML from its 4-letter batch and a JSON Schema forcing structured output with entries[]. The extraction prompt included a RELEVANT_TAGS filter — Media, Monitoring, Storage, DNS, Development, etc. — to reduce noise.
Results:
- Subagent tokens: 949,263
- Tool uses: 323
- Duration: 18 min 43 sec
- Entries extracted: 821
- Entries written to disk: ~260
The write agent received JSON.stringify(deduped).slice(0, 60000) — a 60KB hard cap. At ~300 bytes per entry that's roughly 200 entries. The remaining ~560 were silently dropped before the agent ever saw them. The TypeScript compiled clean and the commit went through, but the dictionary was missing two-thirds of what was extracted.
## Between Runs — Script Edits Applied Live
The workflow script is a file on disk. While a run is in progress (or paused), you can edit it and resume — completed agents cache-hit, only changed/new calls re-run. The next several runs applied cumulative fixes this way.
## Run 2 — Broader Extraction, Higher Payload Limit
Task ID: wlf5z2bhh
Stopped mid-run (before synthesize completed)
Changes from Run 1:
- RELEVANT_TAGS filter removed. Extract every service with a web UI regardless of category, including unmaintained and archived projects — they had working web UIs and can still be fingerprinted.
- JSON payload limit raised:
slice(0, 60000)→slice(0, 200000) - Read Existing phase added: a new first phase reads
discover.ts, extracts all existingnamevalues, and builds a skip list injected into each extraction agent's prompt.
Agent structure:
Phase: Read Existing
└─ 1 agent: read discover.ts → comma-separated names
Phase: Fetch YAMLs
└─ 26 parallel agents (all journal cache hits from Run 1 → instant)
Phase: Extract Fingerprints
└─ 26 parallel agents, 1 per letter (changed from 7 batches)
extract:a.yaml, extract:b.yaml, … extract:y.yaml
Phase: Synthesize
└─ 1 agent: write + tsc + commit (200KB payload limit)
Total: 54 agents (26 fetch cache hits + 27 running)
The 26 fetch agents replayed from journal cache — no network calls. The 26 extraction agents ran fresh because the prompt changed (tag filter removed + skip list added).
By the time the extraction phase completed, 11 of the 26 letter agents had returned results totalling 3,013 entries with 4 agents still running. We stopped the run at this point rather than wait for the single 200KB-limited synthesize agent to truncate again.
## Run 3 — Batched Writes, Chunked Extraction
Task ID: wa9v5z9cp
Stopped mid-extraction
Changes:
- Batched sequential writes instead of one write agent:
const WRITE_BATCH = 150; // later raised to 500
for (let i = 0; i < writeBatches.length; i++) {
await agent(`BATCH ${i+1}/${writeBatches.length}: append these entries...`,
{ label: `write-batch-${i+1}-of-${writeBatches.length}` });
}
// tsc only on the final batch
- Overflow chunking for files larger than 150KB — split at
\n- name:boundaries into 40KB chunks, each chunk gets its own agent:
// Snap to next YAML record boundary — no entry ever split mid-record
const boundary = content.indexOf('\n- name:', end);
end = boundary !== -1 ? boundary + 1 : content.length;
extractionTasks.push({ label: `extract:${letter}.yaml#${chunkIdx}`, ... });
Agent structure (target):
Phase: Read Existing — 2 parallel agents (names + progress cache)
Phase: Fetch YAMLs — 26 parallel agents (all cache hits)
Phase: Extract Fingerprints — 26+ agents (1 per letter + overflow chunks for large files)
Phase: Save Progress — 1 agent writing .discovery-progress.json
Phase: Synthesize — N sequential write-batch agents + 1 commit agent
Stopped reason: user asked to stop to apply the 500-entry batch size. No synthesize ran.
## Run 4 — The Stuck Agent Crisis
Task ID: wmg4nk241
The run resumed with the previous extraction agents replaying from cache. But 4 agents stalled completely — no output for 17–28 minutes:
| Agent | Transcript size | Silent for |
|---|---|---|
a8933208035c84cff |
131 KB | 28 min |
a2fd08b8273532074 |
241 KB | 22 min |
a8a4c68b153b8f521 |
93 KB | 19 min |
a0bbb1f144931a936 |
65 KB | 17 min |
Inspecting their transcripts revealed the last message was type: assistant with a pending tool_use call — the agent had called a tool and was waiting for a response that never came.
The cause: extraction agents were making WebFetch calls. Without explicit restriction, agents use available tools to supplement the YAML content — looking up port numbers from documentation, checking source repos, verifying descriptions. One agent made 97 WebFetch calls. Slow or rate-limited external URLs stalled it indefinitely.
The fix was one sentence added to the prompt:
IMPORTANT: Do NOT use WebFetch, WebSearch, or any external tools.
Use ONLY the YAML content provided below.
Stopped and resumed.
## Run 5 — Progress Cache, Parallel Saves
Task ID: w3pdfp1z2
Stopped — Read Existing spawned 19+ agents
New additions:
- Per-label progress files in
.discovery-progress/— one small JSON file per processed chunk. Before callingagent(), the script checks this directory:
if (cachedLabels.has(label)) {
// Skip entirely — agent() is never called
continue;
}
This is the critical difference from journal caching: the journal still spawns the agent (overhead ~5–30s each), checks the cache, and replays. The progress file skips the spawn entirely.
- Parallel save agents — instead of one agent writing a multi-megabyte JSON blob, each label gets its own small file written in parallel:
await parallel(
newLabels.map(label => async () => {
await agent(`cat > ${PROGRESS_DIR}/${fname} << 'EOF'
${JSON.stringify(updatedCache[label])}
EOF`, { label: `save-progress:${label}` });
})
);
Stopped reason: Read Existing now read all progress files by spawning one agent per file — 19 agents at ~20–40K tokens each, several taking 1–3 minutes. This was worse than what it replaced.
Agent structure (before fix):
Phase: Read Existing
├─ read-existing-fingerprints (1 agent)
├─ list-progress-files (1 agent)
└─ read-progress:extract_a.yaml.json ← 19 separate agents, one per file
read-progress:extract_b.yaml.json
...
read-progress:extract_y.yaml.json
Total Read Existing: 21 agents doing what should be 1 bash loop.
## Run 6 — Single Bash Loop Reads All Progress Files
Task ID: w9euc6mcn
Stopped — user confirmed
Fix: collapse the 19 per-file agents into a single agent running a bash loop:
await agent(
`Run: for f in ${PROGRESS_DIR}/*.json; do [ -f "$f" ] &&
echo "===FILE:$(basename $f)===" && cat "$f"; done`,
{ label: 'read-all-progress' }
);
The output is a concatenated string. The workflow script parses it by splitting on ===FILE:...=== markers:
const chunks = progressDump.split(/===FILE:([^=]+)===/);
for (let i = 1; i < chunks.length; i += 2) {
const label = chunks[i].replace(/\.json$/, '').replace(/__/g, '#').replace(/_/g, ':');
progressCache[label] = JSON.parse(chunks[i+1].trim());
}
Read Existing now: 2 agents (one reads discover.ts, one reads the progress directory). Both run in parallel.
## Pre-populating the Cache Manually
At this point 19 letters had been extracted across Runs 1–4, but the progress directory was empty — it was a new feature, nothing had been written to it yet. Rather than wait for a full re-extraction pass, the journal was mined directly with Python:
# Map each completed extraction agent to its letter
# by reading the first line of its transcript
with open(agent_path) as f:
first_line = json.loads(f.readline())
text = first_line['message']['content'] # the original prompt
m = re.search(r'file: ([a-z])\.yaml', text)
if m:
label = f"extract:{m.group(1)}.yaml"
progress[label] = completed_results[aid]
19 labels written to .discovery-progress/. Run 7 would skip all of them.
## Run 7 — The Fast Run
Task ID: wvezbknrv
Agent structure:
Phase: Read Existing (2 agents, parallel, ~5 sec total)
├─ read-existing-fingerprints → 292 names from discover.ts
└─ read-all-progress → 19 cached labels, bash loop
Phase: Fetch YAMLs (26 agents, parallel, ~instant — all journal cache hits)
Phase: Extract Fingerprints
└─ 0 agents spawned
All 19 letters in progress cache → skipped before agent() is called
6 remaining letters (d,h,i,k,l,r) → all 404 in repo
Phase: Save Progress (0 new files)
Phase: Synthesize
├─ Dedup: 4,730 pooled entries minus 292 existing = 4,438 new
└─ write-batch-1-of-9 … write-batch-9-of-9 (500 entries each, sequential)
└─ commit
What actually happened: the synthesize batch agents failed. The write-and-commit agent received the JSON, prepended conversational text around it ("Here are the entries you requested:"), and the downstream JSON.parse() threw SyntaxError: Unexpected identifier "The".
## The Final Run — Bypassing Agents for the Write Phase
After multiple synthesize failures (JSON prepending, regex escaping errors in the generated TypeScript, double-escaped forward slashes), the write phase was removed from the workflow entirely.
Instead: Python directly generates the TypeScript and writes the file.
def escape_regex(pattern):
# Only escape '/' not already preceded by backslash
return re.sub(r'(?<!\\)/', r'\\/', pattern)
def make_ts_entry(e):
parts = []
if e.get('bodyPatterns'):
regexes = ', '.join('/' + escape_regex(p) + '/i' for p in e['bodyPatterns'] if p)
parts.append('bodyPatterns: [' + regexes + ']')
if e.get('headerKey') and e.get('headerPattern'):
parts.append("headerPatterns: { '" + e['headerKey'] + "': /"
+ escape_regex(e['headerPattern']) + "/i }")
parts += [
"name: '" + e['name'].replace("'", "\\'") + "'",
"icon: '" + e['icon'] + "'",
"category: '" + e['category'] + "'",
"description: '" + e['description'].replace("'", "\\'") + "'",
]
return ' { ' + ', '.join(parts) + ' },'
The tricky part was the escaping. TypeScript regex literals use /pattern/i syntax — a / inside a pattern terminates the literal early. The naive fix (pattern.replace('/', '\\/')) double-escaped patterns that already had \/ in them: mirego\/accent → mirego\\/accent → TypeScript sees \\ (escaped backslash) then / as regex terminator.
The (?<!\\) negative lookbehind skips / already preceded by \. Result: 1,209 entries, tsc --noEmit clean, committed in 30 seconds.
## Run 8 — Remaining Letters
Task ID: wk6szf646
A focused workflow targeting only d, h, i, k, l, r — the 6 letters not in the progress cache.
Agent structure:
Phase: Fetch
└─ 6 parallel agents (fetch:d.yaml … fetch:r.yaml)
Phase: Extract
└─ 3 parallel agents (d,h,i,k,l,r → only 3 returned content > 10 chars)
k.yaml: 14 chars (404 message)
l.yaml: 78 chars (404 message)
r.yaml: 88 chars (404 message)
— all passed the content.length > 10 filter but contained 404 text
— extraction agents returned 0 entries each
The letters d, h, i, k, l, r are genuinely absent from the awesome-selfhosted-data repository at the time of writing — those letters have no files in the software/ directory. Services starting with H (Heimdall, Homer), D (Dasherr), etc. are either not yet in the data repo or listed under a different canonical name.
Result: 0 new entries. The Python write script had already committed 1,209 entries earlier; the final dictionary stands at 1,501 fingerprints.
## Every Run at a Glance
| Run | Task ID | Agents | Phase reached | Outcome |
|---|---|---|---|---|
| 1 | w3a4f00dd |
34 | Commit | ✅ Committed 260 entries (560 truncated) |
| 2 | wlf5z2bhh |
~30 running | Extract (partial) | ⛔ Stopped — single write agent would truncate again |
| 3 | wa9v5z9cp |
~26 running | Extract (partial) | ⛔ Stopped — batch size change needed |
| 4 | wmg4nk241 |
4 stuck | Extract (stalled) | ⛔ Stopped — agents stuck on WebFetch calls |
| 5 | w3pdfp1z2 |
21 in Read Existing | Read Existing | ⛔ Stopped — 19 agents to read 19 files |
| 6 | w9euc6mcn |
2 in Read Existing | Read Existing | ⛔ Stopped — user confirmed fix was correct |
| 7 | wvezbknrv |
2 + 9 write batches | Synthesize | ❌ JSON parse error in write agent |
| 8 | wk6szf646 |
9 (6 fetch + 3 extract) | Extract | ✅ 0 new entries, 3 of 6 letters 404 |
| — | Python direct | 0 agents | Write | ✅ 1,209 entries written, tsc clean, committed |
## Total Numbers
| Metric | Value |
|---|---|
| Workflow runs (start → stop cycles) | 9 |
| Total agent calls (main workflow journal) | 153 started, 145 completed |
| Subagent tokens — Run 1 alone | 949,263 |
| Output tokens across all transcripts | ~410,000 |
| Total agent transcript data on disk | 15.3 MB |
| Letters with YAML content | 19 of 26 |
| Peak concurrent agents | 26 (fetch phase) |
| Entries extracted across all runs | 5,326 |
| Entries written to discover.ts | 1,501 |
| WebFetch calls by a single stuck agent | 97 |
| Duration — Run 1 (cold, 34 agents) | 18 min 43 sec |
| Duration — warm cache run (0 extract agents) | < 5 min |
The gap between 5,326 extracted and 1,501 written: deduplication across restarts (same services re-extracted each time the prompt changed and cache-busted), plus the 60KB truncation in Run 1 discarding ~560 entries before the limit was raised.
## The Final Architecture
What the workflow script looks like after all iterations:
Phase: Read Existing (2 agents, parallel, ~5 sec)
├─ read-existing-fingerprints
│ Bash: grep 'name:' discover.ts → comma-separated list → existingNames Set
└─ read-all-progress
Bash: for f in .discovery-progress/*.json; cat $f → parse per-label entries
Phase: Fetch YAMLs (26 agents, parallel)
└─ fetch:a.yaml … fetch:z.yaml
All journal cache hits on warm run → ~instant
New run: parallel WebFetch, 19 return content, 7 return 404
Phase: Extract Fingerprints (0–60+ agents, parallel)
├─ Letters in progress cache → skipped, agent() never called
├─ New small letters → 1 agent per letter (extract:d.yaml)
└─ New large letters → 1 base agent + overflow chunks at 40KB boundaries
extract:t.yaml (base, 0–150KB)
extract:t.yaml#1 (overflow, 150KB–190KB)
extract:t.yaml#2 (overflow, 190KB–end)
Phase: Save Progress (parallel, one agent per new label)
└─ cat > .discovery-progress/extract_t.yaml.json << EOF ... EOF
Phase: Synthesize
├─ Dedup: allEntries from cache + fresh → filter against existingNames Set
└─ Python script (not an agent):
escape_regex() → insert into discover.ts anchor → npx tsc --noEmit → git commit
## What Breaks in Agent Pipelines
A few things that weren't obvious before running this at scale:
Stable prompts are infrastructure
Any variable in an extraction prompt that changes between runs — a skip list, a timestamp, a count — busts the journal cache for every agent that uses it. Design prompts to be deterministic from the start. The skip list moved out of the extraction prompt and into the Synthesize phase's dedup Set for exactly this reason.
Two kinds of caching have different costs
Journal cache hits still spawn the agent context and have 5–30s of harness overhead each. The progress file if (cachedLabels.has(label)) continue; is zero overhead — agent() is never called. At 26 letters × 30s, the difference is 13 minutes per run.
WebFetch in extraction agents is a footgun
Give an agent access to external tools without explicit restriction and it will use them to supplement incomplete data. One agent made 97 WebFetch calls. One prohibited line in the prompt eliminated the problem entirely.
The write phase is harder than the read phase
Extraction is embarrassingly parallel and stateless. Writing to a shared TypeScript file sequentially, generating syntactically correct regex literals, escaping forward slashes without double-escaping already-escaped ones — that's where most failures happened. A 15-line Python function is more reliable than an LLM for this class of problem.
The fingerprint dictionary is now at 1,501 entries. The workflow script lives at .claude/workflows/scripts/build-discovery-dictionary-*.js. Next time awesome-selfhosted-data adds new entries, the workflow fetches the updated YAMLs, skips all cached letters, extracts only the new ones, and Python writes the diff — the expensive part only runs once per letter.