Agent docs

extraction

Versable's agentic scraping web server, per the owner.

Versable's agentic scraping web server, per the owner. Three repos on the versable-git org: extractor, extractor-webserver, extractor-regression.

Read the topology section first. There are two live deployments under this name, not one, and only the older is documented anywhere. Which of them serves extraction.versable.ai is unresolved and marked as such.

Sources: live GCP state read 2026-08-17 (project gen-lang-client-0060614394, display name "versable-infra"); local checkouts of ../extractor and ../extractor-webserver; and versable-git/knowledge-base, an OKF bundle whose last update is 2026-07-10 and which covers the VM deployment only.

1. What it is#

A scrape engine plus an operator console over it. extractor is a pure CLI: it owns scrapers, resolvers, the pipeline engine, and a jobs/<NNN_NAME>/ folder layout on local disk. extractor-webserver is a FastAPI app that shells out to that CLI and adds an automation state machine, a self-heal loop, and a browser console. extractor-regression runs periodic regression tests against the scrapers.

Callers are speedway and walmart-mvp, plus the enhancement-product UI. Whether walmart actually calls it today is doubtful: its extractor_api_key defaults empty and the config comment says an unset key means stage 4 skips scraping entirely rather than failing (walmart-mvp/backend/app/config.py:89-94).

2. Shape of the runner, and the two deployments#

Deployment A, the documented one. A single Compute Engine VM, extractor in asia-southeast1-b, custom n2 with 4 vCPU and 16 GiB, external IP 34.177.88.50. Two systemd units on it: extractor-webserver on :8000 and extractor-regression on :8001, managed by ctl.py. The CLI engine is invoked as a subprocess by both. Behind Cloudflare.

caller ──▶ Cloudflare ──▶ VM extractor (asia-southeast1-b)
systemd extractor-webserver :8000
│ subprocess
uv run internal_scripts.py the CLI engine
jobs/<NNN_NAME>/dispatch/<run_id>/
meta.json · stdout.log LOCAL DISK

Dispatch is a detached local process with a run_id, and per-run state is files on the VM's disk. There is no queue and no object store in this path.

Deployment B, undocumented. Two Cloud Run services in australia-southeast1, from one image data-extraction/app:47651fadf0c5fc56dba871f7b994d0a2878b4d9d, one service account extraction-app@.

ServicemaxScaleCPU / memtimeout
extraction-api1002 / 1Gi60s
extraction-worker502 / 2Gi300s

Both carry a pgbouncer sidecar (container-dependencies={"app":["pgbouncer"]}) and a VPC connector, and both keep instances warm, but not at the same floor: extraction-api runs minScale=1 and extraction-worker runs minScale=10. Read live 2026-08-18 in versable-infra (gen-lang-client-0060614394, australia-southeast1); an earlier version of this doc recorded minScale=1 for the pair and understated what the worker costs, since ten instances stay billed whether or not a job is running. Config: DATABASE_URL, STENT_DATABASE_URL, REDIS_URL, SCRAPER_API_KEY, OXYLABS_USER, OXYLABS_PASS, all secret-injected, plus GCS_BUCKET_NAME=versable-data-extraction. 39 revisions, image history to 2026-02-21.

So B is Postgres and Redis and object storage, split api from worker by role, on a private network, always warm. A is a single VM writing to local disk. These are different architectures wearing one name.

Unresolved: which one is extraction.versable.ai. The hostname resolves to Cloudflare (172.67.179.185, 104.21.75.179), so the origin cannot be read from DNS. The knowledge base says the hostname is "the webserver's public host", which is A. But walmart calls /api/v1/extract, and extractor-webserver mounts only /api/automation (automation/router.py:21), which A cannot serve. Resolving this needs Cloudflare access or someone who knows.

3. Concern by concern#

Rows describe A where a citation exists, since that is the deployment with readable source. B's cells are live-config only and marked.

