OverviewReplay Vision

Replay Vision

AI scanners that watch your clients' session recordings with your own model key (OpenRouter or Gemini) and turn what they see into structured, queryable answers, on /client/v1/replay-vision with an agency key.

Overview

A scanner is a saved question about recordings: "did the visitor hit a broken form?", "what did they come to do?", "how engaged were they?". Mythic renders each recording to a video, sends it with a compact event timeline to your model, and stores the answer as an observation. Every answer is also sent through ingestion as a $recording_observed event, so you can count, chart and filter on it like any other event.

You pay your model provider directly. Mythic does not meter model spend.

RouteWhat it does
GET /keys, PUT /keys, DELETE /keysThe agency's model key and per-client overrides
GET /modelsModels that can watch video and return structured answers
/scannersCreate, list, change and delete scanners
POST /scanners/{id}/scanScan up to 50 recordings now
GET /scanners/{id}/observationsOne scanner's answers
GET /observationsEvery scanner's answers for a client or one recording
GET /observations/{id}, POST /observations/{id}/retryOne answer, or retry a failed one

The same operations are MCP tools (list_replay_scanners, create_replay_scanner, scan_replays, list_scanner_observations, …), except setting a key: a provider key must not pass through an AI conversation, so PUT /keys is HTTP only.

Base URL

https://mythic-analytics.gulp.workers.dev/client/v1/replay-vision

Authentication

Agency key (ak_), or an agency-wide mcp_ key with replay_vision:read or replay_vision:write. Setting a key (PUT /keys) takes an ak_ only: mcp_ keys get 403 not_available_to_scoped_keys there, so a provider key never passes through an AI tool. A location secret key (sk_) gets 403 agency_required. Every location_id must be one of your clients (403 location_not_linked).

This surface serves no CORS headers. Call it from your server, never from browser JavaScript.

Scanner types

TypeAnswerConfig
monitorverdict: yes / no (or inconclusive)allow_inconclusive
classifiertags from your vocabularytags (2-30), multiple, freeform
scorerscore on your scalemin, max, label
summarizertitle, summary, intent, outcome, friction_points, keywordslength: short, medium, long

Every answer also has confidence (0 to 1, the model's own estimate), reasoning (except summarizers) with (t N) citations of video seconds, key_moment_t, and citations mapped back to recording time.

Decisions

After your model answers, a decision model (Cloudflare Clef-flash on Workers AI) reads that answer's reasoning and the event timeline and returns a probability for every allowed answer. It is stored in the observation's decision, next to output. Nothing your model wrote is replaced.

The probabilities repeat on identical input, and they rank answers better than the confidence a model reports about itself. agrees is false where the two models disagree: those are the recordings worth watching first.

Scanner typedecision fieldsagrees is true when
monitorp_yesp_yes is on the same side of 0.5 as verdict; null for inconclusive
classifier (one tag)tag, confidence, probabilities per tagtag is in output.tags
classifier (multiple)tags (probability 0.5 or more), tag_probabilitiestags equals output.tags
scorerscore, confidence, probabilities per scale pointscore is within 20% of the scale of output.score
summarizerp_supported: is the summary consistent with the timelinep_supported is 0.5 or more

Every decision also has model, input_tokens, cost_usd and ms. Mythic pays for the decision model; its cost_usd is not part of the observation's cost_usd, which is your provider's charge. If the decision model fails, the observation still succeeds and decision is { "model", "error" }. decision is null on observations scanned before October 7, 2026.

"output": { "verdict": "yes", "reasoning": "Clicked Save My Seat three times (t 14) …", "confidence": 0.9, "key_moment_t": 14 },
"decision": { "model": "clef-flash", "p_yes": 0.97, "agrees": true, "input_tokens": 1029, "cost_usd": 0.00009, "ms": 520 }

The decision model gets the same session details and timeline as your model, plus your model's answer. It never gets the video.

Write prompts around what can be seen, not feelings: "answer yes if the visitor clicked Submit and an error appeared", not "answer yes if the visitor was confused". Say what to answer when the recording does not show enough.

Quick start

Add your model key

curl -X PUT "https://mythic-analytics.gulp.workers.dev/client/v1/replay-vision/keys" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{"provider":"openrouter","api_key":"sk-or-v1-…"}'

Mythic checks the key with the provider before storing it. Omit location_id for the agency default; pass a client's id to override it for that client.

Create a scanner

curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/replay-vision/scanners" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{"location_id":"<client id>","name":"Form friction","type":"monitor",
       "prompt":"Answer yes if the visitor clicked a button or submitted a form and nothing happened, an error appeared, or they clicked the same control repeatedly. Otherwise no.",
       "config":{"allow_inconclusive":true},"sampling_rate":0.1}'

Scan a few recordings you know

curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/replay-vision/scanners/<id>/scan" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{"session_ids":["<session id>"]}'

Session ids come from GET /client/v1/data/replays. Read the answers with GET /scanners/<id>/observations a few minutes later, check the reasoning, and adjust the prompt before raising sampling_rate.

Model keys

ProviderKeyDefault modelHow the video is sent
openrouterAn OpenRouter API keygoogle/gemini-3.5-flash-liteInline in each request, routed only to providers that support every parameter and do not store prompts (data_collection: deny)
geminiA Google AI Studio API key on a paid plangemini-3.5-flash-liteInline, or through the Gemini Files API for large videos (deleted after the call)

