Backend Worker routes implemented in implementation/workers/index.ts. All
JSON responses use Content-Type: application/json unless noted.
Base URL examples:
- Local:
http://localhost:8787 - Production (hosted):
https://common-thread-backend.skyphusion.org - Other deployments:
https://<your-worker>.workers.dev
The production API at https://common-thread-backend.skyphusion.org is operated by the project author for the public web UI and approved integrations. It is not a general-purpose open API for third-party projects.
If you want to call the hosted API from your own application, whether from a browser, a backend service, or a script that runs as part of your product, contact the operator first before building against it:
- Email: common-thread@skyphusion.org
- Or open a GitHub issue (see contact.md)
Include a short description of your project, expected traffic, and (for browser apps) the origin URL(s) you need allowlisted.
You do not need permission to self-host the reference implementation (AGPL-3.0). Deploy your own backend Worker and point your client at that instance instead. See SETUP.md and DEPLOYMENT.md.
The API is intended for server-side clients (curl, scripts, backend
integrations) on your own deployment, or on the hosted API after the
operator has approved your use (see Using the hosted API).
Browser JavaScript on another website can only call the API if the page origin
is listed in the Worker's CORS_ALLOWED_ORIGINS var
(comma-separated exact origins, e.g. https://app.example.com).
| Client | CORS applies? |
|---|---|
curl, Python, Node (no Origin header) |
No, but hosted use still requires prior contact |
| Web UI via service binding | No, Worker-to-Worker, not browser CORS |
| Browser app on another domain | Yes, origin must be allowlisted |
Unknown browser origins receive 403 with code: cors_forbidden. Approved
origins are added to CORS_ALLOWED_ORIGINS in production wrangler.toml after
you have been in touch (see contact.md).
POST /investigations (save access_token from response)
→ POST /investigations/:id/seeds (optional; ingest also registers seeds)
→ POST /investigations/:id/ingest/apify
→ POST /investigations/:id/ingest/apify-twitter
→ POST /investigations/:id/ingest/apify-instagram
→ POST /investigations/:id/ingest/apify-reddit
→ POST /investigations/:id/ingest/apify-bluesky
→ GET /investigations/:id/ingest-jobs/:job_id (poll until completed)
→ GET /investigations/:id/features (verify extractors ran)
→ POST /investigations/:id/attribute (requires AI credentials; see BYOK)
→ GET /investigations/:id/runs
→ GET /investigations/:id/packet/:run_id?format=pdf
→ POST /investigations/:id/seal (optional; read-only thereafter)
All routes under /investigations/:id require the investigation capability
token returned at creation (see Investigation access).
There is no public listing of investigations.
With Workers VPC configured (VPC_INGEST, INGEST_WORKER_URL, and
INGEST_SECRET), ingest and PDF rendering run on self-hosted containers (containers/ingest-worker/, containers/pdf-worker/).
Without VPC, ingest runs the full archive + extraction pipeline inline in the
Worker (suitable for local dev only on small exports).
Health check.
Response 200
{
"name": "common-thread",
"version": "0.5.0",
"environment": "production",
"status": "ok",
"hosted_api_notice": "The hosted API is not open for unsolicited third-party use. Contact common-thread@skyphusion.org before integrating it into your project.",
"contact": "common-thread@skyphusion.org"
}In non-production environments the response omits hosted_api_notice and
contact. Self-hosted deployments do not include these fields unless
ENVIRONMENT is set to production.
Investigations are private by default. Each investigation receives an unguessable capability token at creation. The server stores only a SHA-256 hash of the token; the plaintext is returned once in the create response and cannot be recovered later.
Pass the token on every request scoped to an investigation (/investigations/:id/...,
GET /manifest?investigation=:id, GET /signatures?investigation=:id, and
GET /verify?investigation=:id).
| Method | Header |
|---|---|
| Preferred | Authorization: Bearer ct_… |
| Alternate | X-Investigation-Token: ct_… |
Do not put the token in the query string. Query tokens leak via logs, Referer, and history.
Responses
| Code | code field |
Meaning |
|---|---|---|
401 |
missing_token |
No token provided |
401 |
invalid_token |
Wrong token or unknown investigation |
403 |
read_only |
Investigation is sealed (or archived); mutating routes rejected |
404 |
not_found |
A job or run id does not exist (investigation misses are invalid_token) |
429 |
create_cap_exceeded |
Too many POST /investigations from this client |
Status and writes
status |
Read (GET) | Write (POST, DELETE, ingest, attribute) |
|---|---|---|
active |
Allowed with token | Allowed with token |
sealed |
Allowed with token | Rejected (read_only) |
archived |
Allowed with token | Rejected (read_only) |
Seal an active investigation with POST /investigations/:id/seal. Sealing is
intended for “investigation complete”, review and export evidence packets, but
no further ingest or attribution.
Security expectations (public hosting)
Capability tokens stop casual browsing and ID guessing. They are not
passwords: anyone with the token can read the investigation (and modify it while
active). Tokens in share links or browser storage can leak via history,
referrers, or device compromise. For high-sensitivity work, self-host the
backend or add stronger access controls (for example Cloudflare Access).
Disabled. Returns 404 with code: listing_disabled. Investigations are
not enumerable.
Create an investigation. Does not require a token.
id must match ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$ (1-64 chars). Quote, slash,
and control characters are rejected (400 invalid_investigation_id). Creates
are capped per client IP (429 create_cap_exceeded; default 20 per 10 minutes,
MAX_INVESTIGATION_CREATES_PER_WINDOW).
Body
{
"id": "my-investigation-1",
"name": "Investigation title",
"description": "Optional description"
}Response 201
{
"id": "my-investigation-1",
"name": "Investigation title",
"description": "Optional description",
"status": "active",
"created_at": "2026-06-18T12:00:00.000Z",
"access_token": "ct_…",
"access_notice": "Store access_token securely. It is shown only at creation and cannot be recovered."
}Store access_token immediately. The web UI can bookmark it in
localStorage on the user's device; the server never returns it again.
Fetch investigation metadata. Requires capability token.
Response 200
{
"investigation": {
"id": "my-investigation-1",
"name": "Investigation title",
"description": "Optional description",
"status": "active",
"created_at": "2026-06-18T12:00:00.000Z",
"updated_at": "2026-06-18T12:00:00.000Z"
}
}Update practitioner-controlled metadata (paper §4.2.2, §5.2.1). Requires
capability token. Requires status: active.
JSON body (all fields optional; omitted fields are unchanged):
| Field | Description |
|---|---|
triggering_events |
Array of { id, timestamp, description?, platform_post_id? } for response-latency extractors |
time_bounds |
{ start, end, justification } ISO 8601 window, or null to clear |
Response 200: { investigation: { … }, metadata: { triggering_events?, time_bounds? } }
Response 400: validation error (invalid ISO timestamps, missing justification).
Mark an investigation read-only. Requires capability token. Idempotent when already sealed.
Response 200
{
"investigation": {
"id": "my-investigation-1",
"status": "sealed",
"updated_at": "2026-06-18T14:00:00.000Z"
},
"message": "Investigation sealed. Data remains readable with the access token; ingest and attribution are disabled."
}Hard-delete an active investigation. Requires write-capable capability token.
Refused when status is sealed or archived (immutable evidence records).
Policy:
- Removes all MySQL rows for the investigation (seeds, features, runs, jobs).
- Deletes per-investigation R2 manifest and signature sidecars under
investigations/{id}/. - Retains content-addressed
sha256/blobs (global deduplicated storage; may be shared across investigations).
Response 200
{
"investigationId": "my-investigation-1",
"deleted": true,
"tables_purged": ["account_features", "..."],
"archive_keys_deleted": ["investigations/my-investigation-1/manifest.jsonl"],
"archive_policy": "Content-addressed sha256/ blobs are retained (global deduplicated storage)."
}Response 403: investigation not active.
Response 404: investigation not found after purge attempt.
Active seed count and manifest artifact count. Requires capability token.
All seed routes require the investigation capability token. POST and DELETE
require status: active (not sealed).
List active seeds (removed_at IS NULL).
| Query | Description |
|---|---|
includeRemoved=true |
Include soft-deleted seed rows |
Add a seed account.
Body
{
"platform": "twitter",
"account": "handle",
"basis_statement": "Why this account is in the seed set (§5.1.1)",
"is_control": false,
"added_by": "api"
}basisStatement is accepted as an alias for basis_statement.
Response 201
Response 400 seed_cap_exceeded: active seed count is already at
MAX_SEED_ACCOUNTS (default 50; wrangler var). Body includes limit and
attempted. Re-adding an existing active seed is not blocked.
Soft-delete active seeds for a platform + account (sets removed_at,
removed_reason; row preserved for audit).
Body
{
"platform": "twitter",
"account": "handle",
"removed_reason": "Optional reason"
}Response 200: { removed_at, removed_count, ... }
Response 404: no active seed for that pair.
Requires capability token.
Query extracted features from MySQL.
| Query | Description |
|---|---|
account |
Filter by account identifier |
platform |
Filter by platform (account/event scope) |
pair |
accountA,accountB (canonicalized) |
accountA + accountB |
Alternate pair filter syntax |
category |
Feature category, e.g. stylometric, network |
scope |
account, pair, event, or all (default) |
includeProvenance=true |
Attach artifact_hash provenance rows |
Response 200
{
"investigationId": "...",
"filters": { ... },
"account_features": [ ... ],
"pair_features": [ ... ],
"event_features": [ ... ],
"count": { "account": 0, "pair": 0, "event": 0, "total": 0 }
}Supported actors (upload their dataset JSON):
| Platform | Typical actors | Pipeline |
|---|---|---|
| Twitter / X | apidojo/tweet-scraper, profile/user scrapers |
existing Twitter extractors |
apify/instagram-scraper, apify/instagram-post-scraper, apify/instagram-profile-scraper |
stylometric + temporal Instagram + shared pair extractors | |
trudax/reddit-scraper-lite (and native listings) |
stylometric + temporal + Reddit account-metadata + shared pair extractors | |
| Bluesky | fatihtahta/All-In-One-Bluesky-Scraper, cryptosignals/bluesky-scraper (AT Proto text / createdAt / authorHandle) |
stylometric + temporal Bluesky + shared pair extractors |
| Mastodon | newpo/mastodon-scraper (public API text / createdAt / author.acct; mastodon.social plus acct/instance hints, not every federated host) |
stylometric + temporal Mastodon + shared pair extractors |
| TikTok | clockworks/tiktok-scraper (caption text / createTimeISO / authorMeta.name / webVideoUrl) |
stylometric + temporal TikTok + shared pair extractors |
| YouTube | streamers/youtube-scraper + streamers/youtube-comments-scraper (titles, descriptions, and comments as text; not a tweet-like timeline) |
stylometric + temporal YouTube + shared pair extractors |
apify/facebook-posts-scraper (public-page text / time / user.name; no cookies, no personal profiles) |
stylometric + temporal Facebook + shared pair extractors |
LinkedIn, Telegram, and Discord stay 400 unsupported_export unless added later.
Requires capability token. Requires status: active.
Auto-detects Twitter, Instagram, Reddit, Bluesky, Mastodon, TikTok, YouTube, and Facebook items (mixed uploads split).
Archives raw JSON and runs the matching extractor pipeline (container when
VPC_INGEST, INGEST_WORKER_URL, and INGEST_SECRET are configured;
inline otherwise).
Requires capability token. Requires status: active.
Upload an Apify Twitter export. Always archives raw JSON and runs the full
extractor pipeline (container when VPC_INGEST, INGEST_WORKER_URL, and
INGEST_SECRET are configured; inline otherwise).
Same contract as Twitter, forced Instagram parse (official Apify Instagram scraper / post scraper / profile scraper JSON).
Same contract as Twitter, forced Reddit parse (native listings or
trudax/reddit-scraper-lite rows).
Same contract as Twitter, forced Bluesky parse (text + createdAt +
authorHandle / author.handle, at:// uri, bsky.app url).
Same contract as Twitter, forced Mastodon parse (text + createdAt /
created_at + author.acct, mastodon.social url or /users/.../statuses/
uri). Federated instance hosts are not treated as known unless the row
carries acct/instance/tool hints.
Same contract as Twitter, forced TikTok parse (text caption + createTimeISO
/ createTime + authorMeta.name + webVideoUrl).
Same contract as Twitter, forced YouTube parse. Authored text is video titles
plus descriptions plus comments (streamers/youtube-scraper and
streamers/youtube-comments-scraper). Comments without a parseable timestamp
still contribute stylometric text.
Same contract as Twitter, forced Facebook public-page parse (text + time /
timestamp + user.name / pageName + facebookUrl). Personal profiles,
session cookies, likes, friends lists, and emails are out of scope.
Content types
application/json, array of items, or{ "items": [...] }/{ "data": [...] }multipart/form-data, one or morefilefields containing JSON
Response 400 ingest_cap_exceeded: item count exceeds MAX_INGEST_ITEMS
(default 5000 per request; wrangler var). Split the upload.
Response 202: job delegated to ingest container (delegatedToContainer: true).
Response 200: inline pipeline completed.
Response includes jobId for status polling.
Requires capability token.
Poll ingest job status. Status reads bypass Hyperdrive query cache (fresh
transaction) so completed is visible as soon as the job finishes.
Response 200: { job: { status, item_count, manifest_hashes, error_message, ... } }
(error_message is sanitized; SQL/driver strings are replaced with a generic line.)
Requires AI_GATEWAY_URL and ANTHROPIC_API_KEY on the Worker.
Requires capability token. Requires status: active.
Run attribution for all active seed pairs (or a filtered subset).
Requires Anthropic credentials via server secrets or request BYOK (see below).
| Query / body | Description |
|---|---|
skipTriage=true |
Skip triage model |
accountFilter=a,b |
Restrict to listed accounts |
maxRetries |
Reasoning retry cap (default 3) |
randomizationSeed |
Reproducible signal-table shuffle (§7.4.1) |
Response 400 pair_cap_exceeded: canonical pair count
(n*(n-1)/2 over active seeds, or the accountFilter subset) exceeds
MAX_ATTRIBUTION_PAIRS (default 1225 = C(50,2); wrangler var). Checked
before credential resolution / LLM work. Narrow the filter or raise the var.
Bring-your-own-key (BYOK): for public deployments where the host
does not supply API keys (PUBLIC_BYOK_ONLY):
| Source | Fields |
|---|---|
| Headers | X-AI-Gateway-Url, plus either X-Anthropic-Api-Key or X-CF-AIG-Token |
| JSON body | aiGatewayUrl / ai_gateway_url, plus either anthropicApiKey / anthropic_api_key or cfAigToken / cf_aig_token |
Use https://api.anthropic.com as the gateway URL for direct Anthropic API
access (with an Anthropic API key), or a Cloudflare AI Gateway base URL ending
in /anthropic (with either an Anthropic key or an AI Gateway Run token for
keyless Unified Billing). All request-supplied credentials must come from the
same source (no env backfill).
Request credentials override server secrets when provided. Keys are used only for the attribution call and are not persisted.
Response 200: { investigationId, pair_count, credential_source, runs: [...] } per-pair summaries.
Response 202 (VPC with attribution executor, server credentials only):
{ investigationId, jobId, status, mode: "async" }. Poll
GET /investigations/:id/attribution-jobs/:job_id for completion. BYOK
requests and environments without the executor use the synchronous 200 path.
Requires capability token.
Poll async attribution job status.
Response 200: { job: { status, pair_count, error_message, … } }
(error_message is sanitized; SQL/driver strings are replaced with a generic line.)
Requires capability token.
List attribution runs (summary fields only).
Alias: GET /investigations/:id/attribution-runs
Requires capability token.
Single attribution run with parsed output object (claims, alternatives,
declined pairs, triage, methodology metadata). Does not include raw
output_json string.
Requires capability token.
Build an investigation-level evidence packet from the latest attribution run (convenience route; same JSON shape as the per-run packet below).
| Query | Response |
|---|---|
| (default) | JSON packet |
format=markdown |
text/markdown body |
practitioner |
Practitioner identity string for cover metadata |
redact=false |
Disable control-account pseudonymization |
redact_account |
Repeatable; redact specific account handles |
Response 404: no attribution runs exist for this investigation.
Requires capability token.
Build an evidence packet for one attribution run.
| Query | Response |
|---|---|
| (default) | JSON packet (format_version, cover, narrative, signal_appendix, manifest_extract, methodology_metadata, markdown, packet_signature, …) |
format=markdown |
text/markdown body |
format=pdf |
application/pdf (PDF/A-2b via containers/pdf-worker/; requires PDF_WORKER_URL on VPC) |
Response 503 for format=pdf when PDF container is not configured.
The markdown field is the canonical form of the packet; PDF is a derived view.
Per §8.1.3, when a signing key (SIGNER_PRIVATE_KEY) is configured the default
JSON packet also carries a detached Ed25519 signature over that canonical
Markdown in packet_signature ({ algorithm, publicKey, packetSha256, signedAt, signerId?, signature }); when no key is configured packet_signature is null
and the Markdown can be signed offline with npm run sign:packet -- --key key.txt packet.json (same packet_signature shape as the Worker). Verify an
exported or offline-signed packet with
npm run verify:packet -- packet.json (or pipe the JSON on stdin); it recomputes
the Markdown SHA-256 and checks the signature with no archive or Worker access.
The format=markdown and format=pdf responses are derived views; verify the
signature against the canonical Markdown in the JSON packet (a reproducible PDF
pipeline is required to verify against the PDF, per §8.1.3).
List manifest entries for one investigation. Requires capability token.
| Query | Description |
|---|---|
investigation |
Investigation ID (required) |
List manifest signature records for one investigation. Requires capability token.
| Query | Description |
|---|---|
investigation |
Investigation ID (required) |
Verify manifest signatures for one investigation. Requires capability token.
| Query | Description |
|---|---|
investigation |
Investigation ID (required) |
Development visibility endpoints; not part of the methodology deliverable.
Requires capability token.
Extractor visibility vs manifest entries for an investigation.
Requires capability token.
Raw manifest breakdown (with/without account field).
Not called directly in normal operation; invoked by the Worker over Workers VPC.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/trigger |
Run archive + extraction (IngestJobHandoff) |
Configured via INGEST_WORKER_URL (e.g. http://json-ingest:8080/trigger) and
VPC_INGEST binding. Auth: INGEST_SECRET (required).
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/render |
HTML → PDF/A-2b (PdfRenderHandoff) |
Configured via PDF_WORKER_URL (e.g. http://json-pdf:8081/render) and
VPC_PDF binding (common-thread-pdf). Falls back to VPC_INGEST if
VPC_PDF is not configured. Auth: PDF_SECRET (required; separate from
INGEST_SECRET).
See containers/ingest-worker/README.md and containers/pdf-worker/README.md.
| Route | Notes |
|---|---|
| Token recovery / rotation | Lost tokens cannot be reset; create a new investigation |
| Name | Purpose |
|---|---|
DB |
Hyperdrive → MySQL |
ARCHIVE |
R2 archive bucket |
VPC_INGEST |
Workers VPC → ingest container (json-ingest) |
VPC_PDF |
Workers VPC → PDF container (json-pdf) |
AI_GATEWAY_URL |
Attribution (secret; optional if users BYOK) |
ANTHROPIC_API_KEY |
Attribution (secret; optional if users BYOK) |
INGEST_SECRET |
Container auth (secret; required with VPC_INGEST) |
PDF_SECRET |
PDF container auth (secret; required for ?format=pdf) |
SIGNER_PUBLIC_KEY |
Manifest verification |
SIGNER_PRIVATE_KEY |
In-Worker Ed25519 evidence-packet signing (secret; optional, §8.1.3). When unset, packets export unsigned |
SIGNER_ID |
Optional signer identity recorded in packet signatures (var) |
Vars: INGEST_WORKER_URL, PDF_WORKER_URL, TRIAGE_MODEL, REASONING_MODEL,
CORS_ALLOWED_ORIGINS.