Local ingest
Product release note registry, made for agents
npx -y skills add buildinternet/releases --skill local-ingestAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Onboard or backfill a company's changelog locally in Claude Code by fetching pages and extracting releases with the agent itself (and parallel sub-agents), then writing through the batch-upsert endpoint — no remote fetch dispatch, no server-side extraction inference billing. Use when you want to onboard a source without triggering the remote update workflow's billed extraction, or recover from a remote fetch that burned tokens and wrote zero releases. Includes a mandatory robots.txt / Content-Signal opt-out gate.
SKILL.md
18.0 KB, as published. Nobody here has run it
Local Ingest
Onboard a changelog without dispatching the remote fetch path. The remote path (releases admin source fetch → POST /v1/workflows/update → the API worker's DeterministicUpdateWorkflow, #1946) runs the body→records extraction server-side (incremental Haiku or the tool-loop) and bills that inference — even when it writes zero releases. A local Claude Code agent is already a capable model with web-fetch + file tools, so it can do the per-page extraction inline (folded into the session you're already in) and only needs a deterministic write path.
Core principle: the agent is the extractor. You fetch, you parse, you structure the records, and you POST them to the batch endpoint, which does a plain idempotent upsert with no AI on insert. The remote extraction loop never runs.
Local Claude Code only. This skill uses the Agent tool for sub-agent fan-out and assumes a persistent local filesystem and the CLI's RELEASES_API_* env. It sits alongside seeding-playbooks as a local-only operator skill — it is not deployed to the managed-agent fleet.
Cost contract
State this explicitly to the operator before a large run, and honor it:
- Spends: your agent's own tokens for fetch + extract, plus any sub-agents you fan out (your choice of model —
sonnetfor quality,haikufor bulk). - Does NOT spend: no server-side extraction (the update workflow's incremental-Haiku / tool-loop passes), no remote
web_fetchextraction.POST /v1/workflows/updateis never called. - The batch endpoint runs no AI on insert — no summarization, no marketing classifier, no feed enrichment. It does an
insertplus a fire-and-forget vector embed (waitUntil). The AI fields (title_generated/title_short/summary) are intentionally left empty; see Manual enrichment below for the deliberate follow-up paths.
The negative signal that confirms no remote run happened: in Axiom releases-cloudflare-logs there is no POST .../fetch?sessionId=det-… and no extract-deps-worker event for the source.
When to use
- Onboarding a new source (or backfilling an existing one) interactively in Claude Code and you want to skip the remote extraction cost.
- A remote fetch burned a full extraction loop and wrote 0 releases (the Conductor / large-body /
max_tokensfailure mode) — extracting inline sidesteps the loop entirely. - The page is fetchable by your own web tools (the refusal below is a policy gate, not a fetch limit).
When NOT to use:
- You're in a managed-agent session. This is local-only (Agent-tool fan-out + local filesystem + CLI env). MA sessions should keep using the normal fetch path.
- The source has a clean, content-rich feed. Just add it (
managing-sources) and let the cron pipeline fetch it. Local-ingest is for cases where the agent doing the extraction inline is the right tool, not a replacement for feed/GitHub adapters. - The publisher opts out (preflight refuses — see Step 1).
Workflow
Step 1 — Preflight (mandatory safety gate)
Run the opt-out check before fetching or writing anything:
bun .claude/skills/local-ingest/preflight.ts <url> --json
It fetches robots.txt (following apex→www redirects), parses the Cloudflare Content Signals policy, and gates on exit code:
| Exit | Verdict | Action |
|---|---|---|
0 | proceed | Permissive or absent — continue. |
1 | refuse | ai-input=no declared. STOP. Surface to the user. Do not fetch or write. |
2 | unknown | Couldn't fetch/parse robots.txt. Retry once; if still unknown, surface to the user — don't assume proceed. |
The gate is on ai-input only. Local ingest is search/input ingestion — feeding a search index — which is exactly what ai-input governs. ai-train=no opts out of model training, a use this path doesn't perform, so ai-train=no alone (e.g. vercel.com) correctly proceeds.
This is the Conductor lesson. conductor.build serves Content-Signal: ai-train=no, search=yes, ai-input=no — its org/source already sits paused in the registry for exactly this reason (releases admin playbook conductor). A local onboard must not silently spend tokens or ingest content against a publisher opt-out. conductor.build must be refused (via its ai-input=no) — it is the regression target for this gate.
The refusal is a policy choice, not a technical limitation: web_fetch and CF Browser Rendering can still retrieve these pages (only Cloudflare's /crawl endpoint hard-enforced the signal for Conductor). Honor the opt-out anyway. Override only with explicit publisher permission documented by the operator — there is no silent bypass.
The preflight also prints any Sitemap: URLs from robots.txt — keep them for Step 3.
Step 2 — Resolve target
Ensure the org and source exist before writing — releases attach to a source_id. Defer to managing-sources for create/naming/primary rules and to finding-changelogs for whether a structured feed exists (if it does, prefer the normal pipeline). Then:
- Read the org playbook for parse hints:
releases admin playbook <org>(seemanaging-sources→ Playbooks). - Capture the
sourceSlugand thesrc_…id from the created/resolved row — you need one of them for the batch URL.
If the source is brand-new, create it (e.g. releases admin source create "<name>" --url <url> --org <org> [--primary]) but do not trigger a fetch — that would dispatch the remote update workflow you're trying to avoid.
Step 3 — Map pages
Classify the page shape (releases admin discovery evaluate <url> --json reports pageStructure: single-page | index | unknown):
- Single-page changelog → one fetch in Step 4.
- Index → detail → enumerate the per-release URLs. Prefer
/sitemap.xml(the preflight surfaced it) filtered to the changelog path; otherwise parse the index HTML for per-release links. - Client-rendered index → the index is a JS shell whose cards load via a client-side fetch, so
curl/non-JS scrape returns zero entries. Enumerate by rendering: drive the index with Claude in Chrome (or fan out a rendering sub-agent), paginating if the list is windowed, and collect the per-release links. Then check whether the detail pages are statically rendered — they often are even when the index isn't — and if so bulk-curlthose directly (HTML→markdown locally, no render, no AI bill). Harvey'shelp.harvey.ai/release-notesis exactly this: client-rendered index, static detail pages. Don't conclude "no releases" from an empty non-JS scrape of the index.
Cap to a recent window (default ~25–50 most-recent releases) and log what you skipped — never silently truncate. Re-runs are idempotent, so a deeper backfill later just adds rows.
Step 4 — Fetch + extract
Reuse the parsing-changelogs conventions for type/version/date/media (see Parity below).
- A few pages: fetch and extract inline in this agent.
- Many pages: fan out parallel sub-agents — one URL-slice each, each returning records in the batch schema. Use the
Agent-tool dispatch pattern fromseeding-playbooks(and superpowers:dispatching-parallel-agents for the fan-out discipline). Do not fetch release URLs in the parent agent — delegate to keep the parent's context clean. - Parent-saves caveat: sub-agents may be blocked from making the batch write (permission limits). Plan for it — have each sub-agent return its records in its result, and let the parent (you) do the writes in Step 5. Same pattern
seeding-playbooksuses for note-saving. - Extract media — don't ship text-only. Pull the images/video out of each release body and populate
media, unwrapping_next/image/_vercel/imageoptimizer wrappers to the underlying CDN URL (normalizeMediaUrlfrompackages/rendering/src/media-url.ts). A text-only first pass is a common miss caught only on operator review (Harvey shipped text-only, then had to re-extract_next/image→cdn.sanity.ioand re-seed); the enrichment re-POST ismode: "upsert-content"(Step 5).
Each record must match the batch schema (see Step 5) and the parity rules.
Optional — deterministic extraction (Approach A, monorepo only): instead of extracting by hand you can call the production extract libs in packages/adapters/src/extract/ — extractFromBody, extractWithTools, runDirectFetchExtraction. ExtractDeps.cloudflare is nullable and repo can be a noop, so they run with just an Anthropic key (proof: scripts/smoke-toolloop.ts). This trades agent tokens for direct Anthropic-API tokens but reproduces the production tool-loop exactly. These packages are private: true — reachable from the monorepo only, never the thin CLI. The default remains Approach B (the agent is the extractor).
Step 5 — Write (batch upsert)
POST records to the batch endpoint in chunks (~50/request keeps requests bounded; the handler does the D1 bind-limit chunking server-side):
# Use the org-scoped path. The bare /v1/sources/:slug form accepts ONLY a typed
# `src_…` id — a human slug there returns 400 bare_slug_rejected (slugs are
# org-scoped, #690). With a typed id the bare form also works:
# POST $RELEASES_API_URL/v1/orgs/<orgSlug>/sources/<sourceSlug>/releases/batch
# POST $RELEASES_API_URL/v1/sources/<src_…>/releases/batch
curl -sS -X POST "$RELEASES_API_URL/v1/orgs/<orgSlug>/sources/<sourceSlug>/releases/batch" \
-H "Authorization: Bearer $RELEASES_API_KEY" \
-H "Content-Type: application/json" \
-d @chunk.json # { "releases": [ … ] }
Per-release fields (the real accepted shape — workers/api/src/routes/sources.ts, postReleasesBatchHandler):
{
"version": "1.4.0", // optional; omit/null for date-headed or blog sources
"title": "Dark mode", // required
"content": "Markdown body…", // required
"url": "https://…/changelog/dark-mode", // optional but ALWAYS populate — it's the dedup key
"contentHash": "…", // optional
"publishedAt": "2026-05-30", // optional; ISO-8601 (see Parity)
"media": "https://…/shot.png", // optional
"type": "feature", // optional; "feature" | "rollup"
"prerelease": false, // optional
}
- Idempotent. Upserts on
UNIQUE(source_id, url), fill-don't-clobber: on collision it only backfills content/media when the stored row is empty, so a re-run never overwrites a good row. Safe to re-run and to page — sameurlupdates rather than duplicates. - Enrichment re-POST (
mode: "upsert-content"). Because the default is fill-only, re-POSTing richer content for a URL that already has content is a no-op — the stub stays. For a deliberate second pass (seed index summaries first, then full detail-page bodies + media), add"mode": "upsert-content"at the top level of the batch body: it overwrites content/media on a same-URL collision and skips the scrape title-dedup pre-filter (#1526). Use it only for intentional enrichment; the default path stays fill-only so a routine re-fetch can't clobber. Theurlmust be identical to the seeded row — enrich keys on URL, so a changed URL scheme inserts a duplicate instead. If the URL scheme changed, re-seed instead:releases release delete --source <src_…> --hardthen a fresh/batch(default mode). - Auth. Requires
writescope; the staticRELEASES_API_KEY(root) satisfies it..envautoloadsRELEASES_API_URL+RELEASES_API_KEYin this repo. - No AI on insert (see Cost contract).
Chunking helper (reads a records.json array, posts in batches of 50):
bun -e '
const orgSlug = process.argv[1], sourceSlug = process.argv[2], file = process.argv[3];
const all = JSON.parse(await Bun.file(file).text());
const base = process.env.RELEASES_API_URL, key = process.env.RELEASES_API_KEY;
for (let i = 0; i < all.length; i += 50) {
const releases = all.slice(i, i + 50);
const res = await fetch(`${base}/v1/orgs/${orgSlug}/sources/${sourceSlug}/releases/batch`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ releases }),
});
console.log(`chunk ${i / 50}: ${res.status} ${(await res.text()).slice(0, 200)}`);
}
' <orgSlug> <sourceSlug> records.json
Step 6 — Validate
releases tail <slug> --json # or: get_latest_releases (typed tool)
Confirm releases have non-empty titles, dates, content, and media. If a row is empty, fix the extraction and re-run — the default upsert backfills empty rows safely. If a row already has thin-but-non-empty content (e.g. an index-summary stub you now want to replace with the full body), a plain re-run is a no-op — use the mode: "upsert-content" enrichment re-POST from Step 5.
Recovering a source, not onboarding one? This step only confirms what got written — it says nothing about what didn't. Before telling anyone a source is "recovered" or "nothing is missing," see backfilling-sources → Before declaring a source "clean" or "recovered" for the required live-page spot check.
Step 7 — Playbook note
Record how this source was ingested so future agents don't re-derive it. Via manage_playbook (action update_notes) or releases admin playbook <org> --notes-file -. Note that the source was locally ingested (not on the cron path yet), its observed cadence, and any extraction quirks. Follow the managing-sources playbook-authoring rubric (imperative voice, keep-test, ### Fetch instructions / ### Traps / ### Coverage). Don't restate metadata the header already carries.
Parity requirements
Local output must match production shape so the source can later be picked up by the normal pipeline with no cleanup:
| Axis | Rule |
|---|---|
type | feature vs rollup per parsing-changelogs → Classifying Rollups. Leave unset/feature by default. |
version | Real version string, or omit. Pre-clean <UNKNOWN> / n/a placeholders — the batch path does not strip them (the single-insert path does, server-side). |
publishedAt | ISO-8601. Approximate from month/quarter/year headings rather than omit (parsing-changelogs → Dates): month → first of month, etc. |
media | Unwrap _next/image / Vercel optimizer wrappers before storing — use normalizeMediaUrl from packages/rendering/src/media-url.ts. |
url | Always populate. Dedup relies on a stable per-release url; without it the upsert can't collapse re-runs. |
Manual enrichment (out of scope for /batch, but available)
The batch path intentionally omits title_generated / title_short / summary and org overviews. When an operator wants them, set them deliberately, case by case:
- Per-release override:
PATCH /v1/releases/:idacceptssummary,titleGenerated,titleShort,composition,version, … and re-embeds when content/title/AI-fields change. - At insert time (alternative to
/batch): the single-release insertPOST /v1/sources/:slug/releasesacceptssummary?/titleGenerated?/titleShort?— use this path instead of/batchwhen you want enrichment set in the same write. It also strips<UNKNOWN>/n/aversions server-side. - Bulk AI enrichment after the fact:
bun scripts/generate-release-content.ts --orgs=<slug>.
Common mistakes
- Skipping the preflight. It's the first step for a reason — silently ingesting an opt-out source is the exact failure this skill exists to prevent.
conductor.buildmust be refused. - Silent truncation. Capping the window is fine; not telling the user what you skipped is not.
- Missing
url. Breaks idempotency — re-runs duplicate instead of upserting. - Triggering a fetch on the new source.
releases admin source fetch/manage_sourceactionfetchstarts the remote update workflow (billed server-side extraction) — the cost you're avoiding. Create the source, then write via/batch. - Fetching detail pages in the parent agent during fan-out. Delegate to sub-agents; keep the parent context clean.
- Using this for a clean feed source. Add it and let cron fetch — don't hand-extract what the feed adapter handles for free.
- Running it in a managed-agent session. Local Claude Code only.
Related skills
finding-changelogs— is there a feed/GitHub source that makes local-ingest unnecessary? Page-structure evaluation.managing-sources— create org/source, naming, primary, playbook authoring.parsing-changelogs— the extraction conventions you must match (type, dates, rollups).seeding-playbooks— the parallelAgent-tool dispatch + parent-saves pattern reused in Step 4.- superpowers:dispatching-parallel-agents — fan-out discipline for the many-pages case.