A scan uses the client's key if it has one, otherwise the agency's. A scanner's model overrides the key's default_model and must be a model of that key's provider: OpenRouter ids look like google/gemini-3.5-flash-lite, Gemini ids like gemini-3.5-flash-lite. Setting a scanner model of the other provider gets 422 model_provider_mismatch. If a key is later replaced with the other provider, scans use the new key's default_model until you update the scanner. GET /models?provider=openrouter lists OpenRouter models that accept video and return structured output, cheapest first.

Direct Gemini keys require "confirm_paid_tier": true. Google may use free-tier API content to improve its products, and recordings show your clients' visitors. OpenRouter keys do not need this: Mythic routes them only to providers that do not store or train on prompts.

Keys are encrypted at rest and never returned; GET /keys shows the last four characters. If the provider rejects a key during a scan (expired, revoked, out of credits), last_error on the key says so and those scans fail with error.kind: auth.

How scans run

  • Background sweep. An enabled scanner looks at recordings that ended in the last few minutes, every 5 minutes. It applies the scanner's filters (device_type, utm_source, utm_medium, utm_campaign, landing_page, country, browser, os, min_duration_s), then keeps a random sampling_rate share. A new or re-enabled scanner starts from now; use /scan for older recordings.
  • On demand. POST /scanners/{id}/scan queues up to 50 recordings. It ignores sampling_rate and works on a disabled scanner.
  • Once per recording. A scanner observes each recording at most once. Re-sending a session id reports the existing observation.
  • Monthly limit. monthly_scan_limit (default 500) caps queued, running and succeeded scans per calendar month (UTC), sweep and on-demand together. GET /scanners/{id} shows this_month, including cost_usd when the provider reports it (OpenRouter does, direct Gemini does not).
  • Versions. A change to prompt, config or model increments version; re-sending the same values does not. Each observation keeps the scanner as it was when it was queued.

Queued scans start within a minute and most finish within a few minutes. Mythic retries transient, rate_limited and render_failed failures itself, with backoff, up to three attempts.

What the model sees

The recording is rebuilt from its rrweb snapshots, one frame per second of activity. Idle stretches are cut to a single frame, and a footer on each frame shows the time, the browser tab and the page path. Next to the video the model gets a timeline: clicks, rage clicks, dead clicks, field typing (never the values), page loads, console errors, exceptions, failed or slow requests, and the client's own analytics events. The video shows the page as it was recorded, so anything the SDK masked stays masked. Query strings are removed from URLs, and emails and long numbers are redacted from the timeline.

Recordings shorter than 15 seconds, with less than 10 seconds of activity, or with more than an hour of activity are ineligible and never reach your model. For recordings with more than 30 minutes of activity, only the first 30 minutes are rendered, and the model is told so.

Observation statuses

StatusMeaning
pending / runningQueued or in progress
succeededoutput holds the answer; a $recording_observed event was sent
ineligibleineligible_reason: no_recording, too_short, too_inactive or too_long. Your model was not called
failederror.kind says why (below)
error.kindMeaning
authThe provider rejected the key (invalid, revoked, out of credits). Replace it with PUT /keys
no_keyNo key for the client or the agency when the scan ran
rate_limitedThe provider kept refusing with 429
transientTimeouts, network errors or provider 5xx after every retry
bad_requestThe provider refused the request, most often an unknown model id
rejectedThe provider's safety filter refused the content
too_largeThe video was over the provider's inline limit
bad_responseThe provider answered with something unreadable
validation_failedThe model's answer did not fit the scanner, twice
render_failedThe recording could not be rendered after every retry
orphanedThe scan was interrupted three times
internal_errorA Mythic error

Retry a failed observation with POST /observations/{id}/retry after fixing the cause. A retry counts against monthly_scan_limit like a new scan.

Paging

Observation lists are newest first, limit 1-200 (default 50). When more remain, the response has next_cursor; pass it back as cursor for the next page. next_cursor is null on the last page.

$recording_observed events

Each succeeded observation is ingested as an event on the client, attributed to the recorded visitor's distinct_id and carrying $session_id, so it joins the session in the Data API and Explore. It is left out of the session timeline (GET /client/v1/data/replays/{id}/timeline), which lists what the visitor did, and out of what later scans see. Properties: observation_id, scanner_id, scanner_name, scanner_type, scanner_version, triggered_by, model_used, provider_used, cost_usd, scanner_output_confidence, scanner_output_key_moment_ms, plus the answer: scanner_output_verdict, scanner_output_tags, scanner_output_tags_freeform, scanner_output_score, scanner_output_reasoning, or for summarizers scanner_output_title, scanner_output_summary, scanner_output_intent, scanner_output_outcome, scanner_output_friction_points and scanner_output_keywords. With a decision: scanner_decision_model, scanner_decision_agrees, and scanner_decision_p_yes, scanner_decision_tags, scanner_decision_score or scanner_decision_p_supported by scanner type. The event's time is when the observation finished, not when the visit happened.

Deleting a scanner deletes its observations; events already sent stay.

Limits

25 scanners per client, 50 sessions per /scan call, 120 requests per minute. Prompts up to 4,000 characters. Recordings are kept 30 days, so older sessions come back ineligible: no_recording. HIPAA clients have no recordings.