Repository

Internet2EgoExo

Turn internet-scale video into egocentric and exocentric training data, curated and annotated by agents on the Memories.ai datalake

Stage
What happens
Search
Search
Fetch
Fetch
Frame check
Frame check

Specs

  • StageWhat happens
  • SearchSearch
  • FetchFetch
  • Frame checkFrame check
  • IndexIndex
  • CleanClean
  • AnnotateAnnotate
  • CurateCurate
  • SectionFor
  • How the agent decidesslots, the loop, the answer
  • The pipeline, end to endone request, followed all the way through
  • The agentswhat each agent is judged on
  • How a candidate is judgedthe standard, as executable checks
  • The dataset on diskwhat you actually get handed
  • Cost per hourhow the bill is worked out
  • Web UIrun the whole loop in a browser
  • SourcesSources
  • Viewpoint verdictsViewpoint verdicts
  • Licence filteringLicence filtering
  • Volume goalsVolume goals
  • ManifestManifest
  • Annotation treeAnnotation tree
  • Cost ledgerCost ledger
  • Web UIWeb UI
  • DOWNLOAD_PROVIDERSCredentials
  • apify (default)APIFY_API_TOKEN
  • rapidapiRAPIDAPI_KEY , RAPIDAPI_HOST
  • brightdataBRIGHTDATA_TOKEN , BRIGHTDATA_ZONE
  • oxylabsOXYLABS_USERNAME , OXYLABS_PASSWORD
  • AgentOwns
  • Search agentFinding candidates and ranking them by usability
  • spanleft hand
  • 155–195ssteadies the panel
  • 65–98sholds and steadies the metal bracket
  • CheckWhat it catches
  • ANCHOR-OVERRUN / ANCHOR-ORDER / ANCHOR-SHORT / ANCHOR-LONGAn anchor outside its video, backwards, below the action floor, or a single span called one "action" for three minutes
  • G2-TREE-1 / G2-TREE-2 / G2-TREE-3A child span outside its parent, siblings overlapping, a level that repeats its parent's words verbatim
  • HAND-EMPTYA hand named with nothing behind it — the label a manipulation model would learn from
  • VIEWPOINT-DRIFTA clip delivered in the viewpoint the collection did not ask for
  • EVIDENCE-CONTRADICTEDThe annotation says the hands pick up a soldering iron between 0:42 and 0:58; the captions for that window describe something else

README

Internet2EgoExo

Turn internet-scale video into egocentric and exocentric training data.

Search the open web for first-person and third-person footage, confirm the
viewpoint against real frames before paying for anything, then let agents on the
Memories.ai video datalake curate and annotate whatever
survives.

License: MIT
Python 3.12+
Status: early

Demo

One real run: a plain-language ask, a live search, the frame check that rejects
the wrong viewpoint before anything is paid for, the cost ledger, and a finished
clip with its task and action tree.

Internet2EgoExo, one real run

Watch the full 92-second run (1280x720). The search in it
really took 71 seconds, and that stretch is labelled as sped up on screen rather
than passed off as real time.

What it does

You state a goal the way you would say it out loud, not as a set of filters:

find me first-person cooking footage, hands must be visible, two hours of it

and the loop runs:

Stage What happens
Search YouTube, TikTok, Instagram, X and the open web, in one pass
Fetch The bytes come from a paid provider, routed per platform. No extractor
Frame check Frames are sampled and the viewpoint is decided on pixels, before a cent is spent on indexing
Index Whatever passes goes into the Memories.ai video datalake
Clean A cleaning agent cuts the usable spans out of long recordings
Annotate An annotation agent writes the task, action and event tree
Curate A curation agent grades the set against executable quality gates

What comes out is a directory of clips with annotations, a manifest, and a bill
for what an hour of it cost.

What it is strict about

No hands, no clip. First-person footage without hands in it is just ordinary
footage. This is the one rule with no override.

Never mix hour counts. worn, delivered, accepted and
accepted_labeled are four different numbers. Only accepted_labeled is safe
to quote outward.

Unmeasured is not passed. A quality check that could not be measured is
dropped from the score rather than assumed to have passed.

Where to look

Section For
How the agent decides slots, the loop, the answer
The pipeline, end to end one request, followed all the way through
The agents what each agent is judged on
How a candidate is judged the standard, as executable checks
The dataset on disk what you actually get handed
Cost per hour how the bill is worked out
Web UI run the whole loop in a browser

What you get

