@enricai/barnacle 1.12.17 → 1.12.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,16 @@
1
1
  # Barnacle
2
2
 
3
+ ## How it works
4
+
3
5
  Point Barnacle at a site, describe the user flow in plain English, and run three
4
6
  recon commands. Barnacle drives a real browser through the flow, captures every
5
7
  API call, replays them with plain HTTP to prove which ones work without a browser,
6
- probes rate-limit ceilings, and then generates a complete plugin — Zod schemas
8
+ probes rate-limit ceilings, and generates a complete plugin — Zod schemas
7
9
  inferred from captured JSON, load-bearing headers, rate-limit ceiling, hot-path
8
10
  HTTP client, and Stagehand browser fallback. Register the plugin in one line;
9
11
  Barnacle handles sessions, retries, fallback routing, audit persistence, and
10
12
  response envelope wrapping.
11
13
 
12
- ## How it works
13
-
14
- ### The mental model
15
-
16
- Stagehand drives a real browser through your described user flow. Its only job is
17
- to trigger the site's network traffic — not to extract DOM data. While it clicks,
18
- a response listener wiretaps every API call to disk. Once that recon run is done,
19
- a separate script replays those captures via plain `fetch()` — no browser, no AI —
20
- to prove the endpoints work standalone. The surviving queries and headers become
21
- committed constants. In production, the runtime hits those endpoints directly:
22
- fast, cheap, deterministic. The browser only re-engages if the direct path breaks.
23
-
24
- A nightly smoke test tells you the moment a contract drifts. When it fires, you
25
- re-run the same recon command you ran the first time and diff the captures.
26
- Human involvement is one recon run up front and a small PR when things change.
27
-
28
- ### The pipeline at a glance
29
-
30
14
  | Phase | What runs | What you get |
31
15
  |-------|-----------|--------------|
32
16
  | **1 — Browser recon** | `pnpm run recon:browser` | Every API call the site makes, captured to `<run-dir>/graphql/*.json` |
@@ -34,945 +18,9 @@ Human involvement is one recon run up front and a small PR when things change.
34
18
  | **4 — Plugin generation** | `pnpm run recon:generate` | A complete plugin: Zod schemas, headers, Bottleneck config, hot-path client, Stagehand fallback |
35
19
  | **5+ — Runtime** | `pnpm start` | Direct HTTP hot path, automatic browser fallback, nightly smoke test, drift detection |
36
20
 
