The ingestion API lets a remote agent submit article URLs without using browser admin cookies.
The flow is intentionally two-step:
- Worker proposes where a URL belongs.
- Agent approves the worker proposal.
- Worker validates again, attaches the story, and queues record summary work.
No notification webhook is wired yet. Review pending proposals through the API.
Repo-local agent instructions live in:
skills/massmurdercanada-story-ingest/SKILL.md
Staging-only operator tasks live in Taskfile.yml:
task staging:migrate
task staging:deploy
task staging:secret:ingest-token INGEST_API_TOKEN=...
task staging:secret:rotate-ingest-token
task staging:ingest:create INGEST_API_TOKEN=... INGEST_TEST_URL=...
task staging:ingest:search INGEST_API_TOKEN=... SEARCH_QUERY='city=Calgary&province=AB&date=2026-04-29'
task staging:ingest:approve INGEST_API_TOKEN=... PROPOSAL_ID=... RECORD_ID=... AGENT_CONFIDENCE=... AGENT_REASON=...
task staging:ingest:approve INGEST_API_TOKEN=... PROPOSAL_ID=... RECORD_ID=... AGENT_CONFIDENCE=... AGENT_REASON=... FORCE_APPLY=true
task staging:ingest:reject INGEST_API_TOKEN=... PROPOSAL_ID=... AGENT_CONFIDENCE=... AGENT_REASON=...
task staging:record:delete RECORD_ID=...Apply the schema migration:
npx wrangler d1 migrations apply massmurdercanada --env staging
npx wrangler d1 migrations apply massmurdercanada --env productionSet a machine token secret:
npx wrangler secret put INGEST_API_TOKEN --env staging
npx wrangler secret put INGEST_API_TOKEN --env productionINGEST_API_TOKENS may also be used for a comma-separated list of accepted tokens.
For staging smoke tests, task staging:secret:rotate-ingest-token generates a staging-only token, stores it in /private/tmp/ff-workers-staging-ingest-token, and sets the staging secret without printing the token.
After that, the staging ingest tasks can use the local token file when INGEST_API_TOKEN is not passed.
Optional confidence thresholds:
INGEST_WORKER_MIN_CONFIDENCE, default0.65INGEST_AGENT_MIN_CONFIDENCE, default0.65INGEST_AI_MODEL, default falls back toAI_MODELINGEST_RATE_LIMIT_PER_MINUTE, default60whenAUTH_TOKENSKV is configured
Source fetches use public-URL validation, manual redirect checks, DNS checks, timeouts, and response-size caps. When direct fetches fail or extract too little text, configured third-party fallback readers (r.jina.ai, markdown.new) may be used for ingestion and summaries. These fallbacks can disclose source URLs, and depending on provider behavior, extracted article text to those services.
Ingest endpoints accept:
Authorization: Bearer <INGEST_API_TOKEN>The bearer token works only for /admin/api/ingest/*. Existing admin APIs still require the browser session cookie.
curl -s https://massmurdercanada.org/admin/api/ingest/proposals \
-H "Authorization: Bearer $INGEST_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/article"}'Batch mode:
{
"urls": [
"https://example.com/article-1",
"https://example.com/article-2"
]
}Maximum batch size is 5.
Important response fields:
status:worker_proposed,needs_review,duplicate,applied, orrejectedproposed_action:attach_to_record,create_record,needs_review, orduplicateproposed_record_id: record the Worker thinks the URL belongs toworker_confidence: Worker confidence, 0 to 1worker_reason: short reasoning stringdecision.top_candidates: nearby records considereddecision.proposed_record: new record draft whenproposed_actioniscreate_recorddecision.record_date_basis: whether a proposed new record used an event date/year or publication-year fallbackextracted_facts: incident facts extracted from the source
Only worker_proposed proposals are eligible for normal automatic agent approval. A needs_review proposal may be force-attached only when an agent has independently confirmed the target existing record, sends force_apply: true, and provides a high-confidence evidence-based reason.
Agents can run a read-only structured candidate search before approving a proposal, especially when the Worker proposes create_record or when the agent is uncertain.
curl -s "https://massmurdercanada.org/admin/api/ingest/records/search?city=Calgary&province=AB&date=2026-04-29&victims=2&deaths=2" \
-H "Authorization: Bearer $INGEST_API_TOKEN"Supported query parameters:
q: text to match against record name, city, province, date, or devicescityprovince: province or territory code/nameyeardate:YYYY-MM-DD, month/day/year text, or four-digit yeardate_window_days: exact-date tolerance, default30, maximum366victims,deaths,injuries: integer countslimit: default20, maximum100
At least one search filter is required. Results include compact record fields plus score and reasons; this endpoint never writes records or stories.
curl -s https://massmurdercanada.org/admin/api/ingest/proposals/$PROPOSAL_ID/approve \
-H "Authorization: Bearer $INGEST_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"record_id": "2d8bb859-5b00-4718-9a89-1d12cdf5b892",
"agent_confidence": 0.92,
"agent_reason": "The article names the same incident, location, year, and victim count."
}'The Worker applies only when:
- the proposal is still
worker_proposed attach_to_record: the approved record existscreate_record: the approved record ID matches the Worker-proposed new record ID, or the agent explicitly redirects to an existing record with high confidence and an evidence-based reason- worker and agent confidence meet thresholds
- the URL is not already attached to another story
If these checks fail, the proposal moves to needs_review instead of mutating news_stories.
Matching uses the article's extracted event/death/incident date when available:
- existing records near the source event date are favored
- different years are penalized
- exact or nearby event-date matches are stronger than city/count matches alone
- publication/update dates do not boost existing-record matching
- new-record drafts use the extracted event date when available, otherwise the best supported year
For new-record proposals, bearer-token approval does not accept record-field overrides. The Worker creates the reviewed decision.proposed_record as-is when the agent approves the Worker-proposed record ID.
{
"record_id": "worker-proposed-record-uuid",
"agent_confidence": 0.92,
"agent_reason": "The source describes a new Calgary incident with two child deaths."
}Field corrections should go through the admin UI or a future reviewed record_patch proposal flow.
If the Worker proposes create_record but the agent finds a clearly matching existing record, the agent may approve with that existing record_id instead. The Worker will attach the story to that existing record and audit the decision as an agent redirect. Use this only when the existing record is a strong factual match; otherwise reject or leave for human review.
For already-needs_review proposals, an agent may force-attach the source to an existing record only after structured search or equivalent review confirms the target. This path cannot create records, still requires the target record to exist, still checks duplicate URLs, and records the decision as approve_force_attach.
{
"record_id": "existing-record-uuid",
"force_apply": true,
"agent_confidence": 0.92,
"agent_reason": "The source names the same incident, location, victim count, and later court update."
}curl -s https://massmurdercanada.org/admin/api/ingest/proposals/$PROPOSAL_ID/reject \
-H "Authorization: Bearer $INGEST_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent_confidence": 0.9,
"agent_reason": "The source describes a different incident."
}'Fetch one proposal:
curl -s https://massmurdercanada.org/admin/api/ingest/proposals/$PROPOSAL_ID \
-H "Authorization: Bearer $INGEST_API_TOKEN"List proposals:
curl -s "https://massmurdercanada.org/admin/api/ingest/proposals?status=needs_review&limit=25" \
-H "Authorization: Bearer $INGEST_API_TOKEN"The remote agent should:
- submit URLs to create proposals
- normally approve only
worker_proposedproposals - force-attach
needs_reviewproposals only withforce_apply: true, an existingrecord_id, and a specific evidence-based reason - include
record_id,agent_confidence, andagent_reason - reject clear mismatches
- leave
duplicate, new-record uncertainty, and worker/agent disagreement cases for human review - report newly discovered canonical facts, such as a released suspect name, as a metadata-update candidate instead of silently changing records
- never call the generic story CRUD endpoint directly unless a human explicitly asks it to bypass ingestion review
- Apply
0004_ingest_proposals.sql,0005_story_canonical_urls.sql, and0006_story_canonical_url_backfill.sqlto production D1. - Set
INGEST_API_TOKENorINGEST_API_TOKENSin production. - Consider stricter launch thresholds, for example
INGEST_WORKER_MIN_CONFIDENCE=0.75andINGEST_CREATE_RECORD_MIN_CONFIDENCE=0.8. - Add a Cloudflare WAF/rate-limit rule for
/admin/api/ingest/*in addition to the Worker-side KV limiter. - Smoke test duplicate, existing-record attach, new-record create, reject, and structured search paths in staging first.
- Add or explicitly defer a reviewed
record_patchproposal flow for metadata updates from later reporting, such as changingnamefromUnknownafter police release an accused person's name. - Review
needs_reviewproposals before enabling broad remote-agent usage.