Sources YouTube, TikTok, Instagram, X, and the open web: dataset pages, lab sites, archives. Exa for neural search, Apify for scraping
Viewpoint verdicts Every candidate is called egocentric, exocentric or unknown, and the cues behind the call are kept with it
Licence filtering Narrow to Creative-Commons material that is safe to reuse
Volume goals Ask for hours, not clip counts. The run reports progress against the target and which constraint was binding
Manifest Every clip with its viewpoint, confidence, duration, licence and usability score, as JSONL or CSV
Annotation tree Task, action and event on time anchors, with per-span hand and object labels written back as tags
Cost ledger Discovery, download, indexing and annotation priced from published rates, per hour collected and per hour delivered
Web UI The whole loop in a browser, with no build step

How the agent decides what to do

Your sentence becomes slots. An agent loop then picks tools until it has enough,
and reports what it found.

Slots. A Gemini model reads the request and pulls out what is actually being
asked for. It runs through OpenRouter by default, or Google directly; see
The models.

# "first-person cooking footage from YouTube, at least 5 minutes, 2 hours of it"
ParsedQuery(
    platforms=["youtube"],
    topics=["cooking"],
    viewpoint=Viewpoint.EGOCENTRIC,
    min_duration_seconds=300,
    target_hours=2.0,
)

The loop. Up to ten steps by default, set with MAX_AGENT_STEPS. Results are
filtered inside the loop, before the model sees them, so it never reasons over
candidates that already fail the request.