Paths are extractor-webserver/ and extractor/ in /Users/alcatraz627/Code/Versable/, read at 4a4dfad and 4fe3b0f.

#ConcernMechanismCiteVendor
1Runner/payload seama process boundary, not an import boundary: the console shells out to the CLI and reads its output files backextractor-webserver/server.py:5,20-22no
2Caller identityone shared API key, timing-safe compared, accepted as an x-api-key header or a ?key= query parameterextractor-webserver/automation/router.py:33-37, esp. :35no
3Tenancynone
4Roles / RBACnone
5Submit surfacePOST /api/automation/scrape-request, 202automation/router.py:83no
6Job state ownershipmodule owns; caller pollsautomation/router.py:249no
7Derived vs stored statusstored, one meta.json per run on local diskextractor/internal/dispatch.py:3-4,51no
8Dispatch / queuedetached local subprocess in a new session, no queue at allextractor/internal/dispatch.py:183-191 (start_new_session=True)POSIX
9Concurrency--parallel=N per dispatch; several dispatches may run at once, with no global ceilingextractor/internal/dispatch.py, READMEno
10Retry / backoffa self-heal loop over failed scrapers, not a per-item retry budgetautomation/router.py:127no
11HeartbeatPID liveness via /proc, so Linux-only; degrades on macOS devknowledge base, ports-and-deploymentLinux
12Cancelscrape-stop sends SIGTERM to the whole process group; the run finalizes as killedextractor/internal/dispatch.py:294,313,320POSIX
13Resume / checkpointnone found
14Idempotencynone found. A duplicate submit starts a duplicate scrape, which costs real Oxylabs spend
15Storage of inputsjobs/<NNN_NAME>/pipeline.json and job_items.json on local diskextractor/README.md, dispatch.py:39local FS
16Storage of outcomesoutputs/ per job, JSON and Excelextractor/README.mdlocal FS
17Results reportingGET /jobs/{job_folder}, GET /status, bulk POST /jobs/statusautomation/router.py:203,249,330no
18Logs per job and itemone stdout.log per run, appended to by the detached process, tailable via scrape-logs --follow; GET /runs/{id}/eventsextractor/internal/dispatch.py:179-187,257-262; automation/router.py:397no
19Tracingnone found
20Usage meteringnone found
21Limits / quotasnone found
22Cachinganti-bot and cache layer inside the engineextractor/ (per knowledge base)no
23Rate limiting outboundOxylabs concurrency handled in the engineno
24Config.env beside server.py, load_dotenv at import, every value an os.environ.get with a defaultextractor-webserver/server.py:12,18,20-26no
25Secretsa .env file on the host. API_KEY defaults to a fresh random token when unset, so a misconfigured start is locked rather than openserver.py:23no
26Human in the loopinteractive stages: POST /runs/{id}/action and /gate to approve or rejectautomation/router.py:412,446no
27Completion signallingpoll only. Grepping the whole console for webhook, hmac, signature and callback finds only the Slack report path; no signed job callback exists hereautomation/router.py:249; server.py:1067 (Slack)no
28Capability discovery/docs Swaggerautomation/router.py:4no
29Versioningnone found
30Health / readiness/health per systemd unitknowledge baseno
31Provisioningsystemd units driven by ctl.pyextractor-webserver/ctl.pysystemd
32Local devthe CLI is the local mode; the console points at ~/extractor by defaultserver.py:20-22no
33Output deliveryExcel and JSON under outputs/extractor/README.mdlocal FS
34Data retentionnone found
35Multiple versionstwo deployments coexist under one name, this one and the Cloud Run pairlive stateno
36Conformanceextractor-regression runs periodic regression tests against the scrapers, the closest thing in the estate to a conformance suiteknowledge baseno
37Outputs and exportsdeployment A writes Excel and JSON under outputs/ per job on local disk and serves them by reading the files back, with no declared transform and no partial-read semantics; pulling results also mutates, because the first pull wins. Deployment B not examined, its source is not on this machineextractor/README.md, speedway/app/lib/extractor.server.ts:11-12local FS
38Data ownership splitdeployment A has no database at all: per-run state is files on local disk under jobs/<NNN_NAME>/, and the caller owns everything else, which is why the caller polls. Deployment B is Postgres plus Redis plus object storage with api and worker split by role, but its source is not on this machine, so the split is known from live container config only and no evidence file exists for itdispatch.py:39, extractor/README.md, live statelocal FS / Postgres + Redis + GCS

