How Spinneret works
Spinneret is two systems that need each other. One finds companies worth contacting by watching public sources for change. The other keeps the scrapers behind that feed alive without a person babysitting them. This page explains both, including the arithmetic, the thresholds and the reasoning behind each decision.
What Spinneret Is
Spinneret watches a small fleet of web scrapers built with Bright Data Scraper Studio. After every run it measures what came back, compares that against what the collector is contracted to deliver, and decides whether the scraper is still doing its job.
When it is not, Spinneret writes a repair prompt from its own measurements, sends that prompt through Bright Data’s self-healing API, approves the resulting patch and re-runs the collector to check the score actually improved. The whole cycle needs no human to describe what broke.
The data those collectors produce feeds a buying-signal board: a ranked list of companies where something recently changed in a way that suggests budget just moved.
The Core Claim
The Problem
Scrapers rarely fail loudly. A site ships a redesign, a CSS selector stops matching, and the extraction code keeps running perfectly. It returns the same number of rows on the same schedule. The rows are just empty.
Nothing in a normal pipeline notices this. HTTP returned 200. No exception was thrown. The job is green. Three weeks later a sales team reports that leads dried up, and someone starts bisecting a month of data to find out when the values turned into nulls.
Spinneret treats that silence as the actual bug. Every run is measured against a contract, so a field that quietly empties out is a first-class event rather than something you discover downstream.
A real example from this project
The job-board collector was created from the description extract every job listing: job title and company name. Bright Data built a scraper that returned 66 rows, and every one of them contained a single field: product_page_url. Neither contracted field was present.
A conventional pipeline would have called that a successful run of 66 rows. Spinneret scored it 20 out of 100, classified it as a schema gap, and repaired it. Nothing about that break was staged for a demo.
Quickstart
You need Node 20 or newer and a Bright Data account. The free tier includes 5,000 monthly credits and needs no card.
git clone https://github.com/hassaansaleem28/spinneret
cd spinneret
npm install
cp .env.example .env.local # paste your Bright Data token
npm run spinneret -- seed # register the collectors
npm run dev # dashboard on localhost:3000.env.local is gitignored. Keep your token out of commits and out of any screen recording.
Watch a collector heal itself
npm run spinneret -- observe hiring-signals # run, measure, diagnose
npm run spinneret -- heal hiring-signals # compose a prompt, dispatch it
npm run spinneret -- approve hiring-signals 1 # approve, re-run, record the deltaOr run the whole loop unattended with npm run spinneret -- cycle hiring-signals, which heals only if the evidence justifies it.
The Healing Loop
Seven stages, running continuously. The first three happen on every observation. The last four only run when the diagnosis found evidence worth acting on.
Observe
Run the collector against its target and record every row it returned.
Cycling. Click a stage to pin it.
When Spinneret refuses to heal
There are two cases, and both are deliberate.
A run that never completed. If the request timed out or was rate limited, nothing came back. Every field looks absent, which is indistinguishable from a site that rewrote itself completely. Healing that would burn a cycle and give the healing AI no live page to work from. Transport failure is not drift.
A collector that is merely degraded, with no specific field identified as the cause, is left alone on purpose. An unfocused prompt such as the scraper seems wrong tends to make a working scraper worse, because the healing AI has nothing specific to anchor a rewrite to. In that case Spinneret records the evidence, marks the collector degraded and waits for a person.
The same reasoning drives the cooldown. A heal that ran and did not raise the score is never retried on a timer, because re-sending a prompt already shown not to work costs credits and can degrade the scraper further. A visibly broken collector is the better failure mode.
Health Scoring
Every run produces one number between 0 and 100, made of three weighted terms. Try the scenarios below to see how the same formula separates a schema gap from a selector drift from a pagination break.
Every contracted field present and populated. This is the changelog collector on a normal run.
- Coverage× 0.5100% → +50
Mean fill rate across contracted fields
- Schema× 0.3100% → +30
Fraction of contracted fields present as keys
- Yield× 0.2100% → +20
Row count against the healthy baseline
Composite
Why a value has to carry information
Coverage counts a field as filled only if its value means something. null, an empty string, whitespace, and the literal strings "null", "N/A" and "undefined" all count as missing, as do empty arrays and empty objects. Broken scrapers emit these constantly, and treating them as real values is exactly how a hollow pipeline passes as healthy.
Two decisions about the baseline
Yield compares row count against a rolling baseline, and how that baseline is built matters more than the comparison itself.
It uses the median, not the mean. One catastrophic run returning two rows would drag a mean low enough to hide the next real break behind it.
It is built only from runs that already scored well. If degraded runs fed the baseline, the degradation would quietly redefine normal, and the following break would register as an improvement.
First Runs Are Not Punished
Drift Detection
Drift detection turns a score into a decision. It compares the run against the baseline, classifies the severity, and produces the evidence lines that later become the repair prompt.
Severity
The field is gone, not empty. This is a schema-level break.
Something substantial stopped working.
Heals only if a specific field was identified.
No action.
A timeout or rate limit returns nothing, which looks identical to a total site rewrite. There is no DOM to repair against, so healing would spend a cycle blind.
Thresholds
regressionDropPct: 25 // percentage-point fill-rate drop that counts
criticalScore: 55 // below this is a hard break
degradedScore: 80 // below this is degradation
minBaselineSamples: 2 // healthy runs needed before trusting comparisonsThese are deliberately conservative. A false positive costs one heal cycle and an approval gate. A false negative costs silently hollow data for days, which is the failure this whole system exists to prevent.
Telling two breaks apart
When fields regress but row count holds steady, the evidence says so explicitly: pagination and the row selector are intact, so the breakage is field level. When row count collapses instead, the evidence points at the row or pagination selector. These need different repairs, and naming which one it is steers the healing AI at the right part of the code.
Prompt Composition
This is the part that makes Spinneret different from a monitoring dashboard. The instruction sent to Bright Data is assembled from measurements, not typed by a person.
Dispatched Prompt · 403 chars
Evidence
States the observed failure with the specific field names. This clause is generated from the run’s telemetry, never typed by a person. A vaguer version such as “the scraper is broken” produces a vaguer patch.
Click any clause to see the job it does.
Fencing off what still works
When a collector is partially broken, the prompt lists the fields still filling above 90 percent and instructs the healing AI not to touch them. Without that clause a rewrite can regress working extraction while fixing the broken part, and the verification run would show a heal that helped one field and broke two others.
Two prompt shapes
If some contracted fields arrive and others never appeared, the task is to extend the schema, and the prompt says so plainly. If fields that used to work have hollowed out, the task is to repair drifted selectors. Naming the narrower job produces a more reliable patch.
The character budget
The CLI caps prompts at 1000 characters. Spinneret budgets to 960 to leave room for shell escaping. When the budget binds it drops evidence lines from the middle, because the opening clause names the target and the closing clause states the required schema, and losing either one produces a worse patch than losing a detail.
Contract Lab
Everything else on the dashboard is retrospective. It reports what already happened and asks you to believe it. The Contract Lab is the one place you can act, and it exists because a self-healing claim deserves to be tested rather than trusted.
Pick a collector, change what it is required to return, and the whole diagnosis re-runs in front of you: coverage moves, evidence assembles, and the repair prompt writes itself a character at a time.
None of it is simulated
The rows being scored are rows those collectors genuinely returned on their last healthy run. The scoring, the drift classification and the prompt composition are the same functions the sentinel runs in production, not a demonstration copy. Because the contract is the real definition of correct, requiring a field the scraper does not produce makes it genuinely non-compliant, and the text that appears is exactly what Spinneret would dispatch to Bright Data.
Try this
Require salary on the job board collector. The score drops, the field reads absent, and the prompt names it as the repair target while explicitly fencing off the fields that still work. That protection clause is generated, not written by hand, and it is what stops a repair regressing extraction that was already correct.
Why This Runs In Your Browser
Signal Scoring
A company in a directory is not a lead. A company that just opened four revenue roles is. Spinneret scores change, and every point traces back to a stated rule shown in the interface, because nobody acts on a number they cannot argue with.
Each source type gets its own rules
A job title, a changelog entry and a directory listing say different things about intent, so each runs against its own table rather than one table stretched across all three. Within a table only the strongest matching rule applies, which stops a company with many similar rows compounding the same evidence.
| Source | Rule | Points | Reasoning |
|---|---|---|---|
| hiring | GTM leadership hire | 34 | New leaders re-tool their stack within a quarter |
| hiring | RevOps hire | 32 | An explicit mandate to buy and integrate tooling |
| hiring | Revenue-carrying role | 30 | Quota capacity is being added |
| changelog | Enterprise readiness | 34 | SSO, SCIM and audit logs mean larger deals ahead |
| changelog | Integration surface | 26 | Points to an ecosystem play |
| directory | B2B software | 24 | Buys tooling as a matter of course |
| any | Newly appeared | 22 | The change is fresh, not historical |
| any | Concurrent activity | ≤27 | Coordinated build-out rather than backfill |
No Rule, No Signal
Single-subject sources
A competitor changelog has no company column. Every entry belongs to the one company that publishes it. Those collectors declare a subjectCompany, so attribution comes from the collector rather than from a field that will never exist.
Architecture
Four layers, with dependencies pointing one direction only.
Interface
app/, components/Dashboard, docs, HTTP routes and the SSE feed
may import: services, db, core
Services
services/Sentinel loop, scheduler, background jobs, read model
may import: adapters, db, core
Adapters & Storage
adapters/, db/The only modules that spawn the CLI or write SQL
may import: core
Domain Core
core/Health scoring, drift detection, prompt composition, signal rules
may import: nothing
Every arrow points one way. That is what lets the self-healing decision logic run in a unit test with no database, no network and no Bright Data account, and it is why swapping SQLite for Postgres touches exactly one file.
What lives in the core
health.ts fill rate, schema conformance, yield → composite score
drift.ts baseline comparison → severity + evidence lines
heal-prompt.ts evidence → natural-language repair instruction
signals.ts rows → scored buying signals with stated rationale
cooldown.ts whether a fresh heal is permitted yet
types.ts the domain vocabulary everything else speaksThese modules are covered by 48 unit tests. The retry policy in cooldown.ts is pure specifically because it is the code that spends money without supervision, so it is worth testing exhaustively.
Live Collectors
Three collectors, deliberately different in shape so they exercise different parts of the health model.
hiring-signals
c_mt4dzl5v15c84o86saWeWorkRemotely GTM job board
Paginated listing. The collector that was born broken and healed itself from 20 to 97.
funded-startups
c_mt4e1as4160iqj53asY Combinator directory, Summer 2025
JavaScript-rendered directory with an array field, which is why tags sits near 75 percent rather than 100.
product-velocity
c_mt4fj43j1tvgzr9bs2Linear changelog
Single-subject source with no company column at all.
Why Not LinkedIn
CLI Commands
Everything the dashboard can do is also a command, and both call the same service layer rather than reimplementing each other.
seed register collectors from config into the database
create <url> "<description>" build a new collector with Scraper Studio
observe <slug> run, measure, diagnose, store
heal <slug> compose a prompt from telemetry and dispatch it
approve <slug> <healId> approve the patch, re-run, record before and after
cycle <slug> the whole loop, healing only when justified
status fleet health and recent healsRunning on a schedule
npm run watch # every 30 minutes
SPINNERET_CRON="*/10 * * * *" npm run watch # or set your ownA sweep can outlast its own interval during a slow heal, so a late tick is skipped rather than queued. Overlapping runs would corrupt the shared baseline.
HTTP API
Thin routes over the service layer. Long operations return immediately and report progress through the event stream, because a heal can run for fifteen minutes and no browser should hold a request open that long.
| Method | Route | Purpose |
|---|---|---|
| GET | /api/state | Whole-dashboard snapshot in one round trip |
| GET | /api/stream | Server-sent events, pushing state every 2 seconds |
| POST | /api/observe | Run and measure one collector |
| POST | /api/heal | Compose and dispatch a repair for one collector |
| POST | /api/approve | Approve a pending heal, then verify it |
| POST | /api/sweep | Supervise the whole fleet, the same pass cron runs |
Server-sent events rather than websockets: data only travels one way, and EventSource reconnects on its own, so a dropped connection during a long heal recovers with no client retry code.
Data Ethics
Public data only. No login-walled sources, no paywalled content, and no personal data.
Signals are scored at company level and derived from public job postings, directory listings and published changelogs. Spinneret does not collect, store or score details about any individual. A job posting contributes its title and its employer, never a named person.
Every target is a source with no pre-built Bright Data scraper, chosen from the categories the brief asks for: niche job boards, regional and vertical directories, and competitor changelogs.