flowchart TD
    A["Request, parsed into slots"] --> B{"Model picks a tool"}
    B -->|"search, fetch, classify"| C["Tool registry runs it,
with retry and fallback"]
    C --> D["Results filtered against the slots
before the model sees them"]
    D --> B
    B -->|"enough, or step cap reached"| E["Candidates, viewpoint verdicts,
manifest, cost"]

The answer. An AgentResponse: the written answer, video_references
with their metadata and viewpoint verdicts, a dataset summary, usage_metrics
for tokens and cost, and the parsed_query it actually worked from.

For what happens to a candidate after it is found, read
the pipeline.

Installation

# Clone the repository
git clone https://github.com/Memories-ai-labs/Internet2EgoExo.git
cd Internet2EgoExo

# Install dependencies
pip install -e .

# Or with uv
uv pip install -e .

Configuration

Create a .env file with your API keys:

# Required
GOOGLE_API_KEY=your_google_api_key
YOUTUBE_API_KEY=your_youtube_data_api_key
MEMORIES_API_KEY=sk-mai-your_datalake_api_key
MEMORIES_BASE_URL=https://api.memories.ai/serve/datalake/v1

# Optional
MEMORIES_COLLECTION_ID=            # index into an existing collection
MEMORIES_COLLECTION_NAME=video-searching-agent
MEMORIES_INDEX_FPS=1.0
MEMORIES_INDEX_WAIT_SECONDS=120    # how long one call waits for indexing
EXA_API_KEY=your_exa_api_key
APIFY_API_TOKEN=your_apify_api_token   # also how YouTube videos get downloaded
VIEWPOINT_CHECK=frames                 # off | frames | watch — see PRE-SIGHT

Getting API Keys

  1. Google API Key (required): Get your Gemini API key at Google AI Studio

  2. YouTube Data API Key (required):

  3. Memories.ai API Key (required): Create a Video Datalake key (sk-mai-...) in the Console → API keys. Indexing is billed per minute of video — see pricing

  4. Exa.ai API Key (optional): Sign up at exa.ai for neural web search

  5. Apify API Token (recommended): Sign up at apify.com. It scrapes TikTok, Instagram and X — and it is how YouTube videos are downloaded, which matters more than it sounds:

    yt-dlp no longer works against YouTube from a datacentre address. It fails
    with "Sign in to confirm you're not a bot", which is not a transient
    error — it is YouTube declining to serve any IP range that looks like a
    server, and every host this pipeline realistically runs on (a container, a
    CI runner, a serverless function) sits in one of those ranges. So for
    YouTube:

    • metadata comes from the YouTube Data API, which is free, answers in
      one round trip, and states outright whether a video is Creative Commons
      (status.license) — a Gate 0 fact that the extractor was always vague
      about;
    • the file comes from an Apify downloader actor, which fetches it from a
      residential address into Apify's key-value store, from where this process
      streams it to disk on the way to the Datalake.

    The other platforms are fetched the same way, through the scrapers the
    search already calls, with their download flag on.

    There is no extractor path at all any more. yt-dlp was the download
    route for everything except YouTube, and it does not work: TikTok fails with
    "Unexpected response from webpage request" on every attempt, so a run
    surfaced good TikTok candidates, charged for the search, and then reported
    failed for reasons that said nothing about the footage. Fetching is now
    routed per platform across providers:

    DOWNLOAD_PROVIDERS Credentials State
    apify (default) APIFY_API_TOKEN Verified against the live API for YouTube, TikTok, Instagram and X
    rapidapi RAPIDAPI_KEY, RAPIDAPI_HOST Written to the documented API, never executed
    brightdata BRIGHTDATA_TOKEN, BRIGHTDATA_ZONE Written to the documented API, never executed
    oxylabs OXYLABS_USERNAME, OXYLABS_PASSWORD Written to the documented API, never executed

    Set it to an ordered list — DOWNLOAD_PROVIDERS=apify,brightdata — and each
    is tried until one hands back a media URL. A provider with no credentials
    reports itself unavailable and is never asked, so an unverified one cannot
    quietly become the provider a run depends on. When all of them refuse, the
    error names every attempt rather than one extractor.

Quick Start

import asyncio
from video_searching_agent import VideoSearchingAgent

async def main():
    agent = VideoSearchingAgent()

    response = await agent.query(
        "find me first-person cooking footage, hands must be visible"
    )
    print(response.answer)

    # Every candidate carries its viewpoint verdict and the cues behind it.
    for ref in response.video_references:
        print(f"- {ref.title}: {ref.url}")

asyncio.run(main())

The pipeline, end to end

One request — "find me first-person cooking footage, hands must be visible" —
runs the whole way through. Nothing below is a separate product: it is one path,
and each leg is owned by an agent whose job is narrow enough to be judged on its
own output.

                      ┌──────────────────────── you ────────────────────────┐
                      │  "all first-person cooking videos, hands visible"   │
                      └───────────────────────────┬─────────────────────────┘
                                                  ▼
 1  SEARCH        search agent        YouTube · TikTok · Instagram · X · web
                                     viewpoint classification, usability ranking
                                                  ▼
 2  SCREEN        cleaning agent      licence · length · viewpoint     ← spends nothing
                                                  ▼
 3  LOOK          cleaning agent      a few real frames, not the words about them
                                     wrong viewpoint stops here, for ~$0.002
                                                  ▼
 4  DOWNLOAD      provider routing    platform pages are not fetchable media
                                                  ▼
 5  INDEX         Video Datalake      upload → captions · transcription · embeddings
                                                  ▼
 6  CLEAN         cleaning agent      hands · other people · editing · resolution
                                     then: where does each action start and stop
                                                  ▼
 7  ANNOTATE      annotation agent    task → action → event, on time anchors
                                                  ▼
 8  CURATE        curation agent      hours ledger · diversity · duplicates · grade
                                                  ▼
                      ┌──────────────────────────────────────────────────────┐
                      │  clean clips, every one with hands, each with a tree  │
                      └──────────────────────────────────────────────────────┘

Every verdict is written back onto the video as tags, so each pass narrows the
next one: clean_pass is the annotation agent's worklist, first_person_view
and hands_visible are how the next query finds only footage that already
qualified.

The agents

Agent Owns Module
Search agent Finding candidates and ranking them by usability agent/core.py
Cleaning agent Agentic filtering and agentic clipping agent/cleaning_agent.py
Annotation agent Agentic annotation — task → action → event agent/annotation_agent.py
Curation agent Agentic data curation across a whole set agent/curation_agent.py
Quality-check agent Auditing the clips that came out — independently agent/quality_check_agent.py
Clipping agent Deciding boundaries by looking at the frames (ReAct) agent/clipping_agent.py
Annotating agent Labelling a span by looking at it (ReAct) agent/annotating_agent.py

They are separate on purpose. Filtering is a judgement about footage, annotation
is a judgement about language, and curation is a judgement about a set — mixing
them into one prompt produces an agent that is mediocre at all three and whose
mistakes cannot be attributed.

Looking, rather than reading about it

Every judgement here began as a statement about caption wording: whether hands
are in frame, whether the camera is worn, whether a span is one action. Caption
text is a real signal and a lossy one, and the failures were the kind you only
find by looking — a vertical phone clip of somebody using a washing machine was
rejected on a hand density of 43% computed from words like "pours" and "places",
when what actually disqualified it (9:16, with a burned-in watermark) was sitting
in the pixels.

So two of the agents run as ReAct loops with eyes. agent/eyes.py cuts a
span through the Datalake, pulls frames out of the file and hands them back as
images; agent/react_loop.py is the think-act-observe runtime they share. On each
turn the agent either calls a tool — look at frames, re-read a caption window —
or answers. Every step lands in the trace, so the reasoning is inspectable rather
than a black box that emitted a label.

Three bounds keep it honest and affordable:

  • steps — a loop that has not converged in a handful of turns will not;
  • money — a cut is $0.005, tools report what they spend, and the loop stops
    when the budget is gone rather than when it feels finished;
  • the answer contract — running out of steps is not an answer. It returns
    nothing and the caller abstains.

The clipping agent (agent/clipping_agent.py) takes the caption walk's
proposal — which has never seen the footage — and corrects it where the frames
disagree. On a real IKEA wardrobe build it merged two proposed spans, moved four
boundaries, and gave reasons the captions do not contain ("inserting and
hammering metal threaded inserts"). It may not invent a span covering footage it
never examined, boundaries are clamped to the video, siblings are separated so
G2-TREE-2 holds, and a span it could not see stays exactly as proposed. When
the loop does not converge the proposal stands — the honest outcome, not an
empty one.

The annotating agent (agent/annotating_agent.py) labels one span, and looks
when the captions do not settle what it is being asked. That recovers the field
that used to be lost most often. A hand is still never invented — but "I saw it"
is now a way of knowing, and each hand field records which:

span left hand right hand evidence
155–195s steadies the panel hammers the fitting into the panel frames
65–98s holds and steadies the metal bracket inserts screws and tightens them with a screwdriver frames

Both of those came back null before, as "hand assignment not stated in the
captions". A hand claimed from frames when no look happened is discarded in
code, not merely discouraged in the prompt.

Auditing what came out

The first four agents each make a judgement and then act on it. The
quality-check agent makes none of its own about footage: it audits their
output
, adversarially, and it is allowed to fail a clip that all of them
accepted.

That independence is the point. A pipeline that grades its own homework will
tell you a dataset is fine in exactly the cases where its own reasoning was
wrong. Twenty videos in and fifty clips out is only a result if the fifty clips
would survive somebody reading them, so the audit asks a narrower and harsher
question of the finished artefact: if someone handed me this dataset, would I
accept it?

Check What it catches Cost
ANCHOR-OVERRUN / ANCHOR-ORDER / ANCHOR-SHORT / ANCHOR-LONG An anchor outside its video, backwards, below the action floor, or a single span called one "action" for three minutes free
G2-TREE-1 / G2-TREE-2 / G2-TREE-3 A child span outside its parent, siblings overlapping, a level that repeats its parent's words verbatim free
HAND-EMPTY A hand named with nothing behind it — the label a manipulation model would learn from free
VIEWPOINT-DRIFT A clip delivered in the viewpoint the collection did not ask for free
EVIDENCE-CONTRADICTED The annotation says the hands pick up a soldering iron between 0:42 and 0:58; the captions for that window describe something else ~$0.0004 a span
EVIDENCE-NONE A label resting on a window with no captions at all — a claim with nothing checkable behind it ~free
SET-DUPLICATE / SET-CONCENTRATED The same span delivered twice; fifty clips that are really twelve videos free
SET-HOURS Anchors totalling more than the hours the manifest claims, which means something is double counted free

The evidence check is asked as a refutation — the model is told to find the
reason the label is wrong — because a model asked to confirm a label will
confirm it. Silence is not contradiction: a window with no captions is a
warning, never a failure, and captions vaguer than the annotation still support
it. A finding with no evidence behind it is a bug in the audit, not a finding.

Spans are sampled across a clip rather than taken from the front, because
later anchors are the ones most likely to have drifted.

Running the whole thing on real task queries

python qa/run_pipeline.py                  # five task queries, end to end
python qa/run_pipeline.py --query laundry   # one of them
python qa/run_pipeline.py --dry-run         # search and check candidates only, free

qa/run_qa.py proves the path works. This proves the output is worth
having: it runs laundry, kitchen, bike repair, packing and furniture assembly
all the way through — search → collect → index → clean → annotate → curate —
and then hands the delivered clips to the audit. It costs real money (a download
through Apify, a minute of indexing and a caption pass per clip), so it is a
separate script rather than part of the recurring sweep, and its exit code is
non-zero when the audit rejects a set.

Two things it found on its first real run, both fixed here:

  • A clip whose indexing outran the request budget came back neither accepted
    nor rejected, with no reason given
    — so the run reported "nothing survived
    collection: []", an empty list of reasons, for work that had in fact reached
    the Datalake. That outcome is now its own stage, pending, which says what
    happened and hands back the video_id to curate later.
  • Two twelve-minute videos do not fit in one 300-second serverless request, so
    the stream is cut before the summary event arrives. The per-clip events are
    the reliable record; the summary is a convenience that may never be sent.

Agentic filtering

Filtering runs twice, at the two points where it is cheapest.

Before the download, from platform metadata alone — nothing is fetched and
nothing is indexed, so a candidate that cannot qualify costs nothing:

Check Rule
PRE-DUR Shorter than the minimum you asked for → skip
PRE-VIEW Metadata places it in the other viewpoint → skip. Metadata silence never rejects: the frames get the deciding vote
PRE-SIGHT The frames show the other viewpoint → skip. See below
G0-LIC Licence recorded as a data field. Unclear licensing does not stop collection, it bars the clip from the training set — and it is enforced by the field, not by memory

Everything above except PRE-SIGHT reads words about a video — a title, a
description, tags. That is how a run can come back with clips that are licence
clear and completely off topic: a video called "POV cooking" is very often a
tripod pointed at a worktop, and no field in the metadata says so.

PRE-SIGHT looks at the video instead. It runs twice, and the first time is
the one that matters most: right after the search, on the candidates about to be
offered. A search for first-person cooking otherwise returns things like "10
Camera Angles and Shots for Cooking Videos" — nothing in that title says the
camera is on a tripod, so queueing four clips means watching all four get
skipped later. Checking at search time means the wrong ones are never offered,
and the manifest records them under frames show exocentric footage rather than
pretending they were never found. The second pass is the pre-download one below,
which catches anything queued by hand. Two tiers,
and the price gap between them is why there are two:

VIEWPOINT_CHECK What it does Measured cost
frames (default) Reads the three storyboard stills YouTube publishes for every video — actual frames from about a quarter, a half and three quarters in, free to fetch — and asks the VLM what camera it is looking at ~$0.002 and ~3.5s a candidate
watch Hands the YouTube URL to Gemini, which watches the whole video ~$0.26 for ten minutes of footage
off Skips the look free

frames is the default because it is 140× cheaper for the same verdict on the
question being asked, and on the four-video check in tests/ it got all four
right — including abstaining on the one whose stills are title cards. watch is
better judgement and worth it for a handful of finalists; it is never the
default, because twenty candidates a query would be $5 a search.

The arithmetic is what makes this worth running on every candidate rather than
on finalists. Measured on this deployment: the look costs $0.002; the
download it might prevent costs $0.09–0.12 in Apify credits, plus a minute
of Datalake indexing, plus the caption reads the cleaning and annotation passes
make on top. One rejection pays for fifty looks.

The look can only ever stop a download, and only on a confident opposite
reading. Abstention passes through, a weak reading passes through, and a failed
call passes through with a note saying it did not run — the post-index caption
evidence sees all of the footage rather than three moments of it, and keeps the
last word.

After indexing, from the derived content — this is the pass that matters,
because it judges the footage rather than the description of it:

Gate Check Blocking
G1-HAND The wearer's own hands in ≥60% of caption segments yes
G1-OTHERHAND Nobody else's hands in frame — a second pair misattributes the action yes
G1-OTHERFACE Nobody else's face in frame — consent, and the mount must be wrong yes
G1-ORIENT Landscape. Portrait footage is scrapped no
G1-RES ≥720p to pass, ≥1080p for grade A no
G1-FPS ≥30 fps, the temporal-annotation floor no
G1-GLOVE No loose or bulky gloves — they swallow hand shape and keypoints no
G1-WHOLE One take. Splicing manufactures causality that never happened no
G1-IDLE ≤15% idle, and idle time is subtracted, never averaged in no
G1-CODEC H.264/H.265 MP4 no
— Not footage at all (screen recording, slideshow, title card, gameplay) yes

A clip with no hands is dropped. That is the point of the run, and it is the one
rule with no override: first-person footage without hands in it is just ordinary
video.

Two honesty rules hold everywhere:

  • Nothing is scored on a number that was not measured. A check that cannot be
    computed from the available inputs reports measured: false and is excluded
    from the score, rather than being assumed to pass. G3-DUP (overlap with
    public corpora) needs embeddings this repo does not compute, so it reports
    unmeasured — always.
  • The evidence is caption wording, not a detector. Every hand verdict
    carries the caveat "read from caption wording, not a hand-tracking or pose
    model"
    , so nothing downstream can mistake it for detector output.

Agentic clipping

Caption segments carry timestamps, so a run of consecutive segments with work
happening in them is an action and a run of idle segments is not. The cleaning
agent merges, splits and drops:

  • consecutive hand-present segments merge into one action;
  • a gap longer than two seconds splits rather than merges;
  • an idle segment breaks the run and is excluded;
  • a span under two seconds is dropped as noise;
  • the task span covers the actions that survived — usually less than the whole
    video, because the intro and the outro are not the task.

The output is a tree of time anchors on the original video — [start, end]
pairs — and never cut files. That is G2-TREE-5 in the standard and it is not
negotiable: cut clips lose the context on either side of a boundary, and a
boundary that turns out to be wrong can no longer be moved.

Agentic annotation

The annotation agent narrates what happens between the boundaries, at three
depths:

task     replace-inner-tube      "A punctured tube is swapped for a new one."
  action   lever-tyre-off        "Two levers walk the bead off the rim."
    event    lever-slips         "The second lever slips out of the bead."

Depth is the grade:

Level What it is Points
L0 Metadata only 0
L1 One flat caption for the whole video — untrainable 10
L2 Task → action hierarchy, own text per level, time anchors — the minimum 22
L3 Plus events, objects and hand state 30

The structural rules are checked, not trusted:

Check Rule
G2-TREE-1 A child's span sits inside its parent's — events are clamped, never written out broken
G2-TREE-2 Sibling spans do not overlap
G2-TREE-3 Each level says something of its own. Copying the task sentence down onto its actions produces a tree that looks deep and teaches nothing
G2-TREE-5 Every annotation is an anchor on the whole video, never a delivered clip file

And one rule the prompt enforces because the data is worthless without it:
hand assignment is never invented. If the captions do not say which hand did
what, left_hand and right_hand stay null. A guessed left/right is worse than
a blank one, because downstream it cannot be told apart from a real one.

Tags are written back in the form hoi/// — for
example hoi/chop-vegetables/right/move-knife — which is what makes the next
query able to ask for exactly that.

There are two ways in. annotate_video narrates anchors the cleaning agent
already found. run is the discovery loop the Datalake is built for:
search_moments shortlists spans → get_moment says what is really in them →
the model judges → update_video writes the verdict back. The share of the
shortlist that survives the read is reported as the run's survival rate;
retrieval proposes, the span decides.

Agentic data curation

Curation is what can only be judged across a set.

Four hour measures, never mixed. Reporting a delivered hour as an accepted
hour overstates a dataset by 30-40%:

Measure Meaning
worn_hours Recorded at the source
delivered_hours Landed on disk
accepted_hours Cleared the media gates, idle time removed
accepted_labeled_hours Accepted and annotated to L2+ — the only figure to quote externally

media_yield is accepted / delivered, and it is reported rather than assumed.

Diversity, across the set:

Check Rule
G3-OP ≥3 sources, none above 50% — one creator's kitchen filmed twenty times is one hour of information, not twenty
G3-SOP ≥10 task families
G3-ERR 10-20% error / rework samples. Mistakes are wanted; a set of only clean runs teaches a model that nothing ever goes wrong
G3-DUP ≤10% overlap with public corpora — reported unmeasured; reposts by the same uploader at the same length are grouped as a cheap proxy

The scorecard, 100 points: annotation 45, diversity 25, media 20, licensing
10 → A ≥85 (main training set, sellable), B 70-84 (main set, not
external), C 55-69 (pretrain / diversity supplement), D The rate limit does not survive serverless. The token bucket lives in

process memory, and on a platform like Vercel each request can land on a fresh
instance — so on that host the limit is effectively not enforced. What actually
bounds spend there is MAX_COLLECT_URLS (how many clips one request may
queue — indexing is billed per video-minute), a spend cap set on the keys
themselves at OpenRouter and memories.ai, and API_KEYS if you are willing to
require one. A real shared limit needs shared state (Vercel KV, Upstash), which
this repo does not assume you have.

Vercel

vercel.json and api/index.py are in the repo, and api/requirements.txt
holds a deliberately smaller dependency set than the project's own (no
playwright, no scraper SDKs — they are not importable in a serverless bundle and
are not needed to serve the API or the UI).

npm i -g vercel
vercel            # first deploy, links the project
vercel --prod

Then set environment variables in the Vercel dashboard (or vercel env add):

Variable For
DEMO_MODE=1 A clickable demo with no other keys at all
OPENROUTER_API_KEY The models — one key drives the agents (see below)
MEMORIES_API_KEY The Video Datalake (sk-mai-…)
MEMORIES_COLLECTION_ID Index into an existing collection
YOUTUBE_API_KEY / EXA_API_KEY / APIFY_API_TOKEN Platform search
API_KEYS Require X-API-Key on /api/v1/*

What does not fit serverless. A function has a request timeout — 60s on
Hobby, up to 300s on Pro — and indexing a long video takes minutes. The
collection stream handles that the way it was designed to: it reports
indexing still running with the video_id to come back with, rather than
hanging. For bulk collection, run the app on a host without a request timeout
(a VM, Render, Railway, Fly) and point the UI at it. Downloading is also
happier there: platform hosts rate-limit datacenter IPs, and serverless disk is
capped at 512 MB in /tmp.

The models: one key, or Google's

Either provider drives the agents, and the choice is a setting rather than a
rewrite:

Provider Key Notes
OpenRouter OPENROUTER_API_KEY One key in front of hundreds of models. Default model google/gemini-3.7-flash — cheap, fast, and multimodal, so the same key can later read frames rather than only caption text. Cost is reported by OpenRouter itself, not guessed from a local price table.
Gemini GOOGLE_API_KEY The original path, through the google-genai SDK.

LLM_PROVIDER forces a choice (openrouter / gemini); the default, auto,
prefers OpenRouter when its key is present. Set OPENROUTER_MODEL to use a
different model.

Streaming API

POST /api/v1/queries/stream runs a query and streams Server-Sent Events
(started, progress, tool_call, tool_result, clarification_needed,
complete, error):

curl -N http://localhost:8000/api/v1/queries/stream \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{
        "query": "Top latte art videos from the past week",
        "sources": ["youtube", "tiktok"]
      }'
Field Type Description
query string Natural language query (1-2000 chars)
sources string[] Pin the search to youtube, tiktok, instagram, twitter, web. Omit, send [], or send ["auto"] to let the agent choose. Aliases: yt, x, ig, reels, exa
clarification string Answer to a previous clarification_needed event
max_steps int Override the agent step budget (1-20)
enable_clarification bool Set false to never ask for clarification

Pinned sources replace whatever platforms the query parser inferred, and the agent is told
to search only those.

POST /api/v1/collect/stream

Download candidates, index them into the Video Datalake, clean them and annotate
what survives. Events: started, clip_stage (one per stage, per clip),
clip_done, complete, error.

Field Type Description
urls string[] Candidate page URLs, 1-25 per request. Each one costs money to index, so the bound is deliberate
require_hands bool Default true. Reject footage with no hands in frame
viewpoint string egocentric / exocentric, or omit for any
min_duration_seconds int Skip candidates shorter than this, before downloading
annotate bool Default true. false is a cleaning-only pass, which is much cheaper

POST /api/v1/curate/stream

Clean, annotate and grade a worklist that is already indexed. Events: started,
clip_done, complete (hours ledger, Gate 3 checks, batch grade), error.

Field Type Description
video_ids string[] Indexed videos to curate
tag string Or pull the worklist from a tag, e.g. clean_pass
query string What the collection was looking for, carried into the record
require_hands bool Default true
viewpoint string Require a camera viewpoint
annotate bool Default true

One of video_ids or tag is required.

Reading one video's content

Ask about a video's own content and the agent indexes it into the Video Datalake,
then reads back the captions, transcription and summary:

response = await agent.query(
    "What does this video actually show and say? https://example.com/clip.mp4"
)

Indexing takes time. If it is still running when the call's wait budget expires,
the tool reports status: "processing" with a video_id, and the next call reads
the results instead of re-indexing.

Response Structure

AgentResponse:
    session_id: str           # Unique session identifier
    query: str                # Original query
    answer: str               # Natural language answer
    video_references: list    # List of VideoReference objects
    platforms_searched: list  # Platforms that were searched
    total_videos_analyzed: int
    steps_taken: int          # Agent loop iterations
    tools_used: list          # Tools that were called
    execution_time_seconds: float

    # Extended fields
    usage_metrics: UsageMetrics   # Detailed cost tracking
    parsed_query: ParsedQuery     # Extracted slots from query
    tool_execution_details: list  # Success/failure for each tool call
    confidence_score: float       # Answer confidence (0-1)
    needs_clarification: bool     # Whether clarification is needed
    clarification_question: str   # Question to ask user if needed

UsageMetrics Structure

UsageMetrics:
    gemini: GeminiCost            # Gemini API costs
        token_usage: TokenUsage   # input_tokens, output_tokens, total_tokens
        input_cost_usd: float
        output_cost_usd: float
        total_cost_usd: float
    tool_costs: list[ToolUsageCost]  # Per-tool cost breakdown
    total_cost_usd: float         # Combined Gemini + tools cost
    gemini_calls: int             # Number of Gemini API calls
    tool_calls: int               # Total tool invocations

Architecture

VideoSearchingAgent
    ├── GeminiClient (Google Gemini API)
    ├── QueryParser (LLM-first slot extraction)
    ├── ClarificationManager (handles missing context)
    ├── RetryExecutor (retry with exponential backoff + fallbacks)
    ├── ToolRegistry
    │   ├── YouTube: YouTubeSearchTool, YouTubeChannelTool
    │   ├── Exa: ExaSearchTool, ExaSimilarTool, ExaContentTool, ExaResearchTool
    │   ├── TikTok (Apify): TikTokSearchTool, TikTokCreatorTool
    │   ├── Instagram (Apify): InstagramSearchTool, InstagramCreatorTool
    │   ├── Twitter (Apify): TwitterSearchTool, TwitterProfileTool
    │   ├── Video Datalake: VideoIndexTool, VideoAnalysisTool, VideoMomentSearchTool
    │   └── Unified: VideoSearchTool
    └── AgentSession (tracks query lifecycle)

Tools Reference

The agent has access to 16 specialized tools organized by category:

YouTube Tools (2)

Tool Description
youtube_search Search YouTube videos with filters (relevance, date, view count, rating)
youtube_channel_info Get detailed channel information and recent videos

Exa.ai Tools (4)

Tool Description
exa_search Neural web search to discover video content across the web
exa_find_similar Find videos similar to a given URL
exa_get_content Extract full content/text from web pages
exa_research Deep research mode with multiple searches and synthesis

Apify Social Media Tools (6)

Tool Description
tiktok_search Search TikTok videos by keyword, hashtag, or music
tiktok_creator_info Get TikTok creator profile and recent videos
instagram_search Search Instagram Reels and videos
instagram_creator_info Get Instagram creator profile and content
twitter_search Search Twitter/X for video tweets
twitter_profile_info Get Twitter profile and video tweets

Memories.ai Video Datalake Tools (3)

The Datalake is the agent's long-term video memory: a video indexed once stays
searchable, so later questions read the lake instead of re-processing the video.

Tool Description
video_analysis Index a video URL (or read an indexed video_id) and return its AI title, summary, visual captions and speech transcription — whole video or a start/end window
video_index Add a video to the Datalake without waiting for indexing to finish
video_moment_search Search already-indexed videos for the moments matching a description, with timestamps and thumbnails

Cost control: indexing is billed per minute of video, so the tool policy blocks
these tools unless the query actually names a video or asks what is inside one —
a broad discovery query can never trigger paid indexing.

Unified Tools (1)

Tool Description
video_search Unified search combining Exa discovery + Apify scraping

Query Slots

The agent extracts structured slots from natural language queries using LLM-first parsing. These slots control search behavior:

Platform Slots

Slot Values Description
platforms youtube, tiktok, instagram, twitter Target platforms for search

Entity Slots

Slot Example Description
topics ["cooking", "bicycle repair"] Subject matter keywords
brands ["Nike", "Adidas"] Brand names to search
creators ["@mkbhd", "@charlidamelio"] Specific creators to find
hashtags ["#fitness", "#workout"] Hashtags to search
products ["iPhone 15", "AirPods"] Product names

Metric Slots

Slot Values Description
metric most_popular (default) Highest current views
fastest_growth_views View velocity / viral potential
highest_engagement Best engagement rate
most_liked Highest like count
most_commented Highest comment count
most_shared Highest share count
most_recent Most recently published

Time Frame Slots

Slot Values Description
time_frame past_24_hours Videos from last 24 hours
past_48_hours Videos from last 48 hours
past_week (default) Videos from last 7 days
past_month Videos from last 30 days
past_year Videos from last 365 days
all_time No time restriction

Quantity Slots

Slot Range Description
quantity 1-100 (default: 10) Number of videos to return

Data Models

Core Entities

Video:
    platform: Platform        # youtube, tiktok, instagram, twitter
    platform_id: str          # ID on source platform
    url: HttpUrl              # Direct video URL
    title: str | None
    creator: Creator | None
    metrics: VideoMetrics | None
    published_at: datetime | None
    hashtags: list[str]

Creator:
    username: str
    platform: Platform
    followers: int | None
    verified: bool
    total_videos: int | None

VideoMetrics:
    views: int | None
    likes: int | None
    comments: int | None
    shares: int | None
    engagement_rate: float | None  # Platform-specific calculation

Query Models


ParsedQuery:
    original_query: str
    query_type: QueryType     # industry_topic, brand_analysis, creator_profile, etc.
    platforms: list[str]
    topics: list[str]
    creators: list[str]
    metric: MetricType        # most_popular, highest_engagement, etc.
    time_frame: TimeFrame     # past_week, past_month, etc.
    quantity: int             # 1-100
    needs_clarification: bool

AgentSession:
    session_id: str
    user_query: str
    parsed_query: ParsedQuery | None
    current_step: int         # Curre