Deployment B, live config only#

No source. Every cell is read from the running container and nothing here rests on code. Kept separate rather than mixed into the table above, so no reader mistakes a live-config observation for a verified mechanism.

ConcernObserved
topologyextraction-api (maxScale 100, 2cpu/1Gi, timeout 60s) and extraction-worker (maxScale 50, 2cpu/2Gi, timeout 300s), one image, one service account
storageDATABASE_URL, STENT_DATABASE_URL, REDIS_URL, all secret-injected; GCS_BUCKET_NAME=versable-data-extraction
networkVPC connector, pgbouncer sidecar, minScale 1 on extraction-api and minScale 10 on extraction-worker (live, 2026-08-18)
provisioningimage tagged by git SHA; no Cloud Build triggers in-project, so CI lives elsewhere

4. Runner vs payload#

The seam is a process boundary, which is unique in the estate. extractor-webserver never imports the engine; it shells out (EXTRACTOR_CMD = "uv run python internal_scripts.py", server.py:22) and reads the engine's output files back.

That buys real isolation: the engine cannot corrupt the console's state, the console can be restarted without touching a running scrape, and the engine stays a usable standalone CLI. It costs everything a type system or a function signature would give you. The contract is argv plus a folder layout, and it is enforced nowhere.

Runner: server.py, automation/{router,models,config}.py, ctl.py. Payload: the whole extractor repo, plus ext/extractors/ and ext/resolvers/ within it.

Leak worth naming: the console reaches into the engine's private layout. JOBS_DIR defaults to ~/extractor/jobs (server.py:21), so the console reads per-run meta.json and stdout.log directly rather than through any engine API. The process boundary is clean in one direction and absent in the other.

5. Deliberate decisions#

The engine stays a CLI, and the service is a wrapper. Every capability is reachable by a human at a terminal without the service running. This is why the engine has an ONBOARDING.md and a scraper-builder workflow, and why a developer can debug a scrape without touching the console.

Per-job opt-in strictness. strict_match_criteria is a pipeline-arg flag that tightens resolver matching per job, off by default, documented with the two symmetric failure modes that motivated it and a table of when it is a no-op (extractor/CLAUDE.md). The reasoning is recorded next to the knob, which is rare.

A regression service as a separate deployment. extractor-regression exists because scrapers break when sites change, and it is the only thing in the estate resembling canon row 36.

Human gates are first-class. POST /runs/{id}/action and /gate drive interactive stages with approve and reject. Canon 26 is open; this is a real implementation of it.

6. Lapses#

Frequent polling for job status (owner-reported, via gcp-fable), but the capability is not simply absent, and an earlier version of this doc said it was.

Deployment A does not deliver callbacks, and this is now grepped rather than assumed. Searching the whole console for webhook, hmac, signature and callback returns only the Slack report path (server.py:1067). The only completion signals it offers are GET /status and GET /runs/{id}.

The surface speedway talks to does deliver callbacks: speedway/app/lib/extractor.server.ts:5-9 documents a service that takes one site plus a SKU list per job, returns 202 with a job_id, and "delivers rows by webhook and/or polling", HMAC-SHA256 signed. Speedway receives them at app/routes/tasks/extractor-webhook.tsx:20-21, verifying an x-signature header. That is a third surface, distinct from A's /api/automation and from the /api/v1/extract walmart names.

So the owner's polling complaint is real but surface-specific, and the tree should stop treating "the extractor" as one thing with one answer.

