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-protocol for 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 rest
  • agent(prompt, { label, schema }) — spawn a sub-agent; with a JSON Schema it's forced to call a StructuredOutput tool before completing
  • phase(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:

  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.
  2. JSON payload limit raised: slice(0, 60000)slice(0, 200000)
  3. Read Existing phase added: a new first phase reads discover.ts, extracts all existing name values, 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:

  1. 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
  1. 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:

  1. Per-label progress files in .discovery-progress/ — one small JSON file per processed chunk. Before calling agent(), 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.

  1. 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\/accentmirego\\/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.

support

Found this useful? You can buy me a coffee.

☕ buy me a coffee