Base URL: https://screensay-api.fly.dev. Interactive docs: /docs; schema: /openapi.json.
format= query asks for Markdown.{"error": {"code": "<snake_case>", "message": "<human readable>"}} (extra keys such as
retry_after_seconds appear on 429s).Authorization: Bearer <api_key>. Keys look like vc_live_<32 hex> (Stripe mode) or
vc_test_<32 hex> (dev billing mode). They are shown once and stored as SHA-256 hashes.ytcontext/2 context documents (the FORMAT_SPEC document: schema_version,
source, narrative, speakers, chapters, segments, beats, utterances, shots, events,
points, text, structures, moments, threads, entities, mentions, relations, sponsors,
index, quality, provenance, served minified with defaulted fields omitted) produced by the
final strategy, and carry X-VCI-Format-Version: 2. With VCI_STRATEGY=v1 (or a job created
with "strategy": "v1") the legacy ContextDoc (video, transcript, timeline, …) is served
with the built-in Markdown rendering and X-VCI-Format-Version: 1. Read the header before parsing.POST /v1/preview, GET /v1/preview/{id}, POST /v1/billing/checkout,
POST /v1/billing/claim, POST /v1/billing/webhook, GET /v1/health, GET /v1/pricing.youtube.com / youtu.be video URLs; webhooks must be https and resolve to public addresses.GET /v1/healthcurl -s https://screensay-api.fly.dev/v1/health
GET /v1/pricingThe pricing table the website renders. Every
plan carries the same keys; a price that does not apply to a plan is null.
{ "currency": "usd",
"metered_unit": "video minutes analyzed, rounded up to 0.1 min; standard and deep are metered separately",
"detail_levels": { "standard": "the deterministic projection …", "deep": "the same analysis as the full evidence store …" },
"ask_cost_standard_minutes": 0.1,
"preview": { "max_seconds": 180, "per_ip_per_hour": 3, "concurrent_per_ip": 1, "cache_days": 7 },
"plans": [
{ "id": "free", "name": "Free preview", "monthly_usd": 0, "included_minutes": 30,
"standard_per_min_usd": null, "deep_per_min_usd": null, "overage_standard_usd": null, "overage_deep_usd": null,
"api_key": false, "description": "…" },
{ "id": "payg", "name": "Pay as you go", "monthly_usd": 0, "included_minutes": 0,
"standard_per_min_usd": 0.12, "deep_per_min_usd": 0.30, "overage_standard_usd": null, "overage_deep_usd": null,
"api_key": true, "description": "…" },
{ "id": "scale", "name": "Scale", "monthly_usd": 199, "included_minutes": 3000,
"standard_per_min_usd": 0.08, "deep_per_min_usd": 0.20, "overage_standard_usd": 0.08, "overage_deep_usd": 0.20,
"api_key": true, "description": "…" } ] }
| plan | monthly | included standard minutes | standard / min | deep / min | overage standard / deep |
|---|---|---|---|---|---|
free | $0 | 30 preview minutes (180 s previews, no key) | — | — | — |
payg | $0 | 0 | $0.12 | $0.30 | — |
scale | $199 | 3,000 | $0.08 | $0.20 | $0.08 / $0.20 |
curl -s https://screensay-api.fly.dev/v1/pricing | jq '.plans[] | {id, monthly_usd, included_minutes, standard_per_min_usd, deep_per_min_usd}'
POST /v1/billing/checkoutBody {"email": str, "plan": "payg"|"scale"} → {"checkout_url": str, "billing_mode": "stripe"|"dev"}.
In Stripe mode this is a Checkout Session in subscription mode with the metered prices; the
success URL is <SITE_URL>/welcome?session_id={CHECKOUT_SESSION_ID}. In dev mode the URL is
<SITE_URL>/welcome?session_id=dev:<email>.
curl -s https://screensay-api.fly.dev/v1/billing/checkout -H 'content-type: application/json' \
-d '{"email":"you@example.com","plan":"payg"}'
POST /v1/billing/claimBody {"session_id": str}. Verifies the Checkout session with Stripe (or accepts dev:<email> in
dev mode), creates/links the account and issues the first API key (shown once). Claiming the
same session twice → 409 already_claimed. In dev mode both this route and /billing/checkout
answer 403 signup_closed unless the deployment sets VCI_PUBLIC_SIGNUP=1; the operator mints
keys with uv run ytcontext keys create --email <email> instead (the DEPLOY_API document).
curl -s https://screensay-api.fly.dev/v1/billing/claim -H 'content-type: application/json' \
-d '{"session_id":"dev:you@example.com"}'
GET /v1/billing/portal (auth)→ {"url": str} — a Stripe customer-portal session (400 dev_billing_mode without Stripe).
curl -s https://screensay-api.fly.dev/v1/billing/portal -H "$H"
POST /v1/billing/webhookStripe → API. Verified with STRIPE_WEBHOOK_SECRET (Stripe-Signature header); bad or missing
signatures are 400 invalid_signature; events are idempotent on their id. Handles
checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.paid,
invoice.payment_failed. Accounts with status canceled, or past_due for more than 7 days, get
402 on every authenticated call.
stripe listen --forward-to https://screensay-api.fly.dev/v1/billing/webhook
standard and deepThere is one analysis. detail chooses how much of it you get back (the FORMAT_SPEC document §3):
standard — the deterministic projection of the evidence store: ≤ 40 000 tokens (o200k) per
15 minutes of video, the eleven-rung degradation ladder applied when needed (doc.ladder_rung,
doc.omitted say what went); no utterances[].words / basis, no frames[].deep — the same rows as the full evidence store: every text kind and structure row, every
mention and speaker event, utterances[].words / basis, events[].frame / basis,
beats[].frames, frames[] (the audit table behind every timestamp), no ladder. ~120–150 K
tokens per 15 minutes on a dense video.Deep does not sample more frames or make more model calls than standard; it is billed per video
minute at the deep rate because the document is three to four times larger. A deep result is
served with frames[] by default (?include_frames=false drops it); standard and deep are
separate cached results for the same video.
POST /v1/analyze (auth)Body:
{ "url": "https://www.youtube.com/watch?v=8GRmLR__OGQ", "detail": "standard",
"strategy": null, "webhook_url": null, "include_frames": false, "metadata": {"ref": "my-id"} }
→ 202 {"job_id", "status": "queued", "video_id", "estimated_seconds"}. If this account already has a
finished job for the same video + detail → 200 {"job_id", "status": "done", "cached": true} (no charge);
if one is queued/running, that job is returned with 202.
When the requested document is already on disk from any account
(cache/<video_id>/v2/<strategy>/doc.json at the requested detail — the strategies write it there),
the worker serves the job from that file without a model call. The job still bills the normal
per-minute rate (the account is paying for the document, not for our compute) and reports
served_from_cache: true.
curl -s https://screensay-api.fly.dev/v1/analyze -H "$H" -H 'content-type: application/json' \
-d '{"url":"https://www.youtube.com/watch?v=8GRmLR__OGQ","detail":"standard"}'
webhook_url (https, public host) receives job.completed / job.failed, signed with the account's
webhook_secret (see Webhook signing). strategy selects the pipeline for this job (final by
default; spec_hybrid, frames_only, single_pass, native_clip are the evaluation variants;
v1 the legacy document); only names in VCI_ALLOWED_STRATEGIES are accepted (400 invalid_strategy).
A job that runs out of Gemini credit is requeued with error: "billing_exhausted: …" and the
workers pause for VCI_BILLING_PAUSE_S; after 3 such attempts it fails. Failure error strings are
stable codes (video_unavailable, download_failed, analysis_failed, account_not_allowed,
worker_failed), never raw tool output.
GET /v1/jobs/{job_id} (auth)curl -s https://screensay-api.fly.dev/v1/jobs/$JOB -H "$H"
status is queued | running | done | failed; billed_minutes is the video length rounded up to
0.1 min once done; result_url is /v1/jobs/{job_id}/result once done; served_from_cache is true
when the document was reused from disk instead of analyzed again.
DELETE /v1/jobs/{job_id} (auth)Removes the job, its stored document and — when no other job of any account still references
the same video id — the video's cache media (video.mp4, audio, frames/, clips/); the cheap
artifacts (metadata.json, transcript.json, subs/, the v2/ documents) stay so a re-analysis
is cheaper. 204 on success; 404 for a job that is not yours; 409 job_active while it is
queued or running. Usage rows (billing history) are kept.
curl -s -X DELETE https://screensay-api.fly.dev/v1/jobs/$JOB -H "$H" -o /dev/null -w '%{http_code}\n' # 204
GET /v1/jobs/{job_id}/result (auth)ytcontext/2 document JSON (schema_v2.to_dict: _legend first, defaults omitted;
frames — the audit table behind every timestamp, part of the deep evidence store — is served
for deep jobs and for jobs created with include_frames: true; ?include_frames=true|false
overrides either way)?format=screenplay → text/markdown: render.render_screenplay(doc, doc.doc.detail) — the
fixed-grammar screenplay of FORMAT_SPEC §4 (header, LEGEND, CAST, ## chapters, ### segments,
=== shot slugs, [bt_N t0–t1] SEE: … beats with the speech, on-screen text, events, points and
moments inside them; every line ends with the {ids} it came from)?format=summary → text/markdown: render.render_summary_card(doc), ≤ 2 000 characters
(~600 tokens): logline, format, speakers, tone, arc, chapters, key points, sponsors, mentioned
entities, top moments, unresolved references, coveragecurl -s https://screensay-api.fly.dev/v1/jobs/$JOB/result -H "$H" \
| jq '.schema_version, .source.title, (.beats|length), (.utterances|length), .provenance.totals'
curl -s https://screensay-api.fly.dev/v1/jobs/$JOB/result -H "$H" | jq '.points[] | select(.resolved|not) | {t, words, target_text}'
curl -s "https://screensay-api.fly.dev/v1/jobs/$JOB/result?format=screenplay" -H "$H" | head -40
curl -s "https://screensay-api.fly.dev/v1/jobs/$JOB/result?format=summary" -H "$H"
For a question-answering model the recommended payload is format=summary + format=screenplay
plus the narrative, chapters, segments, entities, sponsors, points and index.by_time
keys of the JSON (FORMAT_SPEC §3.4; ~18 K tokens for an 8-minute video against ~45 K for the whole
document).
POST /v1/jobs/{job_id}/ask (auth)Body {"question": str} → {"answer", "evidence": [{"t_s", "quote"}], "confidence": "high"|"medium"|"low"}.
Answered server-side by a text-only gemini-3.8-flash call over the screenplay + the document JSON
(frames, meta/provenance stripped). Billed as 0.1 standard minute, charged only when an answer
comes back. If the model provider refuses the call for lack of prepaid credit the answer is
503 {"error": {"code": "capacity_exhausted", "retry_after_seconds": 600}} with a Retry-After
header, and nothing is billed.
curl -s https://screensay-api.fly.dev/v1/jobs/$JOB/ask -H "$H" -H 'content-type: application/json' \
-d '{"question":"Which nightly build version is shown in the settings pane?"}'
GET /v1/videos/{video_id}?detail=standard (auth)The account's finished job for that video + detail (same shape as GET /v1/jobs/{id}), 404 if none.
curl -s "https://screensay-api.fly.dev/v1/videos/8GRmLR__OGQ?detail=standard" -H "$H"
GET /v1/jobs?limit=&cursor= (auth)Newest first. next_cursor is passed back as cursor for the next page.
curl -s "https://screensay-api.fly.dev/v1/jobs?limit=20" -H "$H"
POST /v1/previewBody {"url": str} → 202 {"preview_id", "status": "queued"}. Analyzes only the first 180 s at
standard detail. Limits: 3 fresh previews per IP per hour and 1 concurrent per IP → 429 with
retry_after_seconds. Results are cached by video id for VCI_PREVIEW_RETENTION_DAYS (7): a repeated
URL returns 200 {"preview_id", "status": "done", "cached": true} immediately and does not count
against the limit. After that the sweeper deletes the preview like DELETE /v1/jobs/{id} and
GET /v1/preview/{id} answers 404.
curl -s https://screensay-api.fly.dev/v1/preview -H 'content-type: application/json' \
-d '{"url":"https://www.youtube.com/watch?v=8GRmLR__OGQ"}'
GET /v1/preview/{preview_id}{ "preview_id": "prv_…", "status": "done", "progress": {"stage": "done", "pct": 100}, "video_id": "…",
"result": { "_legend": "…", "schema_version": "ytcontext/2", "doc": {"detail": "standard", …},
"source": {…}, "narrative": {…}, "speakers": […], "chapters": […], "beats": […], … },
"screenplay": "# … — standard\nLEGEND: …\nCAST: …", "summary": "# …",
"transcript_only_comparison": { "questions": [
{"q": "…", "transcript_only_answer": "Not determinable from the transcript.", "with_video_answer": "…"} ] },
"error": null, "max_seconds": 180, "analyzed_seconds": 180, "duration_s": 2595.0 }
analyzed_seconds is how much of the video was analyzed (the first 180 s, or the whole video when
it is shorter); duration_s and the document's source.duration_s (video.duration_s for v1) are
the full video's length, so a caller can say "the first 3:00 of a 43:15 video". max_seconds
is the configured cap and is kept for older clients.
The response carries X-VCI-Format-Version: 2 once the job is done (1 while queued/running or
failed, when there is no document). Progress stages for the final strategy are measure,
windows, extract (10–70 %), merge, synthesis, assemble, done. transcript_only_comparison
is three auto-generated questions the transcript alone cannot answer; each has an answer produced
from the transcript only (the utterances[] text) and one from the full document (two extra text-only
model calls, cached with the preview; null if generation failed).
curl -s https://screensay-api.fly.dev/v1/preview/$PRV | jq '.status, .progress, .transcript_only_comparison'
POST /v1/monitors (auth){ "channel_url": "https://www.youtube.com/@t3dotgg", "webhook_url": "https://example.com/hooks/vci",
"detail": "standard", "include_result": true,
"filters": {"min_duration_s": 300, "max_duration_s": null, "title_regex": null} }
→ 201 {"id", "channel_id", "channel_title", "webhook_url", "secret" (shown once), "poll_interval_s": 900, "created_at", …}.
Accepted channel forms: /@handle, /channel/UC…, /c/name, /user/name (resolved to the /videos
tab). The uploads that exist at creation time are recorded as the baseline; only videos published
afterwards trigger analysis. Every poll_interval_s the poller lists the newest 20 uploads with
yt-dlp --flat-playlist --playlist-end 20, analyzes unseen ids that pass the filters (billed normally),
then delivers video.analyzed.
curl -s https://screensay-api.fly.dev/v1/monitors -H "$H" -H 'content-type: application/json' \
-d '{"channel_url":"https://www.youtube.com/@t3dotgg","webhook_url":"https://example.com/hooks/vci"}'
GET /v1/monitors, GET /v1/monitors/{id}, DELETE /v1/monitors/{id} (auth)curl -s https://screensay-api.fly.dev/v1/monitors -H "$H"
curl -s -X DELETE https://screensay-api.fly.dev/v1/monitors/$MON -H "$H"
POST /v1/monitors/{id}/test (auth)Sends a signed sample video.analyzed event synchronously and returns the outcome:
curl -s -X POST https://screensay-api.fly.dev/v1/monitors/$MON/test -H "$H"
GET /v1/monitors/{id}/deliveries (auth)The last 100 deliveries with attempts, status codes, errors and next_retry_at.
Every delivery is POST webhook_url with:
Content-Type: application/json
X-VCI-Event: video.analyzed | job.completed | job.failed
X-VCI-Delivery: <uuid>
X-VCI-Timestamp: <unix seconds>
X-VCI-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>
secret is the monitor's secret for video.analyzed, or the account's webhook_secret
(GET /v1/account) for job events. Verify in Python:
import hmac, hashlib, time
ts = request.headers["X-VCI-Timestamp"]
expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers["X-VCI-Signature"]) and abs(time.time() - int(ts)) <= 300
Reject deliveries whose X-VCI-Timestamp is more than 300 s from your clock (a captured delivery is
otherwise replayable), and de-duplicate on X-VCI-Delivery: retries reuse the same id.
ytcontext.api.webhooks.verify(secret, ts, body, signature) does both checks. Monitors also
receive video.failed ({"event","monitor_id","job_id","video":{id,url,title},"error","retry_at"})
when an upload's analysis fails; the upload is retried after retry_at (twice, then given up).
Bodies:
{ "event": "video.analyzed", "monitor_id": "mon_…",
"video": {"id", "url", "title", "published_at", "duration_s"},
"job_id": "job_…", "result_url": "/v1/jobs/job_…/result", "result": { … } | null }
{ "event": "job.completed", "job_id": "job_…", "video_id": "…", "status": "done",
"result_url": "/v1/jobs/job_…/result", "billed_minutes": 43.3 }
{ "event": "job.failed", "job_id": "job_…", "video_id": "…", "error": "RuntimeError: …" }
Non-2xx responses and transport errors are retried: initial attempt + 5 retries at 1 m / 5 m / 30 m /
2 h / 12 h, then the delivery is marked failed.
GET /v1/account (auth)curl -s https://screensay-api.fly.dev/v1/account -H "$H"
retention_days is how long finished jobs (document + cache media) are kept after they finish
before the sweeper deletes them (0 = until you delete them); preview_retention_days the same for
free previews.
POST /v1/keys, GET /v1/keys, DELETE /v1/keys/{id} (auth)curl -s -X POST https://screensay-api.fly.dev/v1/keys -H "$H" # {"id":"key_…","key":"vc_test_…","prefix":"vc_test_ab12","created_at":"…"}
curl -s https://screensay-api.fly.dev/v1/keys -H "$H" # {"keys":[{"id","prefix","created_at","last_used_at","revoked_at"}]}
curl -s -X DELETE https://screensay-api.fly.dev/v1/keys/$KEY_ID -H "$H"
At most 20 active keys per account. A revoked key fails with 401 invalid_key.
GET /v1/usage?from=&to= (auth)curl -s "https://screensay-api.fly.dev/v1/usage?from=2026-09-01&to=2026-09-30" -H "$H"
The metered unit is video minutes analyzed, rounded up to 0.1, tracked separately for standard
and deep; each completed job sends one Stripe meter event (idempotency key = job id).
data/results/, and — when no other job of any account references the same video id — the media
in cache/<video_id>/ and cache/_preview/<video_id>/ (video.mp4, *.wav, frames/,
clips/, audio_chunks/). metadata.json, transcript.json, subs/, audio_transcript.json
and the v2/ documents are never deleted by retention; usage rows are never deleted.VCI_JOB_RETENTION_DAYS (default 90; 0 = keep forever). A finished or
failed job older than that (by finished_at, else created_at) is deleted by the sweeper exactly
as DELETE /v1/jobs/{id} would. GET /v1/account reports the number as retention_days.VCI_PREVIEW_RETENTION_DAYS (default 7). Expired preview rows are purged
and a preview job nobody references any more is deleted the same way.VCI_RETENTION_SWEEP_INTERVAL_S (default 3600): expired jobs, expired previews, then the cache cap.
Every removal is logged (vci.retention: job id, video id, what was removed, MB freed).VCI_CACHE_MAX_GB (default 20). After every job, and on every sweep, when
cache/ is above the cap the media of the least-recently-used videos (last use = the newest of
the directory's mtimes and the newest job that referenced the video; videos with a queued or
running job are never touched) is removed until the cache is under the cap. 0 disables the cap.VCI_KEEP_MEDIA=0: a job's video media is removed the moment the job finishes (unless another
job for the same video is queued or running). The next job for that video downloads it again.| status | code | when |
|---|---|---|
| 400 | invalid_url, unsupported_url | not a youtube.com / youtu.be video URL |
| 400 | invalid_webhook_url | not https, or the host resolves to a private/loopback/link-local address |
| 400 | invalid_channel_url, channel_unavailable | bad channel URL / yt-dlp could not list it |
| 401 | unauthorized, invalid_key | missing, malformed, unknown or revoked key |
| 402 | subscription_canceled, payment_past_due | billing status blocks the account |
| 404 | not_found | job/monitor/key that is not yours or does not exist |
| 409 | not_ready, already_claimed, job_active | result requested before the job finished; session claimed twice; DELETE on a queued/running job |
| 413 | payload_too_large | body > 64 KB |
| 422 | validation_error | body shape is wrong |
| 429 | rate_limited, preview_rate_limited, preview_in_progress | see retry_after_seconds / Retry-After |
The mcp/ package in the repository exposes the API as tools for Claude Code, Claude Desktop, Cursor and any other MCP client: watch_video(url, detail) submits a URL, waits, and returns the summary card, the screenplay and the job id; get_video_context(job_id, format) re-fetches an analysis as screenplay, JSON or summary; ask_video(job_id, question) answers with timestamped evidence, billed 0.1 minute; list_recent(limit) lists recent analyses. Re-watching an analyzed URL is instant and free.
Build it once with bun install && bun run build in mcp/ (Node 20 or newer), then register it. It reads nothing from disk and sends the key only as a bearer token to VCI_API_URL.
claude mcp add screensay \
-e VCI_API_KEY=vc_live_... \
-e VCI_API_URL=https://screensay-api.fly.dev \
-- node /path/to/yt-context/mcp/dist/index.js{
"mcpServers": {
"screensay": {
"command": "node",
"args": ["/path/to/yt-context/mcp/dist/index.js"],
"env": { "VCI_API_KEY": "vc_live_...", "VCI_API_URL": "https://screensay-api.fly.dev" }
}
}
}--http --port 3333 serves the same tools over Streamable HTTP for a remote deployment; put your own auth in front of it.