offer-updater — publish a verified free-AI-credit offer
Turn a screenshot or pasted text describing a free-credit offer into a valid,
web-verified offers/<slug>.yaml, without ever inventing a value and never
committing anything the curator did not explicitly approve.
Schema reference: docs/schema.md. Ground rules: CONTRIBUTING.md.
Repo Sync Before Edits (mandatory)
Before touching offers/, offers/details/, or any git-tracked file:
branch="$(git rev-parse --abbrev-ref HEAD)"
git fetch origin && git pull --rebase origin "$branch"
- If the working tree is dirty (
git status --porcelainnon-empty):git stash push -m "offer-updater pre-sync", sync, thengit stash pop. If pop conflicts, stop and ask the curator how to resolve before continuing. - If
originis missing or the rebase conflicts: stop, report the error verbatim, and ask the curator before continuing. Never force-push or skip the sync.
What I do
- Extract offer fields from your input (screenshot transcript, pasted text, or a source URL you supply), plus any claim instructions the input contains.
- Verify on the web that the offer is still live and the terms match — keeping a full reference trace of every URL visited for evidence.
- Normalize them into the frozen seven-field schema and pick a slug,
writing the reference trace into
offers/details/<slug>.json. - Validate the draft with the deterministic helper in this directory.
- Present the git diff of exactly what would change (including the reference trace).
- Commit only after you say yes — then open a tracking issue and PR.
The frozen schema (F5)
| Field | Rule |
|---|---|
title |
Human-readable offer name (non-empty). |
provider |
Company/product granting the credit (non-empty). |
category |
Exactly one of: api_provider, coding, image, voice, video. |
amount |
Free value in human terms, e.g. $300 in credits (non-empty). |
expiry_date |
YYYY-MM-DD the offer stops being claimable, or explicit null if ongoing. |
source_url |
Official provider page describing the offer (http(s)://). |
verified_date |
Date YOU verified the offer is live, YYYY-MM-DD, never null, never future. |
Pipeline
Step 1 — Extract
Read the screenshot/text and collect all seven fields. Hard rules:
- Never guess. A value you cannot read or confirm stays unknown; it is
never approximated, inferred from similar providers, or copied from stale
data elsewhere in
offers/. - If any required field is unknown after extraction, ask ONE targeted
clarifying question naming exactly the missing fields, then stop. Do not
write a partial file to
offers/.
Illegible input (unreadable screenshot, truncated paste): say which parts are illegible and ask the targeted question above instead of guessing.
Claim instructions. While extracting, also capture HOW to get the offer if
the input says so — signup URL, promo code, CLI command, plan tier, eligibility
restrictions (region/student/new-user), or usage limits. Record them verbatim
in the Step 5 presentation as a "How to claim" list; they feed the optional
offers/details/<slug>.json claim_steps enrichment and the tracking issue
body. Claim instructions never enter the seven YAML fields — they are
supporting evidence only, and like everything else they are never invented:
if the input is silent on how to claim, omit the section entirely.
X source posts become embedded evidence. If source_url (or any cited
evidence in the input) is an x.com/twitter.com post, ALWAYS create the detail
file offers/details/<slug>.json alongside the YAML with a social_proof
entry of type x so the post renders as a quote card on the offer's detail
page — never leave an X-sourced offer without it. Fill url from the post;
fetch author/handle/text automatically from Twitter's public oEmbed
endpoint (https://publish.twitter.com/oembed?url=<post-url>) rather than
asking the curator to copy-paste. The oEmbed response's author_name maps to
author (@author_name → handle); take text from the post content you
already fetched in Step 2 (oEmbed returns HTML, not plain text). If oEmbed is
unreachable, fall back to the text captured during verification. This embeds
the post statically at build time (no third-party scripts, per
docs/schema.md) and preserves the evidence if the post is later deleted.
Add summary and claim_steps to the same file when you have them.
Step 2 — Verify on the web (trust policy)
Verification is on by default; the curator may explicitly say
"skip verification", in which case verified_date keeps today's date ONLY if
the input itself is first-hand evidence (a fresh screenshot), and every field
the page would have confirmed must be reported as unverified in Step 6.
When verification runs:
- Fetch
source_url. If the curator supplied none, ask for it — an offer without an official source is unpublishable. - Render one of three verdicts, quoting the sentence(s) that prove it:
- live — offer currently claimable and terms match the extracted
values →
verified_date: <today>, and record the quoted evidence as the comment header's source note. - expired / dead URL — page gone, offer withdrawn, or dates passed →
do NOT create or update any
offers/file. - unverifiable — page unreachable, bot-walled, or silent about the claimed terms → treat as expired/dead above until proven otherwise.
- live — offer currently claimable and terms match the extracted
values →
- Any offer that is not live is staged as
needs_review/<slug>.yamlwith a header note explaining what failed. Nothing unverifiable is ever committed —needs_review/is gitignored precisely so a straygit add .cannot leak an unverified entry into the site. - Conflicts between the screenshot/input and the web page (different amounts, expiry dates, eligibility) are surfaced side-by-side in a small table — input claim vs page quote vs proposed resolution — and REQUIRE an explicit human decision before any file is written. Never silently pick a winner.
Reference trace — keep every URL you touch (mandatory)
During verification you will inevitably fetch more than just source_url:
redirects, docs pages, pricing pages, announcement blog posts, changelog
entries, or a secondary search result that confirms eligibility. Keep a
trace of every relevant URL you visit and persist it as evidence — never
discard the chain of sources that justified the verdict.
Rules:
- Collect as you go. Start the trace with
source_url. Append every additional URL you actually fetched whose content informed the verdict (HTTP 200 and contains terms you quoted or relied on). Skip dead links, bot-wall pages, and incidental search-engine result pages that added nothing. - Capture title + excerpt. For each URL, record
title(page<title>or firsth1, ≤200 chars) and a shorttextexcerpt (the quoted sentence that proves the offer, ≤500 chars). These map directly tosocial_prooflinkfields inoffers/details/<slug>.json— seedocs/schema.mdandschemas/offer-detail.schema.jsonfor limits. - Deduplicate and cap. Normalize URLs (strip fragments, trailing
slashes), deduplicate, keep
source_urlfirst, then discovery order. Hard cap at 10 entries total forsocial_proof(schema limit); if the trace would exceed 10, keepsource_url+ the 9 most authoritative provider-domain pages and drop aggregators/third-party mirrors first. X/Reddit posts remain typex/reddit— only generic pages use typelink. - Persist in the detail file. The trace lives in
offers/details/<slug>.jsonundersocial_proofas entries of typelink(orx/redditwhere applicable). If a detail file already exists, merge: preserve existingsummary/claim_steps, append new trace entries that are not already present (compare normalizedurl), and never duplicate the same URL. If no detail file exists, create one with the trace as itssocial_proof(at least one entry is enough to satisfyminProperties: 1). - Evidence only. Every traced URL must be one you fetched and verified. Never invent titles, excerpts, or URLs. If a fetch failed, do not add it.
- Why this exists. The reference trace is the audit trail that lets any
future curator re-verify the offer without re-discovering sources, and it
satisfies
docs/schema.md"Evidence only" forsocial_proof.
The Step 3 normalizer and Step 5 presentation both consume this trace.
Step 3 — Normalize
Slug = lowercase ASCII words separated by single hyphens
(^[a-z0-9]+(-[a-z0-9]+)*$), matching the target filename
offers/<slug>.yaml. Draft template (written to needs_review/<slug>.yaml
until Step 6):
# Verified <YYYY-MM-DD> against <source_url>
# ("<short quote proving the offer text>")
title: ...
provider: ...
category: ... # api_provider | coding | image | voice | video
amount: ...
expiry_date: null # or YYYY-MM-DD
source_url: https://...
verified_date: YYYY-MM-DD
The comment header is mandatory curation evidence: quote the sentence(s) from
Step 2 that prove title/amount/expiry. Optional enrichment (summary, claim
steps, social proof / reference trace) lives in offers/details/<slug>.json — see
docs/schema.md for its rules. When a reference trace was collected in Step 2,
the detail file MUST contain it as social_proof entries of type link
(or x/reddit for social posts), merged as described above. Example detail
with a reference trace:
{
"summary": "Cerebras grants $20 in inference credits for new signups.",
"claim_steps": ["Create an account at inference.cerebras.ai.", "Credits apply automatically at signup."],
"social_proof": [
{
"type": "link",
"url": "https://inference.cerebras.ai/policies/credits",
"title": "Cerebras Inference Credits Policy",
"text": "New users receive $20 in free inference credits upon signup."
},
{
"type": "link",
"url": "https://cerebras.ai/blog/announcing-free-credits",
"title": "Announcing Free Inference Credits",
"text": "We are offering $20 in free credits to try Cerebras Inference."
}
]
}
Step 4 — Validate (deterministic, same rules as CI)
python3 .claude/skills/offer-updater/validate_offer.py <draft.yaml>
# also validate the detail file if one was created/updated:
python3 scripts/build.py --help # validates offers/details/*.json as part of the build
Exit 0 + OK means the file is byte-for-byte compliant with what CI
enforces — it cannot fail the build. Any failure names the offending file and
field; fix ONLY formatting/validation errors here. If fixing would require
inventing a value, go back to Step 1's clarifying-question rule instead.
For detail files, watch the social_proof limits: ≤10 entries, url
≤200 chars, title ≤200 chars, text ≤500 chars — the validator reports the
exact offending index.
Step 5 — Present the diff
Show the curator exactly what would change, no more and no less:
git diff --no-index -- <existing-file-if-any> needs_review/<slug>.yaml # updates
# and for the detail file:
git diff --no-index -- offers/details/<slug>.json needs_review/details/<slug>.json # if new or updated
plus the full draft content for brand-new offers (and the detail JSON if one
was created). State plainly: the target
path (offers/<slug>.yaml or offers/details/<slug>.json), whether it is a
new file or an edit, and the verification verdict + evidence quote. If claim
instructions were extracted, include them under a "How to claim" heading.
Always list the reference trace under a "References verified" heading —
each URL with its title and the quoted excerpt — so the curator can see the
audit trail before approving.
Step 6 — Commit gate (hard rule)
Nothing is committed, moved into offers/, pushed, or opened as a PR
without the curator's explicit yes.
- Acceptable confirmation: a clear affirmative from the curator in the conversation ("yes", "commit it", "ship it") AFTER seeing the Step 5 diff. Silence, topic change, or ambiguity is a NO.
- On YES: move the draft into
offers/(git mvfor edits), stage the matchingoffers/details/<slug>.jsonif one was created or updated (it now carries the reference trace), run the validator once more on its final path (validate_offer.pyfor the YAML andpython3 scripts/build.pyorpython3 scripts/validate_offers.pyfor the detail JSON), then followCONTRIBUTING.md:- Create the tracking issue with
gh issue create(unless the curator supplied one). Title:Add <provider> <short offer name>. Body: provider, what is free, amount, expiry, source URL, the full References verified list (one bullet per traced URL with title), and any extracted "How to claim" steps, plus the verification date. - Branch
<type>/<issue>-<slug>from currentmain. - Commit with a Conventional Commits message referencing the issue, e.g.
feat(offers): add <Provider> offer (#<issue>), and push. - Open the PR whose body starts with
Closes #<issue>, include the verification verdict + evidence quote, the References verified list, and the local-check results (validator,scripts/build.py, test suite).
- Create the tracking issue with
- On NO / no answer / unverifiable: leave the draft in
needs_review/, summarize why (including which reference URLs were checked and why they failed), and stop. Re-running the skill later resumes from Step 2.
Why this gate exists
The directory's entire value is trust: every listed offer was verified by a
human against a live official page (§9.3 link-rot/scam mitigation). An agent
that auto-commits unverified entries converts one dead URL into a broken
promise to every visitor. When in doubt, park it in needs_review/ and ask.