37
- ### Why this approach
38
-
39
- You will get asked why not just use the browser for every request, or scrape HTML,
40
- or reverse-engineer endpoints by hand. Here is the honest comparison:
41
-
42
- | Approach | Cost/req | Latency | Fragile to UI | Fragile to API | Effort |
43
- |----------|----------|---------|---------------|----------------|--------|
44
- | Browser on every request | High | 5–15 s | Medium | Low | Low |
45
- | HTML screen scraper | Low | Low | **High** | Low | Medium |
46
- | Manual DevTools recon | Low | Low | Low | High (human redo) | High (ongoing) |
47
- | HAR replay (mitmproxy) | Low | Low | Medium | **High** | Medium |
48
- | **Recon → codify → direct HTTP + fallback (this)** | **Low** | **Low** | **Low** (re-runnable) | **Low** (fallback covers) | Medium, front-loaded |
49
-
50
- The browser-on-every-call approach uses Steel minutes and Anthropic tokens on
51
- every production call — orders of magnitude more expensive at scale. HTML
52
- scrapers break on every UI redesign, and the API response usually carries richer
53
- data than what the UI renders anyway. Manual DevTools recon is exactly what this
54
- pipeline automates, but committed and re-runnable. Front-loaded recon work buys
55
- an integration as cheap as direct HTTP, as robust as a browser fallback, and
56
- maintainable in a way none of the hand-rolled options are.
57
-
58
- ---
59
-
60
- ## Adding a New Site — The Recon Playbook
61
-
62
- Every new site follows the same pipeline (Phases 0–6). The only human-authored
63
- input is the flow definition you write once in Phase 0. After that, the scripts
64
- run unattended — recon captures, HTTP replay proves endpoints, the generator
65
- writes the plugin. When the site changes months later, you re-run the same
66
- command and diff the captures. Human time is front-loaded to one recon run and
67
- a small PR.
68
-
69
- ### Phase 0 — Define the user flow
70
-
71
- Commit the flow steps to a file first — this makes recon re-runnable in one command without retyping it. When the site changes and you need to re-run recon months later, you `git pull` and run the same command you ran the first time:
72
-
73
- ```bash
74
- # src/sites/my-site/recon-flow.json
75
- ["click the Electronics category filter", "open the first product result"]
76
- ```
77
-
78
- ### Phase 1 — Run the browser recon harness
79
-
80
- ```bash
81
- # Preferred: load flow from committed file
82
- pnpm run recon:browser -- \
83
- --url https://example.com \
84
- --flow-file src/sites/my-site/recon-flow.json
85
-
86
- # Or inline (ephemeral — must be re-typed each recon run):
87
- pnpm run recon:browser -- \
88
- --url https://example.com \
89
- --flow '["click the Electronics category filter", "open the first product result"]'
90
-
91
- # For sites whose API paths don't match /graph, /api/, /graphql, /v1/, or *.json:
92
- pnpm run recon:browser -- \
93
- --url https://example.com \
94
- --flow-file src/sites/my-site/recon-flow.json \
95
- --capture-all
96
-
97
- # Capture page-load XHRs only (no interaction — useful for pure GET-style SPAs):
98
- pnpm run recon:browser -- --url https://example.com
99
- ```
100
-
101
- Drives a real Stagehand + Steel browser through your flow. Captures are wired via a single CDP session-level listener (`page.getSessionForFrame().on(...)`) — Stagehand V3 enables the Network domain internally, so attaching our `Network.requestWillBeSent` / `responseReceived` / `loadingFinished` listeners on the main session catches every response, including the early ones that fire before any page-level handler could be wired.
102
-
103
- Captures every network call matching `/graph`, `/api/`, `/graphql`, `/v1/`, or `*.json` to `<run-dir>/graphql/<NNN>-<phase>-<operationName>.json` — one file per call, diffable and greppable. Use `--capture-all` for sites with non-standard API paths; it captures every response, producing more noise but missing nothing. Omitting both `--flow` and `--flow-file` runs zero interaction steps and captures only the network activity that fires during page navigation — useful for pure GET-style SPAs that fetch everything they need on load.
104
-
105
- Each step runs through a self-healing cascade (`act` → `observe + act` → `observe + act` with `ignoreSelectors` → LLM rephrase) verified by network-counter delta or URL change. The script's `main()` attempts up to two global flow replans before giving up; terminal failures dump a diagnostic bundle to `<run-dir>/step-failures/`. See [docs/playbook.md#1c--self-healing-cascade](./docs/playbook.md#1c--self-healing-cascade) for the full design.
106
-
107
- Total runtime: 20–40 minutes for a typical flow (longer if healing or replans fire), fully unattended.
108
-
109
- Every artifact — captures, cookie-jar snapshots, step-failure dumps, DOM dumps — is rooted under one run-scoped directory resolved once at startup: `<run-dir>` defaults to `/tmp/recon/<runId>`, where `<runId>` is a timestamp + random suffix generated per process (e.g. `20260718-120326-a1b2`). Set `RECON_RUN_ID` to pin a deterministic runId (e.g. for tests or replaying a known run) and `RECON_OUT_DIR` to override the base directory runs are rooted under. This keeps concurrent or repeated runs from intermixing files — the startup log line prints both the resolved `runId` and `out=<run-dir>`.
110
-
111
- #### Cookie-jar snapshots
112
-
113
- Alongside the network captures, every run snapshots the browser's complete cookie jar (via CDP `Network.getAllCookies`, which returns the whole-browser jar regardless of the current page's URL — unlike `document.cookie` or `Page.getCookies`, it also sees HttpOnly cookies) at each phase boundary: the initial goto, immediately before each flow step (`pre-step`), immediately after each flow step completes (`post-step`), and once more at run completion (`run-complete`).
114
-
115
- Snapshots land in `<run-dir>/cookies/<NNN>-<label>-<phase>.json` — one file per boundary, using the same zero-padded chronological index convention as the network captures. `<label>` is the boundary kind (`goto`, `pre-step`, `post-step`, `run-complete`); `<phase>` is the current step's slugified instruction (e.g. `click-the-apply-button`), or `home` before the first step starts.
116
-
117
- Each file is a JSON object:
118
-
119
- ```json
120
- {
121
- "label": "post-step",
122
- "phase": "click-the-apply-button",
123
- "stepIndex": 2,
124
- "timestamp": "2026-07-18T12:34:56.789Z",
125
- "cookies": [
126
- {
127
- "name": "_acme_attr",
128
- "value": "abc123",
129
- "domain": ".acme.example",
130
- "path": "/",
131
- "expires": 1234567890,
132
- "size": 20,
133
- "httpOnly": true,
134
- "secure": true,
135
- "session": false,
136
- "sameSite": "Lax"
137
- }
138
- ]
139
- }
140
- ```
141
-
142
- Field reference (matches CDP's `Network.Cookie` type verbatim — no remapping between capture and disk):
143
-
144
- | Field | Meaning |
145
- | --- | --- |
146
- | `name` / `value` | The cookie's name and value. |
147
- | `domain` | Scope, e.g. `.acme.example` (all subdomains) vs. `apply.acme.example` (exact host) — the detail needed to tell a click-domain cookie from an apply-domain cookie. |
148
- | `path` | Cookie path scope. |
149
- | `expires` | Raw CDP epoch-seconds number; `-1` means a session cookie (also reflected in `session: true`). Not reformatted — read it as CDP reports it. |
150
- | `size` | Cookie size in bytes, as reported by CDP. |
151
- | `httpOnly` / `secure` | Standard cookie flags. |
152
- | `session` | `true` for a session cookie (no persistent expiry). |
153
- | `sameSite` | `"Strict" \| "Lax" \| "None" \| null` — `null` when the cookie doesn't set the attribute. |
154
-
155
- If the CDP call fails, the file still writes but with an empty `cookies` array and an `error` string field carrying the failure message — cookie telemetry is best-effort and never aborts the run.
156
-
157
- **Diffing what a phase established:** to isolate what a specific traversal (e.g. a tracking-click redirect) minted, diff its `post-step` snapshot against the `pre-step` snapshot for the *next* step — cookies present in the later file but absent from the earlier one were established during that step:
158
-
159
- ```bash
160
- diff <(jq -S .cookies <run-dir>/cookies/004-post-step-click-the-apply-button.json) \
161
- <(jq -S .cookies <run-dir>/cookies/005-pre-step-fill-in-your-name.json)
162
- ```
163
-
164
- **Cookies actually sent per request:** the jar snapshot shows what's *available*, not what's *sent*. Each network capture in `<run-dir>/graphql/` separately carries the outgoing `Cookie` header in its `requestHeaders` (recovered via CDP's `Network.requestWillBeSentExtraInfo`, since `requestWillBeSent` omits it by design) — cross-reference that capture's `requestHeaders.Cookie` against a jar snapshot to see which of the available cookies a given request, e.g. the application submit, actually sent.
165
-
166
- **Caveat on `Set-Cookie`:** response captures fold `responseReceivedExtraInfo` headers (which is where `Set-Cookie` actually appears — `responseReceived` omits it) into `responseHeaders` as a flat `Record<string, string>`. CDP does not guarantee multiple `Set-Cookie` values on one response stay distinguishable once folded into that shape — if a single response mints more than one cookie, treat the jar snapshot (not the response capture's `Set-Cookie` header) as the source of truth for what actually landed.
167
-
168
- ### Phase 2–3 — Replay and probe
169
-
170
- ```bash
171
- pnpm run recon:http
172
- ```
173
-
174
- Replays every capture via plain `fetch()` — no browser, no AI — to prove endpoints work standalone. Every replay returning 200 proves the browser is unnecessary for production. Also runs GraphQL introspection, auxiliary fixture detection (static JSON to commit as fixtures), and a rate-limit probe at 1→3→5 rps (run last — if it triggers a ban, all captures are already saved). Results land under the run-scoped root resolved by `resolveReconRunDir()` — `/tmp/recon/<runId>/replays/` by default, rooted elsewhere via `--out-dir <path>` or `RECON_OUT_DIR`.
175
-
176
- See [docs/playbook.md](./docs/playbook.md#interpreting-replay-failures) for the full troubleshooting decision matrix when replays fail.
177
-
178
- ### Phase 4 — Generate the plugin
179
-
180
- ```bash
181
- pnpm run recon:generate -- --site-id my-site
182
- ```
183
-
184
- Reads every artifact from Phases 1–3 — `<run-dir>/graphql/*.json` (captures), `<run-dir>/replays/*.json` (replay results), `<run-dir>/replays/rate-limit.json` (probe findings), `<run-dir>/aux/*.json` (static fixtures), and `src/sites/my-site/recon-flow.json` — and writes a complete plugin to `src/sites/my-site/`. Pass `--run-dir <path>` to read a specific run's artifacts instead of the most recently modified run root under `/tmp/recon` (or `RECON_OUT_DIR`, if set):
185
-
186
- - `contract.ts` — Zod schemas inferred from captured JSON, load-bearing headers, Bottleneck ceiling, and `executeHttp` / `execute` implementations
187
- - `flows/browser-flow.ts` — Stagehand fallback wired to your `recon-flow.json` steps
188
- - `index.ts` — barrel export
189
- - `fixtures/` — any static JSON found by the auxiliary probe, already copied in
190
-
191
- Then review the generated files: trim UI-only fields from the GraphQL query, narrow any `z.unknown()` entries in the schema you care about, and verify the headers. If you need to regenerate after making changes to the recon flow, pass `--force`.
192
-
193
- #### Telling the generator which fields carry your caller's data
194
-
195
- Barnacle cannot know what your site's forms mean. `"Select the neighborhood from the Country dropdown"` and `"…select the test candidate's country"` are the same sentence shape; only you know that the first is a search facet and the second is your caller's address. So the vocabulary is yours to supply, with `--vocabulary`:
196
-
197
- ```ts
198
- // src/recon/my-vocabulary.ts
199
- import type { ReconVocabulary } from "@enricai/barnacle/recon/vocabulary";
200
-
201
- export const vocabulary: ReconVocabulary = {
202
- // Only a step naming the SUBJECT may splice off a bare dropdown — a dropdown
203
- // step carries no quoted constant, so the label alone cannot tell your
204
- // caller's data from a filter that happens to say "Country".
205
- subject: /\b(the\s+)?(test\s+)?customer'?s\b/i,
206
- // Labels that must stay literal even when a table row matches.
207
- exclusions: [/\bbilling contact\b/i],
208
- // Ordered label → payload field. First match wins, so specific precedes broad.
209
- table: [
210
- [/\bfirst name\b/i, "FirstName"],
211
- [/\bcity\b/i, "City"],
212
- ],
213
- };
214
- ```
215
-
216
- ```bash
217
- pnpm run recon:generate -- --site-id my-site --vocabulary ./src/recon/my-vocabulary.ts
218
- ```
219
-
220
- - The specifier follows the same rule as `BARNACLE_PLUGINS`: a leading `.` or `/` is a filesystem path; anything else resolves from your `node_modules`. The module may export `vocabulary` or a default.
221
- - **`--vocabulary none`** for a site that splices no caller data at all (read-only inventory, search, pricing). Explicit, so it can't happen by accident.
222
- - Every regex must be free of the `g`/`y` flags and every field name must be a valid JS identifier — the loader rejects both. A stateful regex matches only every other step; a non-identifier emits `payload.<name>` as a syntax error.
223
- - Run the generator with a `.ts` vocabulary under `tsx`, or point it at compiled `.js` — plain `node` cannot import TypeScript before v22.18.
224
-
225
- > Omitting `--vocabulary` disables splicing entirely — every step's recon constant is emitted literally, with no caller-data substitution.
226
-
227
- #### Telling the generator your ATS's form-schema wire keys
228
-
229
- Where `--vocabulary` matches instruction *prose*, `--form-schema` names the JSON *keys* the generator reads out of an ATS's form-definition responses when recovering field ids, option ids, and submitted values. The engine ships no vendor's format; a site whose ATS exposes a form definition declares its keys with `--form-schema`:
230
-
231
- ```ts
232
- // src/recon/my-form-schema.ts
233
- import type { ReconFormSchema } from "@enricai/barnacle/recon/form-schema";
234
-
235
- export const formSchema: ReconFormSchema = {
236
- fieldIdKey: "fieldId", // UUID-valued field identity
237
- fieldNameKeys: ["code", "label"], // code, then human label — code preferred
238
- fieldOptionsKey: "options",
239
- optionIdKey: "optionId", // option id, inside the options array
240
- optionValueKey: "optionLabel", // option label, inside the options array
241
- responseValueKey: "submittedValue", // submitted free value
242
- responseOptionIdKey: "submittedOptionId", // submitted option reference
243
- };
244
- ```
245
-
246
- ```bash
247
- pnpm run recon:generate -- --site-id my-site --form-schema ./src/recon/my-form-schema.ts
248
- ```
249
-
250
- - The specifier follows the same rule as `--vocabulary`: a leading `.` or `/` is a filesystem path; anything else resolves from your `node_modules`. The module may export `formSchema` or a default.
251
- - **`--form-schema none`** for a site with no ATS form definition (a search API, a real-estate listing site) — same as omitting it. "none" is the explicit form.
252
- - Wire keys anchor `"key":"uuid"` markers, so they may be any non-empty string without a quote or backslash — the JS-identifier rule that governs vocabulary field names does **not** apply here.
253
- - `fieldNameKeys` models two roles: the first key is a machine code (PascalCased directly), the second is a human label (run through the section-heading heuristic). Supply one key for a label-only ATS, or two for one that exposes both. Additional keys are unused.
254
- - Omit `--form-schema` (or pass `none`) and ATS form-key recovery does not run — the engine hardcodes no vendor's wire format. A site whose ATS exposes a form definition must supply one to recover its option fields. See issue #57.
255
-
256
- #### Filtering analytics noise
257
-
258
- | Env var | Default | Description |
259
- | --- | --- | --- |
260
- | `RECON_TELEMETRY_URL_PATTERNS` | *(empty)* | Comma-separated extra URL fragments to treat as analytics noise, on top of the built-in list. Put your site's trackers here. |
261
-
262
- Screening-question answers with coded options (dropdowns) are recovered generically: when a flow step selects an answer by its label and the site's response carries the option list (JSON-Schema `enum`/`enumNames`, or `{label,value}` option objects), the generator maps the label to the wire code and emits a `z.enum` payload field, so the caller supplies the human-readable answer.
263
-
264
- Optionally generate the human-readable findings doc alongside:
265
-
266
- ```bash
267
- pnpm run recon:summarize -- --site-id my-site
268
- ```
269
-
270
- Writes `docs/my-site-recon.md` with: endpoints found, replay status, rate-limit ceiling, header frequency table, and hazards (Akamai, Cloudflare). Without `--site-id`, the default output path is `docs/target-recon.md`. Accepts `--run-dir <path>` the same way `recon:generate` does.
271
-
272
- ### Phase 5 — Register the plugin
273
-
274
- See **[Plugin Authoring Guide → Register the plugin](#register-the-plugin)** below for plugin registration options.
275
-
276
- ### Phase 6 — Wire up drift detection
277
-
278
- See **[Plugin Authoring Guide → Wire up the nightly smoke test](#wire-up-the-nightly-smoke-test)** below for the CI step that runs the smoke test nightly.
279
-
280
- See [docs/playbook.md](./docs/playbook.md#phase-6--drift-detection) for the full detection ladder and maintenance loop.
281
-
282
- ### The whole loop, in one picture
283
-
284
- ![Barnacle end-to-end workflow: Setup (recon) feeds a dashed Deploys edge into Runtime (dispatch + cache + hot path), Heal catches errors and runs nightly drift detection, and a solid orange arrow sweeps back from smoke-test.ts into Phase 1 to close the self-healing loop.](docs/images/workflow.svg)
285
-
286
- The dashed `deploys` edge is the human-in-the-loop step (the contract PR merges and ships to Runtime). The solid orange edge from `smoke-test.ts` back into Phase 1 is the self-healing loop: when the contract drifts, recon reruns unattended (~20–40 min) and the next PR is a diff of captures, not a hand-rewrite. See [docs/architecture.md](./docs/architecture.md) for the design rationale behind each lane.
287
-
288
- ## Plugin Authoring Guide
289
-
290
- A site plugin is a single TypeScript module that satisfies `SitePlugin<TInput, TOutput>`
291
- from `src/site-plugin.ts`. Core registers built-in plugins via `BUILTIN_SITE_PLUGINS` in
292
- `src/plugins/discover.ts`; out-of-tree plugins are loaded at startup via `BARNACLE_PLUGINS`.
293
-
294
- ### The SitePlugin interface
295
-
296
- ```ts
297
- interface SitePlugin<TPayload, TResult> {
298
- meta: SitePluginMeta;
299
- // Optional direct-HTTP hot path — no browser, no LLM tokens, millisecond latency.
300
- // Core tries this first; falls back to execute() on HttpSchemaError / HttpBotChallengeError / HttpServerError.
301
- executeHttp?: (
302
- payload: TPayload,
303
- context: SitePluginContext
304
- ) => Promise<SitePluginResult<TResult>>;
305
- // Browser fallback — Stagehand + Steel session, acquired from the pool by core.
306
- execute(
307
- payload: TPayload,
308
- session: BrowserSession,
309
- context: SitePluginContext
310
- ): Promise<SitePluginResult<TResult>>;
311
- // Async work is supported. Note: NOT called on CaptchaError or EmptyResultsError —
312
- // p-retry skips onFailedAttempt for AbortError, so those abort paths bypass this hook.
313
- onRetry?: (error: ScraperError, attempt: number) => void | Promise<void>;
314
- }
315
- ```
316
-
317
- ### SitePluginMeta — required fields
318
-
319
- | Field | Type | Purpose |
320
- |---|---|---|
321
- | `siteId` | `string` | Stable key used for routing (`/v1/<siteId>/run`) and audit rows |
322
- | `displayName?` | `string` | Human-readable label for logs and Swagger docs. `recon:generate` does not derive this — plugin authors set it explicitly |
323
- | `bodySchema` | `ZodTypeAny` | Request body schema — core validates before calling `execute()` |
324
- | `responseSchema` | `ZodTypeAny` | Success response schema — drives Swagger output shape |
325
- | `routeOverride?` | `string` | Override the full route path (legacy compatibility only) |
326
- | `defaultBaseUrl?` | `string` | Fallback base URL when `config.scraper.siteBaseUrls[siteId]` is absent |
327
- | `taskTimeoutMs?` | `number` | Override the pool's 60-minute per-task hang ceiling for this plugin only — set when the site's normal latency is well below the default and a faster failure is preferable |
328
- | `maxAttempts?` | `number` | Override the retry policy's default of 3 attempts (including the first try). Without this, the per-run ceiling is `3 × taskTimeoutMs`; set to `1` so `taskTimeoutMs` is the real per-run cap |
329
- | `apiVersion?` | `string` | Semver range targeting a plugin API version (e.g. `"^1.0.0"`); core disables the plugin on a major-version mismatch. Absent means "accept any version." |
330
- | `extraRoutes?` | `readonly SitePluginExtraRoute[]` | Extra non-run routes (OTP trigger, resume, etc.) that core registers as authenticated Fastify routes at startup. See `SitePluginExtraRoute` in `src/site-plugin.ts`. |
331
- | `onShutdown?` | `() => Promise<void>` | Optional cleanup for background work the plugin launched fire-and-forget, awaited during graceful shutdown so in-flight work is not abandoned and sessions are not leaked. Parallels the engine's own drain functions. Bounded by a per-plugin timeout, so a hanging drain cannot stall shutdown. Module plugins only — config-only `*.plugin.json` manifests are pure JSON and cannot declare a function. |
332
-
333
- ### Full plugin skeleton (hot path + browser fallback)
334
-
335
- `pnpm run recon:generate` produces this structure automatically. Use `createRateLimitedJsonClient()` for REST endpoints that send Chromium client-hint headers (the common case) and `createGraphqlClient()` for GraphQL endpoints — `recon:generate` selects the right one based on what it captured. The skeleton below illustrates the REST hot-path pattern; for GraphQL sites, `recon-generate` uses `createGraphqlClient` instead. A GraphQL target with a single captured query inlines it as a constant; a read-only GraphQL flow with several 2xx `query` candidates and no `submitStep` instead ranks them by response size, `payloadField` correlation, capture phase, and recurrence to pick the primary data operation; a GraphQL target whose captures form a multi-operation mutation sequence (a submission flow) gets the same state-threaded, multi-step `executeHttp` REST submission flows get.
336
-
337
- ```ts
338
- // src/sites/my-site/contract.ts
339
- import { z } from "zod/v4";
340
- import { createRateLimitedJsonClient } from "@/scraper/rate-limited-json-client";
341
- import type { BrowserSession } from "@/scraper/session";
342
- import type { SitePlugin, SitePluginContext, SitePluginResult } from "@/site-plugin";
343
- import { runMySiteBrowserFlow } from "@/sites/my-site/flows/browser-flow";
344
-
345
- // Generated: Zod schemas inferred from captured JSON — tighten z.unknown() fields as needed.
346
- export const MySiteResponseSchema = z.object({ data: z.object({ items: z.array(z.object({ id: z.string() })) }) });
347
- const MySitePayloadSchema = z.object({ query: z.string().min(1) });
348
-
349
- type MySitePayload = z.infer<typeof MySitePayloadSchema>;
350
- type MySiteResponse = z.infer<typeof MySiteResponseSchema>;
351
-
352
- // Generated: rate-limit ceiling (5 rps) + Chromium hints + site-specific headers from recon.
353
- // Use createHttpClient() directly only when you need manual Bottleneck or header control.
354
- const httpClient = createRateLimitedJsonClient({
355
- minTimeMs: 200,
356
- userAgent: "Mozilla/5.0 ...",
357
- secChUa: '"Chromium";v="..."',
358
- platform: "Linux",
359
- extraHeaders: {
360
- "Content-Type": "application/json",
361
- Accept: "application/json, */*",
362
- },
363
- schema: MySiteResponseSchema,
364
- });
365
-
366
- export const mySitePlugin: SitePlugin<MySitePayload, MySiteResponse> = {
367
- meta: {
368
- siteId: "my-site",
369
- displayName: "My Site",
370
- bodySchema: MySitePayloadSchema,
371
- responseSchema: MySiteResponseSchema,
372
- defaultBaseUrl: "https://my-site.com",
373
- },
374
- // Hot path: direct HTTP — no browser, no LLM tokens.
375
- async executeHttp(payload: MySitePayload, context: SitePluginContext): Promise<SitePluginResult<MySiteResponse>> {
376
- const data = await httpClient(`${context.baseUrl}/api/search`, {
377
- method: "POST",
378
- body: JSON.stringify({ query: payload.query }),
379
- });
380
- return { data };
381
- },
382
- // Browser fallback: Stagehand + Steel — invoked automatically when hot path fails.
383
- async execute(payload: MySitePayload, session: BrowserSession, context: SitePluginContext): Promise<SitePluginResult<MySiteResponse>> {
384
- const raw = await runMySiteBrowserFlow(session.stagehand, context.baseUrl, payload.query);
385
- return { data: raw };
386
- },
387
- };
388
- ```
389
-
390
- ### The auditPayload hook
391
-
392
- `SitePluginResult` accepts an optional `auditPayload` field alongside `data`:
393
-
394
- ```ts
395
- return {
396
- data: responseData,
397
- auditPayload: { query: payload.query, resultCount: responseData.items.length },
398
- };
399
- ```
400
-
401
- When `auditPayload` is present, core writes it — not `data` — to the submission-envelope telemetry record. Use this to strip PII or large blobs from the audit trail while keeping the full response in the API reply. When absent, `data` is written as-is.
402
-
403
- ### Reconciliation join keys (`extractJoinKeys`)
404
-
405
- Core has no opinion on what a reconciliation join key is named or how it's
406
- shaped — that's site-specific vocabulary (an attribution vendor's click ID,
407
- a job-reference composition rule, whatever the site needs). A plugin that
408
- needs its submission and beacon-fire telemetry to be joinable back to its own
409
- attribution provider declares an optional `extractJoinKeys` hook on its
410
- `SitePlugin`:
411
-
412
- ```ts
413
- export const myPlugin: SitePlugin<MyPayload, MyResponse> = {
414
- extractJoinKeys: (payload) =>
415
- payload.someVendorClickId ? { vendorClickId: payload.someVendorClickId } : null,
416
- // ...
417
- };
418
- ```
419
-
420
- `dispatch()` (`src/plugins/loader.ts`) calls this once per submission,
421
- resolving `extractJoinKeys(payload)` from the inbound payload alone — core
422
- never inspects the result's contents. A plugin with no reconciliation needs
423
- simply omits `extractJoinKeys`.
424
-
425
- #### Mid-run attach point (`context.telemetry.addJoinKeys`)
426
-
427
- `extractJoinKeys` only ever sees the payload a plugin received up front, so
428
- it has no way to attach a field the plugin only discovers *during* the run
429
- — a token minted mid-flow, a value read from the page after navigation, a
430
- value observed on a response. For that, call the mid-run attach point,
431
- `context.telemetry.addJoinKeys()`, from anywhere inside `execute()` or
432
- `executeHttp()`:
433
-
434
- ```ts
435
- async execute(payload: MyPayload, session, context: SitePluginContext) {
436
- const mintedToken = await readTokenFromPage(session);
437
- context.telemetry.addJoinKeys({ mintedToken });
438
- // ...
439
- },
440
- ```
441
-
442
- `context.telemetry` is a per-dispatch `RunTelemetry` accumulator
443
- (`src/lib/telemetry/run-telemetry.ts`), constructed fresh for every
444
- dispatch by `buildPluginContext` (`src/plugins/loader.ts`) alongside
445
- `recordBeaconOutcome` below. Successive `addJoinKeys()` calls within the
446
- same run merge, later calls winning on key collision. Once the plugin call
447
- resolves — on both the success and error paths — `dispatch()` snapshots the
448
- accumulator and merges it over the earlier `extractJoinKeys(payload)`
449
- result, run-discovered keys winning on collision, before stamping the
450
- combined bag onto the submission envelope's and beacon-fire record's
451
- `joinKeys` field. `joinKeys` stays `null` only when neither source ever
452
- produced anything.
453
-
454
- **A config-only `*.plugin.json` manifest can reach
455
- `context.telemetry.addJoinKeys()` only through the same `spec.httpModule`
456
- escape hatch documented below for `context.recordBeaconOutcome`** —
457
- `executeHttp(payload, context)` receives the same `SitePluginContext`, so an
458
- `httpModule` can call it exactly like `execute()` does above; the
459
- manifest's declarative browser flow cannot, since `runHealingFlow` is
460
- data-driven with no imperative call site for either seam to live in.
461
-
462
- **Declaring `extractJoinKeys` also opts the plugin out of core's automatic
463
- `TrackingUrl` fire.** If the site returns a post-submission click-tracking
464
- URL, declare it on the plugin's `bodySchema` by composing `JobTrackingSchema`
465
- (`src/lib/job-tracking.ts`) — `MySitePayloadSchema.extend(JobTrackingSchema.shape)`.
466
- When a plugin has no `extractJoinKeys`, `dispatch()` fires that `TrackingUrl`
467
- itself via `fireTrackingClick`, site-agnostically, after a successful submit.
468
- When a plugin *does* declare `extractJoinKeys`, core assumes the plugin fires
469
- its own post-submit tracking navigation (e.g. because the click and apply
470
- navs must share one browser session for a vendor's device-cookie
471
- attribution to work) and skips its own fire — firing both would open two
472
- independent sessions against the same URL.
473
-
474
- By default a self-managing plugin's beacon-fire telemetry is stuck at
475
- `beaconStatus: "skipped"`, since core has no visibility into a navigation the
476
- plugin drives itself. To report the real outcome, call
477
- `context.recordBeaconOutcome` — passed on `SitePluginContext` alongside
478
- `baseUrl`/`logger`/`requestId`, bound to this run — from `execute()`,
479
- `executeHttp()`, or an extra-route handler:
480
-
481
- ```ts
482
- import type { SitePlugin, SitePluginContext } from "@enricai/barnacle/site-plugin";
483
-
484
- export const myPlugin: SitePlugin<MyPayload, MyResponse> = {
485
- extractJoinKeys: (payload) =>
486
- payload.someVendorClickId ? { vendorClickId: payload.someVendorClickId } : null,
487
- async execute(payload, session, context: SitePluginContext) {
488
- const t0 = Date.now();
489
- const fired = await runMySiteBeaconNav(session, payload.TrackingUrl);
490
- await context.recordBeaconOutcome({
491
- beaconStatus: fired ? "fired" : "failed",
492
- joinKeys: { vendorClickId: payload.someVendorClickId },
493
- trackingUrl: payload.TrackingUrl,
494
- durationMs: Date.now() - t0,
495
- });
496
- // ...
497
- },
498
- };
499
- ```
500
-
501
- Core binds the run's `requestId` and the plugin's own `siteId` for you, so
502
- `recordBeaconOutcome`'s input carries only `beaconStatus` (`"fired"` |
503
- `"failed"` — `"skipped"` stays an engine-owned outcome), the opaque `joinKeys`
504
- bag (same shape returned from `extractJoinKeys`), and optional `trackingUrl`/
505
- `durationMs`. It never throws — a telemetry-sink hiccup cannot fail the
506
- request. A `fired`/`failed` line recorded this way outranks the automatic
507
- `skipped` line for the same `requestId` when the two are folded together
508
- (see [Telemetry & LLM judging](docs/telemetry-and-judging.md)). A plugin that
509
- never calls it keeps today's unchanged `skipped` default. Import
510
- `BeaconOutcomeInput` from `@enricai/barnacle/site-plugin` if you want to type
511
- the input object explicitly — that's the published subpath an out-of-tree
512
- plugin resolves against its own `node_modules`; in-tree code under `src/`
513
- uses the `@/site-plugin` alias instead.
514
-
515
- **A config-only `*.plugin.json` manifest can reach `context.recordBeaconOutcome`
516
- only through the `spec.httpModule` escape hatch** — `executeHttp(payload,
517
- context)` receives the same `SitePluginContext` a module plugin's does, so an
518
- `httpModule` can call it exactly like `execute()` does above. The manifest's
519
- declarative browser flow cannot: `runHealingFlow` is data-driven, with no
520
- imperative call site for a call like this to live in. One consequence to know
521
- before adopting it: `buildConfigPlugin` never synthesizes `extractJoinKeys`,
522
- so a config-only plugin is never `managesOwnTracking` — when the response
523
- carries a `TrackingUrl`, core still fires it itself via `fireTrackingClick`,
524
- and a manifest-recorded `fired`/`failed` line for that `requestId` ranks
525
- equal to core's own line under `beaconRank()`, so the fold resolves by write
526
- order (last line wins) rather than the manifest's line automatically
527
- outranking core's. Only when no `TrackingUrl` is present — so core's own
528
- write is the `skipped` default — does the manifest's recorded line
529
- deterministically outrank it.
530
-
531
- ### Static fixtures
532
-
533
- If Phase 3b (auxiliary fixture detection) found static JSON endpoints (markets, currencies, labels), `recon:generate` copies them to `src/sites/<id>/fixtures/`. Load them at module init via `loadFixture()` — zero per-request overhead, fails fast on deploy if the fixture is missing or stale:
534
-
535
- ```ts
536
- import { z } from "zod/v4";
537
- import { loadFixture } from "@/scraper/fixtures";
538
-
539
- const MarketsSchema = z.array(z.object({ id: z.string(), name: z.string() }));
540
-
541
- // Loaded synchronously at module init. Throws at startup if file is missing
542
- // or shape drifted — surface fixture breakage on deploy, not on the first request.
543
- const markets = loadFixture("my-site", "markets.json", MarketsSchema);
544
- ```
545
-
546
- See [docs/playbook.md — Phase 3b](./docs/playbook.md#3b--auxiliary-fixture-detection) for how fixtures are detected and when to use them.
547
-
548
- ### Register the plugin
549
-
550
- **Out-of-tree (recommended for operator-owned plugins):** point `BARNACLE_PLUGINS` at the compiled plugin module — no core edits required:
551
-
552
- ```bash
553
- BARNACLE_PLUGINS=./plugins/my-site/dist/index.js pnpm start
554
- ```
555
-
556
- Barnacle validates the export at startup and registers `POST /v1/my-site/run` automatically. See the [Out-of-tree plugins](#out-of-tree-plugins) env var table for `BARNACLE_PLUGINS_STRICT` and `BARNACLE_PLUGINS_DIR`. A copyable, runnable template lives at [`examples/plugins/hello-site/`](./examples/plugins/hello-site/).
557
-
558
- **Config-only (no TypeScript, no compile step):** a browser-flow plugin can be a single JSON manifest. Point `BARNACLE_PLUGINS` at a `*.plugin.json` file, or drop manifests into a directory named by `BARNACLE_PLUGINS_CONFIG_DIR`:
559
-
560
- ```bash
561
- BARNACLE_PLUGINS=./plugins/acme-jobs.plugin.json pnpm start
562
- # or, for directory-drop discovery of every *.plugin.json:
563
- BARNACLE_PLUGINS_CONFIG_DIR=./plugins pnpm start
564
- ```
565
-
566
- The manifest wears the Kubernetes-style `apiVersion` / `kind` / `metadata` / `spec` envelope, declares its request/response/extract shapes as **JSON Schema**, and lists the browser flow as data (the same self-heal step format the recon toolchain authors). Core reads it at startup and registers `POST /v1/acme-jobs/run` — no per-site code. A site needing the direct-HTTP hot path can reference a compiled `executeHttp` module via `spec.httpModule`. A copyable manifest lives at [`examples/plugins/acme-jobs.plugin.json`](./examples/plugins/acme-jobs.plugin.json).
567
-
568
- The JSON Schema converter accepts a deliberately small subset — `object`, `string`, `number`, `integer`, `boolean`, `array` (with `items`), string `enum`, and `required` — and rejects anything else (e.g. `pattern`, `minLength`, `$ref`, `format` constraints) at load time. Flow steps interpolate request values with `{{ .request.FieldName }}`; a reference to a field the request schema does not declare fails loudly, while an optional declared field the caller omits splices as an empty string.
569
-
570
- **In-tree (bundled built-ins only):** push to `BUILTIN_SITE_PLUGINS` in `src/plugins/discover.ts`:
571
-
572
- ```ts
573
- import { mySitePlugin } from "@/sites/my-site";
574
- import { BUILTIN_SITE_PLUGINS } from "@/plugins/discover";
575
-
576
- BUILTIN_SITE_PLUGINS.push(mySitePlugin as SitePlugin<unknown, unknown>);
577
- ```
578
-
579
- Core registers `POST /v1/my-site/run` automatically at startup.
580
-
581
- ### Wire up the nightly smoke test
582
-
583
- Add a step to `.github/workflows/smoke.yml`:
584
-
585
- ```yaml
586
- - name: Run smoke test — my-site
587
- if: steps.check-secrets.outputs.skip == 'false'
588
- run: |
589
- pnpm run smoke -- \
590
- --site my-site \
591
- --payload '{"query":"test"}' \
592
- --host "$SMOKE_HOST" \
593
- --fallback \
594
- --response-schema src/sites/my-site/contract.ts
595
- env:
596
- API_KEY: ${{ secrets.SMOKE_API_KEY }}
597
- SMOKE_HOST: ${{ secrets.SMOKE_HOST }}
598
- NODE_ENV: production
599
- ```
600
-
601
- `--response-schema` points to a module whose **default export is a Zod schema**. The smoke test validates the full response body against it — not just the envelope shape — so any schema drift on the data payload fails the pipeline immediately.
602
-
603
- `--fallback` additionally runs a second request via the Stagehand browser path. This catches Stagehand cache staleness: if the page DOM changed and the cached selector now points at the wrong element, the hot-path test passes but the fallback test fails — alerting you before the fallback is invoked in production.
604
-
605
- ### Maintenance loop
606
-
607
- When the smoke test fails: re-run `pnpm run recon:browser` → diff `<run-dir>/graphql/*<operationName>*.json` against `src/sites/<id>/contract.ts` → update query / headers / Zod schema → ship. See [docs/playbook.md](./docs/playbook.md#phase-6--drift-detection) for the full maintenance loop and change severity table.
608
-
609
- ## Runtime internals
610
-
611
- ### Hot-path fallback triggers
612
-
613
- `dispatch()` (`src/plugins/loader.ts`) tries `executeHttp()` first. Which errors trigger the browser fallback and which don't:
614
-
615
- | Hot-path error | Status | Triggers browser fallback? | Reason |
616
- |---------------|--------|--------------------------|--------|
617
- | `HttpSchemaError` | Any | **Yes** | Response shape drifted; browser may still work |
618
- | `HttpBotChallengeError` | 401 / 403 | **Yes** | Residential proxy IP may get through |
619
- | `HttpServerError` | 5xx | **Yes** | Server-side outage; recovery strategy is the same |
620
- | `HttpRateLimitError` | 429 | **No** | A 429 means the configured rps ceiling is too high. Routing to the browser path would just hit the same ceiling and waste a Steel session. The right response is to lower the Bottleneck `minTime` in `contract.ts` and re-deploy. |
621
- | `HttpUrlLockedError` | 429 | **No** | A plugin's `classifyResponseBody` detected a terminal resource-lock sentinel — the URL is locked at the target's end. Neither a retry nor a browser session can succeed; the caller must back off and surface a "retry later" state. |
622
- | `UnknownScraperError` | Any | **No** | Transient network failure or unclassified non-JSON response. `createHttpClient` retries up to 2 times internally; if all attempts fail, the error propagates as `ScrapeFailureError`. |
623
-
624
- ### Cache deduplication
625
-
626
- `getCachedResponse()` checks the LRU cache first. On a miss, `getOrCreateInFlight()` registers a promise in an `inFlight` map before awaiting it — meaning concurrent identical requests all await the same origin call rather than fanning out. First caller wins; all others coalesce onto its promise.
627
-
628
- Cache key: `<endpoint>:<sha256(canonical payload)[:32]>` — the endpoint is a literal prefix; the hash covers only the canonical payload. Object key order and primitive array element order are normalized so `{a:1,b:2}` and `{b:2,a:1}` hit the same entry. Default TTL: 15 minutes (`CACHE_TTL_MS`). Max entries: 1000 (`CACHE_MAX_ENTRIES`). Only successful responses are cached; errors propagate and never poison the cache.
629
-
630
- ### Session pool and timeouts
631
-
632
- `runWithSession()` (`src/scraper/pool.ts`) queues tasks through a `p-queue` bounded by `SESSION_POOL_SIZE` (default: 3). Sessions are created on demand — not pre-warmed — so Steel billing stays proportional to actual traffic.
633
-
634
- **Per-task hang ceiling:** each queued task races against `TASK_TIMEOUT_MS` (`src/scraper/pool.ts`, **60 minutes** by default). A hung `execute()` — frozen CDP connection, infinite network wait — converts to `SessionTimeoutError`, which the retry policy handles by tearing down the broken session and creating a fresh one. The default is sized for long browser flows; shorten per-plugin via `SitePluginMeta.taskTimeoutMs`. This is a hang-recovery floor, not a p99 latency budget.
635
-
636
- **Retry policy:** `withScraperRetry` (`src/scraper/retry.ts`) uses p-retry with `factor: 2`, `minTimeout: 500ms`, `maxTimeout: 5000ms`, `randomize: true`, and default `maxAttempts: 3`. `EmptyResultsError`, `CaptchaError`, and `StepVerificationError` short-circuit retries (abort after the first attempt — a deterministic verification failure won't resolve by re-running the whole flow); `SessionTimeoutError` and `CdpTransportClosedError` both trigger a session restart before every retry attempt, not just the first.
637
-
638
- `src/scripts/recon-browser.ts`'s `main()` applies the same policy at the whole-flow level: it wraps a single-attempt `runFlowAttempt()` (session creation → step loop → replan write-back) in `withScraperRetry`, bounded by `config.scraper.maxTransportRetries` (default 3, `RECON_MAX_TRANSPORT_RETRIES`). Only a `CdpTransportClosedError` — a CDP transport teardown detected mid-flow while traffic is still active — retries on a brand-new session; every other failure mode (`isFlowTruncated`, `StepVerificationError`, etc.) still exits/rejects after exactly one attempt, exactly as before this retry loop was added.
639
-
640
- **Graceful shutdown:** `drainPool()` is called during graceful shutdown — `SIGTERM`/`SIGINT` triggers `app.close()`, which fires Fastify's `onClose` hook, which calls `drainPool()`. It pauses new intake, waits up to 20 seconds for in-flight tasks to close their Steel sessions, then resolves. Without this, process exit leaves live sessions billing until Steel's own timeout.
641
-
642
- ### Viewport rotation
643
-
644
- `createBrowserSession()` (`src/scraper/session.ts`) picks a random desktop viewport per session from: `1280×720`, `1366×768`, `1440×900`, `1920×1080`. A fixed pixel size is an easy bot-detection fingerprint; rotating it makes sessions harder to cluster.
645
-
646
- ### LLM routing: Anthropic vs. AWS Bedrock
647
-
648
- By default, Stagehand calls the Anthropic API directly (`ANTHROPIC_API_KEY`). Set `USE_BEDROCK=true` to route through AWS Bedrock instead:
649
-
650
- ```bash
651
- USE_BEDROCK=true
652
- AWS_REGION=us-east-1
653
- AWS_ACCESS_KEY_ID=...
654
- AWS_SECRET_ACCESS_KEY=...
655
- BEDROCK_MODEL=us.anthropic.claude-sonnet-4-6[1m] # default
656
- ```
657
-
658
- The `[1m]` suffix selects the 1-million-token context variant on Bedrock. Both paths run Stagehand with `serverCache: true` (server-side action cache to skip LLM inference on replay) and `selfHeal: false` (recon-browser owns its own verify-and-retry cascade; see `src/scraper/session.ts` for the rationale).
659
-
660
- When using Anthropic directly (not Bedrock), the model is controlled by `STAGEHAND_MODEL` (default: `anthropic/claude-sonnet-4-6`).
661
-
662
- ## Observability
663
-
664
- `GET /readyz` returns readiness status plus per-site drift-detection metrics exposed by `src/scraper/metrics.ts`:
665
-
666
- ```json
667
- {
668
- "status": "ready",
669
- "checks": {
670
- "database": { "ok": true },
671
- "scraperCredentials": { "ok": true },
672
- "scraperPool": { "ok": true, "detail": "depth=0" }
673
- },
674
- "stats": {
675
- "scraperPool": { "size": 0, "pending": 0, "concurrency": 3 },
676
- "cache": { "size": 12, "max": 1000, "inFlight": 0 },
677
- "metrics": {
678
- "my-site": {
679
- "hotPathSuccess": 4821,
680
- "fallbackActivations": 3,
681
- "rateLimitRejections": 0,
682
- "p95LatencyMs": 187
683
- }
684
- }
685
- },
686
- "telemetry": {
687
- "currentRunFile": "/path/to/.barnacle/events/run-123.ndjson",
688
- "currentRunFileSizeBytes": 4096,
689
- "orphansRecovered": 0
690
- },
691
- "heal": {
692
- "my-site": { "verdict": "SUCCESS", "bestPassRate": 0.95, "reportPath": "heal-out/my-site/healing-my-site.md" }
693
- }
694
- }
695
- ```
696
-
697
- **What rising `fallbackActivations` means:** the hot path is failing and the browser fallback is absorbing traffic. Cost and latency rise while error rate stays flat — users don't notice yet, but you will on your bill. This is your signal to re-run recon.
698
-
699
- **`p95LatencyMs`** is reservoir-sampled (Vitter's Algorithm R, capped at 1000 samples) over actual origin round-trips. Cache hits are excluded — they're memory reads and must not bias the origin latency signal.
700
-
701
- See [docs/playbook.md](./docs/playbook.md#6b--metrics-signals-the-detection-ladder) for the full detection ladder.
702
-
703
- ### NDJSON telemetry files
704
-
705
- Barnacle writes three append-only NDJSON files alongside its metrics:
706
-
707
- | File | Default path | Purpose |
708
- |------|-------------|---------|
709
- | LLM call samples | `.barnacle/calls.ndjson` | One line per LLM/Stagehand call; feed to the `judge:llm` and `slm-self-heal` skills |
710
- | Run event stream | `.barnacle/events/<runId>.ndjson` | Per-run event stream written by the event-stream subsystem; path surfaced in `/readyz` `telemetry.currentRunFile` |
711
- | Submission reconciliation records | `.barnacle/submissions.ndjson` | One line per dispatch submit outcome or beacon-fire event; the durable, queryable join-key record — see [Submission record schema](#submission-record-schema) below and `GET /v1/submissions` under [Endpoints](#endpoints) |
712
-
713
- #### LLM call sample schema
714
-
715
- Every line in `.barnacle/calls.ndjson` is a JSON object with these fields (source: `src/api/schemas/telemetry.ts`):
716
-
717
- | Field | Type | Description |
718
- |-------|------|-------------|
719
- | `callId` | `string` | UUID generated per call |
720
- | `callType` | `string` | Which LLM call site produced this sample — see table below |
721
- | `model` | `string` | Model identifier string passed to the SDK |
722
- | `systemPrompt` | `string \| null` | System-prompt text, or `null` when absent |
723
- | `userContent` | `string` | Full user-turn content |
724
- | `responseContent` | `string \| null` | Raw response text, or `null` on SDK error |
725
- | `parsedOk` | `boolean` | Whether the response was successfully parsed into the expected schema |
726
- | `inputTokens` | `number \| null` | Input token count from SDK usage metadata |
727
- | `outputTokens` | `number \| null` | Output token count from SDK usage metadata |
728
- | `latencyMs` | `number \| null` | Wall-clock latency of the SDK call in milliseconds |
729
- | `success` | `boolean` | Whether the call site considered the call successful end-to-end |
730
- | `ts` | `string` | ISO-8601 timestamp at write time |
731
-
732
- #### Call types
733
-
734
- `callType` is a stable string constant defined in `src/lib/telemetry/call-types.ts`:
735
-
736
- | `callType` | Source | When emitted |
737
- |------------|--------|--------------|
738
- | `recon-rephrase` | `src/scripts/recon-browser.ts` | Attempt-5 rephrase inside the recon-browser step-healing cascade — the ai-SDK model (Anthropic-direct or Bedrock-backed) is asked to reword the failing step |
739
- | `recon-replan` | `src/scripts/recon-browser.ts` | Global replan after a step terminally fails — Claude rewrites the remaining flow tail |
740
- | `recon-flow-patch` | `src/scripts/recon-heal.ts` | Patch proposal from the recon-flow-patch-generator during the `recon-heal` self-healing loop |
741
- | `llm-prompt-patch` | `src/scripts/llm-heal.ts` | Patch proposal from the llm-call-patch-generator during the `llm-heal` self-healing loop |
742
-
743
- #### Submission record schema
744
-
745
- Every `"submit"`-kind line in `.barnacle/submissions.ndjson` is a JSON object
746
- validated against `submissionEnvelopeSampleSchema`
747
- (`src/lib/telemetry/submission-capture.ts`, an alias of `submitRecordSchema`
748
- in `src/lib/telemetry/reconciliation-record.ts`):
749
-
750
- | Field | Type | Description |
751
- |-------|------|-------------|
752
- | `kind` | `"submit"` | Discriminates this record from a `"beacon"` conversion-event record sharing the same sink; defaults to `"submit"` so lines written before this field existed still parse. |
753
- | `siteId` | `string` | Which plugin handled the request — the cohort dimension for reconciliation. |
754
- | `requestId` | `string` | Fastify-issued correlation ID; joins a later `"beacon"` record to this one by matching `requestId`. |
755
- | `joinKeys` | `Record<string, unknown> \| null` | Opaque, plugin-owned reconciliation join keys: the plugin's `extractJoinKeys` hook resolved once from the inbound payload, merged with anything the plugin attached mid-run via `context.telemetry.addJoinKeys()` (run-discovered keys win on collision — see [Reconciliation join keys](#reconciliation-join-keys-extractjoinkeys) above); `null` when neither source produced anything. |
756
- | `inboundPayload` | `unknown` | The request body the caller posted, unredacted. |
757
- | `status` | `"submitted" \| "error"` | Submit outcome. |
758
- | `auditPayload` | `unknown` | The plugin's `SitePluginResult.auditPayload`, or `data` when absent; `null` on errors. |
759
- | `errorMessage` | `string \| null` | Failure message on errors; `null` on success. |
760
- | `durationMs` | `number` | Total dispatch wall time in milliseconds. |
761
- | `ts` | `string` | ISO-8601 timestamp at write time. |
762
- | `session` | `{ id, provider, ip, ipCapturedAt } \| null` | Identity and outbound IP of the Browserbase session that served this run; `null` on the direct-HTTP hot path where no session is ever acquired. See [Submission-envelope sink](docs/telemetry-and-judging.md#submission-envelope-sink) for capture details. |
763
-
764
- A `"beacon"`-kind record shares the same sink to record a later, independent
765
- beacon-fire outcome for the same `requestId`. Core writes one itself — either
766
- a `fired`/`failed` line once `fireTrackingClick` resolves, or a `skipped` line
767
- when there is no `TrackingUrl` to fire or the plugin declared
768
- `extractJoinKeys` — but a plugin managing its own tracking nav can also emit
769
- one directly via `context.recordBeaconOutcome` (see
770
- [Reconciliation join keys](#reconciliation-join-keys-extractjoinkeys) above).
771
- See [Submission-envelope sink](docs/telemetry-and-judging.md#submission-envelope-sink)
772
- for the full schema and the `GET /v1/submissions` read path.
773
-
774
- #### Tailing call samples with jq
775
-
776
- ```bash
777
- # Stream all LLM call samples as they arrive
778
- tail -f .barnacle/calls.ndjson | jq '.'
779
-
780
- # Filter to a specific call type
781
- tail -f .barnacle/calls.ndjson | jq 'select(.callType == "recon-rephrase")'
782
-
783
- # Show only failures
784
- tail -f .barnacle/calls.ndjson | jq 'select(.success == false) | {callId, callType, latencyMs}'
785
-
786
- # Token usage summary by call type
787
- jq -s 'group_by(.callType) | map({callType: .[0].callType, totalInputTokens: map(.inputTokens // 0) | add, totalOutputTokens: map(.outputTokens // 0) | add, n: length})' .barnacle/calls.ndjson
788
-
789
- # Tail the current run event stream (path from /readyz telemetry.currentRunFile)
790
- tail -f .barnacle/events/<runId>.ndjson | jq '.'
791
- ```
792
-
793
- ## Environment variables
794
-
795
- All variables are read once at process start. Required variables cause the
796
- process to exit on missing values; optional ones have safe defaults.
797
-
798
- ### Application
799
-
800
- | Variable | Default | Required | Purpose |
801
- |----------|---------|----------|---------|
802
- | `APP_NAME` | `barnacle` | No | Application name used in logs |
803
- | `NODE_ENV` | `development` | No | `development` / `production` / `test` |
804
- | `PORT` | `3000` | No | HTTP listen port |
805
- | `HOST` | `0.0.0.0` | No | HTTP listen address |
806
- | `LOG_LEVEL` | `info` | No | Pino log level (`debug`, `info`, `warn`, `error`) |
807
-
808
- ### Auth
809
-
810
- | Variable | Default | Required | Purpose |
811
- |----------|---------|----------|---------|
812
- | `API_KEYS_HASHED` | `""` | Yes (prod) | Comma-separated bcrypt hashes of plaintext bearer tokens. See [Generating an API key](#generating-an-api-key). |
813
- | `DEV_BYPASS_AUTH` | `false` | No | Skip auth entirely. Local dev only — **never set in production**. |
814
-
815
- ### Browser automation (Steel + Stagehand)
816
-
817
- | Variable | Default | Required | Purpose |
818
- |----------|---------|----------|---------|
819
- | `STEEL_API_KEY` | — | **Yes** | Steel account API key. Required for all browser automation. |
820
- | `ANTHROPIC_API_KEY` | — | Yes (if not using Bedrock) | Anthropic API key for Stagehand's LLM calls. |
821
- | `STAGEHAND_MODEL` | `anthropic/claude-sonnet-4-6` | No | Stagehand model. Use the `anthropic/` prefix — Stagehand 2.x's model map is stale and the prefix routes through AI-SDK's fallback path. |
822
- | `SCRAPER_PROXY_TYPE` | `residential` | No | `residential` (paid Steel tiers) or `none` (free tier — Steel rejects `useProxy=true` on hobby plans). |
823
- | `SCRAPER_SOLVE_CAPTCHA` | `true` | No | Enable Steel's built-in CAPTCHA solver. Requires a paid plan; set `false` on the free tier. |
824
- | `SESSION_POOL_SIZE` | `3` | No | Maximum concurrent Steel browser sessions. |
825
- | `SCRAPER_MIN_ACTION_DELAY_MS` | `500` | No | Minimum delay between scraper actions (ms). Jitter applied on top. |
826
- | `SCRAPER_MAX_ACTION_DELAY_MS` | `1500` | No | Maximum delay between scraper actions (ms). |
827
- | `STAGEHAND_API_TIMEOUT_MS` | `120000` | No | Anthropic SDK request timeout (ms). Raise on slow network paths to `api.anthropic.com`. |
828
- | `STAGEHAND_CONNECT_TIMEOUT_MS` | `120000` | No | TCP connect timeout for all outbound fetch calls (ms). Raised from the undici default of 10 s to match `STAGEHAND_API_TIMEOUT_MS`. |
829
- | `STEEL_SESSION_TIMEOUT_MS` | `3600000` | No | Steel session wall-clock timeout (ms). Default is 1 hour; lower on plans that enforce shorter maximum session durations. |
830
- | `FRAME_READY_TIMEOUT_MS` | `20000` | No | How long `resolveFrameTarget` polls for a child iframe to attach before falling back to the main frame (ms). Raise further for cross-origin OOPIFs that attach slowly under advancedStealth + proxied CDP. |
831
- | `FRAME_DOCUMENT_READY_TIMEOUT_MS` | `5000` | No | How long `waitForChildFrameReady` polls a resolved child frame's `document.readyState` before proceeding anyway (ms). Independent of `FRAME_READY_TIMEOUT_MS` — this wait settles in well under a second once attached. |
832
- | `FRAME_EVALUATE_TIMEOUT_MS` | `30000` | No | Watchdog budget for a single frame-scoped evaluate/candidate-probe call (ms), so a call against a racy frame fails the attempt instead of hanging indefinitely. |
833
- | `FRAME_PRESENCE_PROBE_FLOOR_MS` | `3000` | No | Per-probe watchdog floor for `probeAttachedFrameTarget`'s single non-polling presence check (ms) — a real budget a genuine CDP round-trip can land within, instead of the `timeoutMs: 0` zero-budget pattern that always loses that race. |
834
- | `SCRAPER_CAPTURE_SESSION_IP` | `true` | No | Master switch for the outbound-IP echo navigation; `false` yields `session: null` / `sessionIp: null` everywhere without touching the rest of the submit/beacon record. |
835
- | `SCRAPER_SESSION_IP_ECHO_URL` | `https://api.ipify.org?format=json` | No | The IP-echo endpoint the session's own short-lived tab navigates to. Operators can point this at a self-hosted echo endpoint. |
836
- | `SCRAPER_SESSION_IP_TIMEOUT_MS` | `10000` | No | Watchdog bound on the echo navigation; a page that never resolves is cut off and yields `null` rather than blocking the submission. |
837
-
838
- ### AWS Bedrock (alternative LLM provider)
839
-
840
- Set `USE_BEDROCK=true` to route Stagehand's LLM calls through AWS Bedrock
841
- instead of the Anthropic API. When enabled, `ANTHROPIC_API_KEY` is not needed.
842
- AWS credentials resolve in standard SDK order: explicit vars → ECS task role →
843
- EC2 instance profile → `~/.aws/credentials`.
844
-
845
- | Variable | Default | Required | Purpose |
846
- |----------|---------|----------|---------|
847
- | `USE_BEDROCK` | `false` | No | Master switch — routes LLM calls through Bedrock when `true`. |
848
- | `AWS_REGION` | `us-east-1` | No | AWS region for Bedrock calls. |
849
- | `AWS_ACCESS_KEY_ID` | — | No | Explicit AWS access key (leave blank for ambient IAM). |
850
- | `AWS_SECRET_ACCESS_KEY` | — | No | Explicit AWS secret key. |
851
- | `AWS_SESSION_TOKEN` | — | No | Required only for temporary STS credentials. |
852
- | `BEDROCK_MODEL` | `us.anthropic.claude-sonnet-4-6[1m]` | No | Bedrock cross-region inference profile ID. The `us.` prefix enables automatic cross-region routing; the `[1m]` suffix selects the 1M-token context variant. |
853
-
854
- ### Cache
855
-
856
- | Variable | Default | Purpose |
857
- |----------|---------|---------|
858
- | `CACHE_TTL_MS` | `900000` (15 min) | LRU response cache TTL. Cached responses skip the target API entirely. |
859
- | `CACHE_MAX_ENTRIES` | `1000` | Maximum entries in the LRU cache. |
860
-
861
- ### Rate limiting (inbound)
862
-
863
- These limit traffic *to* Barnacle's own API. See per-plugin Bottleneck config
864
- in each `contract.ts` for outbound rate limits to target sites.
865
-
866
- | Variable | Default | Purpose |
867
- |----------|---------|---------|
868
- | `RATE_LIMIT_MAX` | `120` | Max requests per window per API key (or IP for unauthenticated traffic). |
869
- | `RATE_LIMIT_WINDOW_MS` | `60000` (1 min) | Rate limit window duration. |
870
- | `TRUST_PROXY` | `true` | Trust `X-Forwarded-For` when behind a reverse proxy. Set `false` for bare-metal deploys to prevent spoofing. |
871
-
872
- ### Readiness / observability
873
-
874
- | Variable | Default | Purpose |
875
- |----------|---------|---------|
876
- | `READINESS_QUEUE_THRESHOLD` | `20` | `/readyz` returns 503 when scraper queue depth exceeds this. Lets orchestrators shed load before the pool is saturated. |
877
- | `ENABLE_DOCS` | `false` | Serve Swagger UI at `/docs`. Disable in production. |
878
-
879
- ### Datadog (opt-in)
880
-
881
- APM tracing and DogStatsD metrics are **opt-in**: `dd-trace` and `hot-shots` are
882
- optional peer dependencies, so a plain `npm i @enricai/barnacle` installs neither
883
- and Barnacle runs without them. Enable either half independently — install the
884
- package and flip its flag. If a flag is on but the package is missing, Barnacle
885
- warns and carries on with that feature disabled; it never fails to boot.
886
-
887
- ```bash
888
- # APM tracing
889
- pnpm add dd-trace
890
- DD_TRACE_ENABLED=true node --import dd-trace/initialize dist/server.js
891
-
892
- # DogStatsD metrics
893
- pnpm add hot-shots
894
- DD_METRICS_ENABLED=true node dist/server.js
895
- ```
896
-
897
- Tracing needs `--import dd-trace/initialize` for full auto-instrumentation:
898
- Datadog requires the tracer to load before any other module so it can patch
899
- http/net/dns. Metrics have no such constraint.
900
-
901
- | Variable | Default | Purpose |
902
- |----------|---------|---------|
903
- | `DD_TRACE_ENABLED` | `false` | Enable APM tracing. Requires the `dd-trace` peer dependency. |
904
- | `DD_METRICS_ENABLED` | `false` | Enable DogStatsD metrics. Requires the `hot-shots` peer dependency. Independent of `DD_TRACE_ENABLED`. |
905
- | `DD_AGENT_HOST` | `localhost` | Datadog agent hostname (the sidecar, in ECS Fargate). |
906
- | `DD_DOGSTATSD_PORT` | `8125` | DogStatsD UDP port on the agent host. |
907
- | `DD_SERVICE` | `barnacle` | Service name tagged on spans and metrics. |
908
- | `DD_ENV` | `NODE_ENV` | Deployment environment tag. |
909
- | `DD_VERSION` | `0.1.0` | Application version tag — git SHA or package version. |
910
-
911
- ### Telemetry
912
-
913
- | Variable | Default | Purpose |
914
- |----------|---------|---------|
915
- | `TELEMETRY_ENABLED` | `true` | Master switch — set `false` to disable all NDJSON telemetry writes. |
916
- | `TELEMETRY_EVENTS_DIR` | `.barnacle/events` | Directory for per-run NDJSON event stream files (`<eventsDir>/<runId>.ndjson`). |
917
- | `CALLS_NDJSON_PATH` | `.barnacle/calls.ndjson` | Append-only NDJSON sink for LLM/Stagehand call samples. One line per call; feed to the judge and self-heal skills. |
918
- | `SUBMISSIONS_NDJSON_PATH` | `.barnacle/submissions.ndjson` | Append-only NDJSON sink for dispatch submission envelopes and beacon-fire outcomes. `kind:"submit"` lines (null/`"submit"`-defaulted on legacy lines) capture siteId, requestId, inbound payload, status, audit payload, and duration, plus the opaque `joinKeys` bag a plugin's `extractJoinKeys` hook resolved, merged with anything attached mid-run via `context.telemetry.addJoinKeys()` — the durable source-of-truth for "what did we submit for jobId X and did it succeed." `kind:"beacon"` lines record a later (or, for `beaconStatus: "skipped"`, immediate) independent beacon-fire outcome (`beaconStatus`: `fired`/`failed`/`skipped`, truncated `trackingUrl`) for the same `requestId`, so "submitted but the beacon did not fire" is measurable — the `skipped` line is always written by `dispatch()` itself, but a plugin managing its own tracking nav can call `context.recordBeaconOutcome` to append a real `fired`/`failed` line for the same `requestId`, which outranks `skipped` when the two are folded (see [Reconciliation join keys](#reconciliation-join-keys-extractjoinkeys)). A reader folds both kinds together by `requestId`, so a plugin can join runs to its own attribution provider's report without re-parsing `inboundPayload`. |
919
- | `TELEMETRY_MAX_FILE_SIZE_BYTES` | `104857600` (100 MB) | Rotate/drop the calls NDJSON once it exceeds this byte count. |
920
- | `TELEMETRY_MAX_RETENTION_MS` | `2592000000` (30 days) | Drop event-stream files older than this many milliseconds. |
921
- | `TELEMETRY_S3_BUCKET` | — | Optional — destination bucket for the buffered S3 telemetry replica. Sink is entirely inert (no client, no network calls) when unset. Credentials/region resolve the same way as Bedrock (`AWS_REGION`, standard SDK credential order). |
922
- | `TELEMETRY_S3_PREFIX` | `telemetry` | Key prefix for uploaded NDJSON objects (`<prefix>/<calls\|submissions>/<date>/...`). |
923
- | `TELEMETRY_S3_FLUSH_INTERVAL_MS` | `60000` | How often buffered lines are flushed to S3. |
924
- | `TELEMETRY_S3_MAX_BUFFER_LINES` | `500` | Threshold-flush trigger — flush early if either buffer exceeds this many lines, ahead of the next scheduled interval. |
925
- | `TELEMETRY_S3_READ_MAX_OBJECTS` | `200` | Upper bound on the number of S3 objects a single reconciliation read-path query is allowed to scan. |
926
- | `TELEMETRY_S3_READ_CONCURRENCY` | `8` | Max concurrent object fetches for a single reconciliation read-path query. |
927
-
928
- ### LLM judging
929
-
930
- | Variable | Default | Purpose |
931
- |----------|---------|---------|
932
- | `JUDGE_MODEL` | `us.anthropic.claude-sonnet-4-6[1m]` | Anthropic model used by the judge script. Reuses Bedrock creds via the cross-region inference profile. |
933
- | `JUDGE_TEMPERATURE` | `0.2` | Sampling temperature for judge LLM calls. Keep low (≤ 0.3) for deterministic verdicts. |
934
- | `JUDGE_BATCH_SIZE` | `10` | Number of call samples sent to the judge in one LLM request. |
935
- | `JUDGE_TIMEOUT_MS` | `120000` (2 min) | Anthropic SDK request timeout for judge calls. |
936
-
937
- ### Self-heal
938
-
939
- | Variable | Default | Purpose |
940
- |----------|---------|---------|
941
- | `SELFHEAL_MAX_ITERATIONS` | `5` | Maximum patch→replay→score iterations before BUDGET_EXHAUSTED. |
942
- | `SELFHEAL_N_REPLAYS` | `5` | Number of replay runs per iteration arm. |
943
- | `SELFHEAL_SUCCESS_THRESHOLD` | `0.9` | Minimum pass rate (0–1) to declare SUCCESS and stop iterating. |
944
- | `SELFHEAL_PLATEAU_WINDOW` | `3` | Consecutive iterations below `SELFHEAL_PLATEAU_DELTA` that triggers PLATEAUED. |
945
- | `SELFHEAL_PLATEAU_DELTA` | `0.03` | Minimum absolute pass-rate improvement per iteration to count as progress. |
946
- | `SELFHEAL_TIMEOUT_MS` | `60000` (1 min) | Per-replay LLM request timeout. |
947
-
948
- ### Per-site base URL overrides
949
-
950
- Set `BARNACLE_SITE_<UPPERCASE_SITE_ID>_BASE_URL` to override a plugin's
951
- `defaultBaseUrl` without source changes. Underscores in the env key map to
952
- hyphens in the `siteId`:
953
-
954
- ```bash
955
- BARNACLE_SITE_MY_SHOP_BASE_URL="https://staging.my-shop.com" # overrides plugin `my-shop`
956
- ```
957
-
958
- ### Out-of-tree plugins
959
-
960
- | Variable | Default | Purpose |
961
- |----------|---------|---------|
962
- | `BARNACLE_PLUGINS` | `""` | Comma-separated list of plugin specifiers to load at startup — relative paths (`./plugins/acme`) or package names (`@acme/barnacle-plugin`). Empty by default (built-ins only). |
963
- | `BARNACLE_PLUGINS_STRICT` | `false` | When `true`, any plugin that fails to load aborts the process instead of producing a disabled record. |
964
- | `BARNACLE_PLUGINS_DIR` | `process.cwd()` | Base directory used to resolve relative specifiers and locate the operator's `node_modules`. Defaults to wherever the binary is run — not the installed Barnacle package root. |
965
- | `BARNACLE_PLUGINS_CONFIG_DIR` | _(unset)_ | Directory scanned at startup for `*.plugin.json` config manifests, each loaded as a config-only plugin. Lets operators register sites by dropping a JSON file in a directory instead of editing `BARNACLE_PLUGINS`. An unreadable directory is logged and skipped — it never crashes boot. |
966
-
967
- **Resolution rule:** a specifier starting with `.` or `/` is treated as a filesystem path resolved relative to `BARNACLE_PLUGINS_DIR`. Anything else is treated as an npm package name and resolved via `require.resolve` against the operator's own `node_modules` inside `BARNACLE_PLUGINS_DIR`.
968
-
969
- **Failure policy:** by default (non-strict), a plugin that fails to load is logged at `warn` level and recorded as `"disabled"` in the load report — the server still boots with the remaining plugins. Set `BARNACLE_PLUGINS_STRICT=true` to abort startup on any load failure instead.
970
-
971
- **`zod/v4` requirement for plugin authors:** import Zod as `import { z } from "zod/v4"` in your plugin, not as bare `"zod"`. Barnacle uses `fastify-type-provider-zod` which compiles routes against core's own zod instance; a plugin schema built against a different zod import may pass load-time validation but fail at route registration.
972
-
973
- `GET /v1/plugins` (authenticated) returns the full plugin load report — one record per built-in and out-of-tree specifier — including `siteId`, `displayName`, `route`, `specifier`, `resolvedPath`, `apiVersion`, `status` (`"loaded"` or `"disabled"`), and an optional `reason` when disabled. Requires a valid `Authorization: Bearer <token>` header (reveals filesystem paths, so it is separate from the auth-free `/healthz`/`/readyz` probes).
974
-
975
- ---
21
+ See [docs/architecture.md](./docs/architecture.md) for the design rationale and
22
+ [docs/playbook.md](./docs/playbook.md) for the full step-by-step guide to
23
+ adding a new site.
976
24
 
977
25
  ## Usage
978
26
 
@@ -980,33 +28,18 @@ BARNACLE_SITE_MY_SHOP_BASE_URL="https://staging.my-shop.com" # overrides plugin
980
28
 
981
29
  - Node.js 22+
982
30
  - pnpm 10.4.1
983
- - A Steel account (`STEEL_API_KEY`) for managed browser sessions
984
- - An Anthropic key (`ANTHROPIC_API_KEY`) for Stagehand's LLM calls, **or** AWS Bedrock (`USE_BEDROCK=true` + AWS credentials) — see `.env.example` for details
31
+ - A Browserbase account (`BROWSERBASE_API_KEY` + `BROWSERBASE_PROJECT_ID`) for managed browser sessions — the default provider. A Steel account (`STEEL_API_KEY`) is an alternative via `SCRAPER_PROVIDER=steel`
32
+ - An Anthropic key (`ANTHROPIC_API_KEY`) for Stagehand's LLM calls, **or** AWS Bedrock (`USE_BEDROCK=true` + AWS credentials) — see [docs/configuration.md](./docs/configuration.md) for details
985
33
 
986
34
  ### Install
987
35
 
988
36
  ```bash
989
37
  pnpm install
990
- cp .env.example .env # fill in STEEL_API_KEY and either ANTHROPIC_API_KEY or Bedrock creds
991
- ```
992
-
993
- ### Generating an API key
994
-
995
- Barnacle validates every request using bcrypt-hashed bearer tokens stored in
996
- `API_KEYS_HASHED`. To create one:
997
-
998
- ```bash
999
- # 1. Generate a random plaintext key — save this, you'll send it as Authorization: Bearer <key>
1000
- node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
1001
-
1002
- # 2. Hash it (bcrypt cost factor 10) — paste the output into API_KEYS_HASHED
1003
- node -e "const b=require('bcryptjs');b.hash(process.argv[1],10,(e,h)=>console.log(h))" <your-key>
38
+ cp .env.example .env # fill in BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID (or STEEL_API_KEY for SCRAPER_PROVIDER=steel) and either ANTHROPIC_API_KEY or Bedrock creds
1004
39
  ```
1005
40
 
1006
- Comma-separate multiple hashes in `API_KEYS_HASHED` to support key rotation.
1007
-
1008
- For local development, set `DEV_BYPASS_AUTH=true` in `.env` to skip auth
1009
- entirely — never set this in production.
41
+ Generating and hashing an API key, plus the full env-var reference, live in
42
+ [docs/configuration.md](./docs/configuration.md).
1010
43
 
1011
44
  ### Dev
1012
45
 
@@ -1023,106 +56,31 @@ pnpm start
1023
56
 
1024
57
  ### Try it
1025
58
 
1026
- Barnacle boots with the built-in plugins registered (see `BUILTIN_SITE_PLUGINS` in `src/plugins/discover.ts`). Follow [Adding a New Site](#adding-a-new-site--the-recon-playbook) above to build and register a plugin; core will register `POST /v1/<your-siteId>/run` automatically at startup.
1027
-
1028
- With the dev server running (`pnpm run dev`), confirm the server is up:
59
+ With the dev server running, confirm it's up:
1029
60
 
1030
61
  ```bash
1031
62
  curl -s http://localhost:3000/health | jq .
1032
63
  ```
1033
64
 
1034
- Once a plugin is registered, every response follows the same envelope shape. The status block is always present; the plugin's `responseSchema` fields are spread alongside it at the root:
1035
-
1036
- ```json
1037
- {
1038
- "status": {
1039
- "httpStatus": "OK",
1040
- "dateTime": "2025-05-16T12:00:00.000Z",
1041
- "details": []
1042
- },
1043
- "items": []
1044
- }
1045
- ```
1046
-
1047
- The envelope is a **flat merge**, not nested — `status` lives at the root and the plugin's response fields are spread alongside it (`src/api/helpers/envelope.ts:8-25`). Parse as `{ status, ...pluginData }`, not `{ status, data: pluginData }`.
1048
-
1049
- Every response — success or error — uses the same envelope shape so clients share a single parser. Error details appear in `status.details[]` with numeric codes:
1050
-
1051
- | Code | Name | When |
1052
- |------|------|------|
1053
- | 1000 | `PARTIAL_CONTENT_SUCCESS` | Partial data returned |
1054
- | 1001 | `DECODING_ERROR` | Request body could not be parsed |
1055
- | 1002 | `FIELD_VIOLATION` | Schema validation failure on a field |
1056
- | 1003 | `EMPTY_REQUEST` | Request body was missing or empty |
1057
- | 1004 | `AUTHORIZATION_ERROR` | Bearer token missing or invalid |
1058
- | 1005 | `RESOURCE_NOT_FOUND` | Requested resource does not exist |
1059
- | 1006 | `INDEX_NOT_FOUND` | Internal index lookup failed |
1060
- | 1007 | `CLIENT_CALL_ERROR` | Downstream client call failed |
1061
- | 1008 | `GENERIC_ERROR` | Unclassified server error |
1062
- | 1009 | `EXTRA_DETAIL` | Supplemental detail entry (informational) |
1063
- | 1010 | `THROTTLED_REQUEST` | Rate limit exceeded (hot path 429) |
1064
- | 1011 | `TIME_OUT` | Request timed out |
1065
- | 2003 | `SCRAPE_FAILURE` | Browser automation failed after retries |
1066
- | 2004 | `CAPTCHA_ENCOUNTERED` | CAPTCHA challenge could not be resolved |
1067
- | 2005 | `EMPTY_RESULTS` | Scrape succeeded but returned no data |
1068
- | 2006 | `VERIFICATION_TRIGGER_FAILED` | OTP trigger to the target site failed |
1069
- | 2007 | `RESUME_INVALID_OTP` | Provided OTP was rejected by the target site |
1070
- | 2008 | `URL_LOCKED` | Target site locked the target URL; back off and retry later |
1071
-
1072
- Full definitions: `src/api/schemas/common.ts`.
1073
-
1074
- **How scraper exceptions map to API codes** (`src/plugins/loader.ts:88-92`):
1075
-
1076
- - `CaptchaError` → `2004 CAPTCHA_ENCOUNTERED`
1077
- - `EmptyResultsError` → `2005 EMPTY_RESULTS`
1078
- - `HttpRateLimitError` → `1010 THROTTLED_REQUEST` (no browser fallback)
1079
- - `HttpUrlLockedError` → `2008 URL_LOCKED` (no browser fallback; distinct from rate-limit for metrics)
1080
- - Any other `ScraperError` → `2003 SCRAPE_FAILURE`
1081
- - Task exceeded `TASK_TIMEOUT_MS` → `1011 TIME_OUT`
1082
-
1083
- ## Endpoints
1084
-
1085
- Each registered plugin exposes a POST route following the default convention:
1086
- `POST /v1/<siteId>/run`. When the hot path detects that required applicant
1087
- answers are absent (e.g. Gender, Degree, EducationLevel, SignatureFullName) or
1088
- a repeat-applicant OTP challenge, `/run` returns HTTP 200 with
1089
- `{ needsUserInfo: true, missingFields: [{ field, question }], requiresOtp }`
1090
- instead of a submission result, so the caller can collect the gaps and hand back.
1091
-
1092
- Plugins declare their own extra routes via `meta.extraRoutes`, which core registers
1093
- uniformly — the engine has no per-site knowledge. Route paths are declared as
1094
- `:siteId` templates, so the concrete path is whatever the plugin's `siteId` is. Two
1095
- conventional shapes a plugin may add:
1096
-
1097
- - `POST /v1/<siteId>/resume` — body = the full original candidate payload plus
1098
- `collectedData` (and `otpCode` where the site issues an OTP challenge); re-runs the
1099
- hot path with the collected answers merged in; returns the same `{ verified }`
1100
- envelope as `/run`, or `2007 RESUME_INVALID_OTP` if the OTP is rejected
1101
- - `POST /v1/<siteId>/trigger-otp` — body `{ offerId, email }`; asks the target site to
1102
- email an OTP to a repeat applicant; returns `{ success: true }` or a
1103
- `2006 VERIFICATION_TRIGGER_FAILED` error envelope
1104
-
1105
- See `examples/plugins/acme-jobs.plugin.json` for a runnable declaration.
1106
-
1107
- Operational routes:
1108
- - `GET /healthz` — liveness probe
1109
- - `GET /readyz` — readiness probe (checks scraper credentials, queue depth)
1110
- - `GET /docs` — Swagger UI (when `ENABLE_DOCS=true`)
1111
- - `GET /v1/plugins` — authenticated plugin load report (see [Out-of-tree plugins](#out-of-tree-plugins))
1112
- - `GET /v1/submissions` — authenticated, queryable submit+beacon reconciliation rows (filter by `siteId`, `requestId`, `status`, `beaconStatus`, `from`/`to`; each row also carries the submit session block `session` (`{ id, provider, ip, ipCapturedAt }`) and the beacon-fire `beaconSessionIp`, neither of which is filterable at this layer, same as the opaque `joinKeys` bag; see [Submission-envelope sink](docs/telemetry-and-judging.md#submission-envelope-sink))
65
+ Barnacle boots with the built-in plugins registered (see `BUILTIN_SITE_PLUGINS`
66
+ in `src/plugins/discover.ts`). Each registered plugin exposes
67
+ `POST /v1/<siteId>/run`; every response uses the same envelope shape
68
+ (`{ status, ...pluginData }`) so clients share a single parser. See
69
+ [docs/plugin-authoring.md#endpoints](./docs/plugin-authoring.md#endpoints)
70
+ for the full endpoint reference.
1113
71
 
1114
72
  ## Commands
1115
73
 
1116
74
  | Command | What it does |
1117
75
  |---------|--------------|
1118
76
  | `pnpm run dev` | `tsx watch --env-file=.env src/server.ts` with hot reload |
1119
- | `pnpm run build` | compile to `dist/` (tsc + path alias rewriting + copy `src/sites/` fixtures and `src/testing/fixtures`) |
77
+ | `pnpm run build` | compile to `dist/` (tsc + path alias rewriting + copy fixtures) |
1120
78
  | `pnpm start` | `node dist/server.js` |
1121
79
  | `pnpm run typecheck` | strict TS noEmit |
1122
80
  | `pnpm run lint` / `lint:fix` | Biome |
1123
81
  | `pnpm run test` | Vitest unit + integration |
1124
82
  | `pnpm test src/scraper/fixtures.test.ts` | Run a single test file (NEVER use `--` before the filter) |
1125
- | `pnpm run test:watch` | Vitest in watch mode (re-runs on file changes) |
83
+ | `pnpm run test:watch` | Vitest in watch mode |
1126
84
  | `pnpm run test:coverage` | Vitest with v8 coverage report |
1127
85
  | `pnpm run format` | Biome format write |
1128
86
  | `pnpm run recon:browser` | Phase 1 — drive browser + capture API calls |
@@ -1131,165 +89,28 @@ Operational routes:
1131
89
  | `pnpm run recon:summarize -- --site-id <id>` | Phase 4 (optional) — write human-readable findings doc |
1132
90
  | `pnpm run recon:heal -- --site-id <id> --url <url>` | Self-heal a failing recon flow without modifying the source file |
1133
91
  | `pnpm run smoke -- --site <id> --payload '...'` | Phase 6 — run nightly drift-detection smoke test |
1134
- | `pnpm run judge:llm -- --calls-ndjson <path> --call-type <type>` | Score captured LLM calls on a three-dimensional rubric; writes a verdict JSON to `judge-out/` |
1135
- | `pnpm run heal:llm -- --verdict-path <path> --call-type <type>` | Self-heal a failing prompt template: iterate patch→replay→score, write `healing-<callType>.md` with the best patch — production prompts are never modified |
1136
-
1137
- ## Architecture
1138
-
1139
- ```
1140
- src/
1141
- ├── server.ts # Fastify bootstrap — calls loadAllPlugins(), registerRoutes(), site-agnostic
1142
- ├── site-plugin.ts # SitePlugin<TInput,TOutput> interface (engine contract)
1143
- ├── config.ts # frozen env-typed config singleton
1144
- ├── plugins/
1145
- │ ├── loader.ts # dispatch(), registerRoutes(app, cfg, plugins)
1146
- │ └── discover.ts # BUILTIN_SITE_PLUGINS, loadAllPlugins(), loadPlugins()
1147
- ├── sites/
1148
- │ ├── _shared/ # branch-local cross-plugin guards (coverage-expectations.test.ts)
1149
- │ └── <site-id>/ # one directory per registered plugin
1150
- ├── api/
1151
- │ ├── plugins/ # auth, error-handler, request-context
1152
- │ ├── routes/ # health
1153
- │ ├── schemas/ # common envelope schemas; LLM telemetry + judge-verdict schemas
1154
- │ ├── helpers/envelope.ts # success envelope builder
1155
- │ └── errors.ts # error hierarchy + envelope builder
1156
- ├── scraper/
1157
- │ ├── session.ts # Steel + Stagehand session factory
1158
- │ ├── pool.ts # p-queue over createBrowserSession
1159
- │ ├── throttle.ts # Bottleneck limiter + jitter
1160
- │ ├── retry.ts # p-retry + failure classification
1161
- │ ├── errors.ts # typed scraper error hierarchy
1162
- │ ├── http-client.ts # typed fetch wrapper (hot path)
1163
- │ ├── rate-limited-json-client.ts # factory: Bottleneck + chromiumClientHints + createHttpClient in one call — prefer this over the three-step scaffold for Chromium-hint plugins
1164
- │ ├── http-status-classifier.ts # pure status→ScraperError classifier for raw-fetch callers
1165
- │ ├── raw-fetch.ts # site-agnostic undici scaffold: network-error wrap, onResponse hook, optional classifyHttpStatus (skipClassify for callers that classify manually)
1166
- │ ├── graphql-client.ts # GraphQL POST wrapper
1167
- │ ├── metrics.ts # drift-detection counters
1168
- │ ├── fixtures.ts # static JSON fixture loader
1169
- │ ├── navigate.ts # shared awaitActivePage + goto(networkidle) helper
1170
- │ ├── behavioral-signals.ts # CDP synthetic mouse-move + scroll dispatcher for bot-detection warmup
1171
- │ ├── session-warmup.ts # generic pRetry browser-session runner: acquire → callback → close, with caller-supplied exhaustion mapping
1172
- │ ├── session-ip.ts # resolves a session's outbound IP via a throwaway tab + IP-echo navigation
1173
- │ └── require-response-field.ts # shared helpers for extracting required fields from HTTP response objects (HttpSchemaError on missing/null)
1174
- ├── cache/
1175
- │ ├── response-cache.ts # lru-cache wrapper for deduplicating concurrent identical scraper requests
1176
- │ └── keyed-ttl-cache.ts # generic per-key TTL + single-flight coalescing cache factory
1177
- ├── lib/ # logging, env, bedrock, db client, multipart, option-matcher, chromium-client-hints, telemetry/
1178
- ├── scripts/ # recon-browser, recon-http, recon-generate, recon-summarize, recon-heal, recon-shared, smoke-test, judge-llm-batch, llm-heal
1179
- ├── testing/
1180
- │ ├── integration-runner.ts # site-agnostic scaffold for integration tests (allocate inbox → dispatch → poll)
1181
- │ ├── mock-fetch-response.ts # shared undici-compatible Response stub factory for flow tests that mock fetch
1182
- │ ├── replay-integration-suite.ts # generic describe.skipIf/it.each scaffold; eliminates per-site integration boilerplate
1183
- │ ├── contract-parity-suite.ts # offline schema-parity scaffold; one-call drop-in for accept + rejection-case coverage
1184
- │ ├── coverage-guard-suite.ts # registry-driven structural guard; asserts contract.parity.test.ts exists per registered plugin
1185
- │ ├── batch-email-confirmation.ts # two-phase batch runner: submit jobs → poll inboxes (site-agnostic)
1186
- │ └── batch-report.ts # markdown table renderer for batch-test verdicts
1187
- └── types/
1188
- ```
1189
-
1190
- **Library choices** (battle-tested — no custom reinventions):
1191
-
1192
- - API server: [`fastify`](https://fastify.dev/) + helmet + compress + rate-limit + swagger
1193
- - Schema: [`zod`](https://zod.dev/) via `fastify-type-provider-zod`
1194
- - Browser automation: [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) + [`steel-sdk`](https://steel.dev)
1195
- - Concurrency: [`p-queue`](https://github.com/sindresorhus/p-queue), [`p-retry`](https://github.com/sindresorhus/p-retry), [`bottleneck`](https://github.com/SGrondin/bottleneck)
1196
- - Caching: [`lru-cache`](https://github.com/isaacs/node-lru-cache)
1197
- - Logging: [`pino`](https://github.com/pinojs/pino) with CloudWatch 256KB splitting + sensitive-field redaction
1198
-
1199
- **Per-site base URL overrides:** set `BARNACLE_SITE_<UPPERCASE_SITE_ID>_BASE_URL` to override a plugin's `defaultBaseUrl` without source changes. Underscores in the env key map to hyphens in the `siteId` (e.g. `BARNACLE_SITE_MY_SHOP_BASE_URL` → plugin `my-shop`).
1200
-
1201
- **Execution header:** send `x-barnacle-execution: browser` on any plugin request to skip the hot path and go directly to the Stagehand browser path. Omit the header (or send any other value) to use the default hot path. Useful for debugging or when you know the hot path is broken. (Fastify lowercases incoming header keys; the dispatcher reads `request.headers["x-barnacle-execution"]` — supply lowercase to match.)
92
+ | `pnpm run judge:llm -- --calls-ndjson <path> --call-type <type>` | Score captured LLM calls on a three-dimensional rubric |
93
+ | `pnpm run heal:llm -- --verdict-path <path> --call-type <type>` | Self-heal a failing prompt template |
1202
94
 
1203
- ---
1204
-
1205
- ## Deployment
1206
-
1207
- ### Production checklist
1208
-
1209
- ```bash
1210
- # .env (production)
1211
- NODE_ENV=production
1212
- ENABLE_DOCS=false # never expose Swagger in prod
1213
- TRUST_PROXY=true # set false if deploying directly to the internet (no ALB/nginx)
1214
- DEV_BYPASS_AUTH=false # this is the default — confirm it's not set to true
1215
- API_KEYS_HASHED="<bcrypt-hash>,<bcrypt-hash>" # at least one key
1216
- STEEL_API_KEY="..."
1217
- ANTHROPIC_API_KEY="..." # or USE_BEDROCK=true + AWS creds
1218
- ```
95
+ ## Writing a plugin
1219
96
 
1220
- ### Process management
1221
-
1222
- Barnacle is a plain Node.js process. Use pm2 or systemd to keep it alive and
1223
- restart it on crash:
1224
-
1225
- ```bash
1226
- # pm2
1227
- pm2 start dist/server.js --name barnacle --env production
1228
- pm2 save && pm2 startup
1229
-
1230
- # systemd (example unit)
1231
- [Service]
1232
- ExecStart=/usr/bin/node /srv/barnacle/dist/server.js
1233
- WorkingDirectory=/srv/barnacle
1234
- EnvironmentFile=/srv/barnacle/.env
1235
- Restart=on-failure
1236
- ```
1237
-
1238
- ### Reverse proxy
1239
-
1240
- Route traffic through nginx or an Application Load Balancer (ALB). Set
1241
- `TRUST_PROXY=true` so Fastify uses `X-Forwarded-For` for the client IP
1242
- (needed for rate limiting on unauthenticated traffic).
1243
-
1244
- ```nginx
1245
- location / {
1246
- proxy_pass http://127.0.0.1:3000;
1247
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1248
- proxy_set_header X-Forwarded-Proto $scheme;
1249
- proxy_set_header Host $host;
1250
- }
1251
- ```
1252
-
1253
- ### Health probes
1254
-
1255
- Wire `/healthz` as the liveness probe and `/readyz` as the readiness probe:
1256
-
1257
- ```yaml
1258
- # Kubernetes example
1259
- livenessProbe:
1260
- httpGet: { path: /healthz, port: 3000 }
1261
- initialDelaySeconds: 5
1262
- readinessProbe:
1263
- httpGet: { path: /readyz, port: 3000 }
1264
- initialDelaySeconds: 10
1265
- ```
1266
-
1267
- `/readyz` returns 503 when the scraper pool queue is saturated (depth >
1268
- `READINESS_QUEUE_THRESHOLD`) or when required scraper credentials are missing.
1269
-
1270
- ---
1271
-
1272
- ## Common issues
1273
-
1274
- | Symptom | Cause | Fix |
1275
- |---------|-------|-----|
1276
- | `Error: STEEL_API_KEY is required` | Missing env var | Add `STEEL_API_KEY` to `.env` |
1277
- | `useProxy rejected` / `402` from Steel | Free-tier plan doesn't support residential proxies | Set `SCRAPER_PROXY_TYPE=none` and `SCRAPER_SOLVE_CAPTCHA=false` |
1278
- | `401 Unauthorized` on every request | No API key configured or wrong plaintext key | Verify `API_KEYS_HASHED` is set; double-check the plaintext key. For dev, set `DEV_BYPASS_AUTH=true` |
1279
- | Stagehand throws `model not found` | Wrong model name format | Use the `anthropic/` prefix: `STAGEHAND_MODEL=anthropic/claude-sonnet-4-6` |
1280
- | `/readyz` returns 503 on `scraperCredentials` | `STEEL_API_KEY` or LLM key missing | Set the missing credential |
1281
- | Build succeeds but `dist/sites/` is empty | `tsc` ran but `cp -r src/sites dist/sites` was skipped | Run `pnpm run build` (not `tsc` directly) — the build script copies site sources after compilation |
1282
-
1283
- ---
97
+ A site plugin is a single TypeScript module that satisfies `SitePlugin<TInput, TOutput>`
98
+ from `src/site-plugin.ts` — a `meta` block, an optional `executeHttp` hot path, and a
99
+ Stagehand `execute` fallback. `pnpm run recon:generate` writes this structure for you.
100
+ Plugins can also be config-only JSON manifests with no TypeScript at all. Full
101
+ reference in [docs/plugin-authoring.md](./docs/plugin-authoring.md).
1284
102
 
1285
103
  ## Reference
1286
104
 
1287
105
  - Coding standards: [CLAUDE.md](./CLAUDE.md)
1288
106
  - Architecture & design rationale: [docs/architecture.md](./docs/architecture.md)
1289
107
  - Recon playbook (step-by-step): [docs/playbook.md](./docs/playbook.md)
108
+ - Configuration (all env vars, deployment, common issues): [docs/configuration.md](./docs/configuration.md)
109
+ - Plugin authoring guide: [docs/plugin-authoring.md](./docs/plugin-authoring.md) (runnable example: [examples/plugins/hello-site/](./examples/plugins/hello-site/README.md))
1290
110
  - Testing guide: [docs/testing.md](./docs/testing.md)
111
+ - Security policy & vulnerability reporting: [SECURITY.md](./SECURITY.md)
1291
112
  - Telemetry & LLM judging concept guide: [docs/telemetry-and-judging.md](./docs/telemetry-and-judging.md)
1292
- - Submission reconciliation runbook (join Barnacle runs to a plugin's own attribution provider's report): [docs/submission-reconciliation.md](./docs/submission-reconciliation.md)
113
+ - Submission reconciliation runbook: [docs/submission-reconciliation.md](./docs/submission-reconciliation.md)
1293
114
  - Per-site recon findings: [docs/target-recon.md](./docs/target-recon.md) (populated after first `pnpm run recon:summarize`)
1294
115
 
1295
116
  ## License