Three things about that webhook are worth canon's attention:

  • The signing key is the API key. extractor.server.ts:8-9 says the webhook is "HMAC-SHA256-signed with that same key", and :252 confirms createHmac("sha256", config.extractorApiKey). contracts/module-surface.md:150 specifies HMAC "with the caller's callback secret", a separate value. Reusing the auth credential as the signing secret means a caller cannot rotate one without breaking the other, and anyone holding the API key can forge a callback.
  • There is no event id. The contract requires X-Foundry-Event-Id for receiver idempotency (module-surface.md:151). Only x-signature is verified.
  • A read mutates. extractor.server.ts:11-12: pulling /jobs/{id}/results "marks the job consumed remotely (first pull wins), never call it as a liveness poll; poll /jobs/{id} instead". A GET that is not idempotent and is destructive on second call is worth an explicit contract prohibition; no doc currently carries one.

Only one environment (owner-reported). No dev or staging deployment of the VM path. Canon 12 and 31 require three environments minimum with separate identity per environment. This is the most severe instance of that lapse in the estate: versable-runner at least has a -dev service, even if it shares prod's service account.

The API key is accepted as a query parameter (automation/router.py:35, request.query_params.get("key")). Query strings land in access logs, browser history, and referrer headers in a way headers do not. The header path is right there in the same expression; the query fallback is the lapse.

No tenancy (3), no roles (4), no metering (20), no limits (21), no versioning (29), no idempotency (14). A duplicate submit starts a duplicate scrape, which costs real vendor money at Oxylabs.

Two deployments under one name (35). Nobody can currently state which one serves production traffic. This is the third accidental instance of row 35 in the estate, after versable-runner's two forks and walmart's two pipelines.

The knowledge base documenting this stack is five weeks stale and describes only deployment A. It is a real artifact and it holds at least one expensive fact (see Unproven), but a reader trusting it would not learn that Cloud Run services exist.

7. Unproven#

  • Which deployment serves extraction.versable.ai. Cloudflare hides the origin. The knowledge base says the VM; the route walmart calls (/api/v1/extract) exists on neither surface I read. Unresolved.

  • Deployment B's source is not in the three named repos, and this was checked. The owner named three repos prefixed extractor on the versable-git org. All three were obtained: extractor and extractor-webserver were already local, extractor-regression was cloned read-only 2026-08-18. Grepping all three for deployment B's distinguishing markers (DATABASE_URL, REDIS_URL, STENT, pgbouncer, api/v1/extract) returns zero hits in any of them.

    So the three repos are deployment A's stack: the CLI engine, the operator console on :8000, and the regression service on :8001. Deployment B, the Cloud Run pair with Postgres, Redis and a pgbouncer sidecar, is a fourth codebase and its source has not been located. Every B cell in the table above is live container config (image, scaling, secrets, sidecar, network) and no B claim rests on source.

    Anyone extending this doc should ask the owner where the data-extraction image is built from rather than re-searching those three.

  • Whether walmart-mvp calls this service at all in practice. Its key defaults empty and the documented behaviour of an unset key is to skip scraping.

  • The Cloudflare User-Agent gotcha is inherited, not verified. Per the knowledge base log (2026-07-07), Cloudflare bot rules 403 datacenter IPs sending a default library UA (axios, python-requests, curl), and callers must send a custom one. Not reproduced here. It is the single most useful fact in that bundle and worth keeping regardless of the bundle's staleness.

  • extractor-regression was not examined. Row 36 rests on the knowledge base's description.

  • Rows 11, 22, 30 and 36 are transcribed from the knowledge base rather than from files opened this pass.

  • The local ../extractor checkout is at 4fe3b0f (2026-06-17) while the remote moved on 2026-06-22, so engine claims may be slightly behind.

  • Nothing was executed. No scrape was dispatched, no endpoint was called.

@versable-git/ui · reference, canon, and method, read in place