Scout & Capy
A personal brain that knows what it doesn't know.
Capy is memory: a Cognee knowledge graph built from your own email, docs, notes and calendar. Ask it something and it first enumerates what it structurally cannot answer — a person with no attributes, a job title last observed 428 days ago, two entities with no path between them — and compiles each hole into one concrete search query. Scout fills exactly those holes through Bright Data, folds the result back in as cited, untrusted-tagged graph data, and re-answers. A Strands agent runs the loop.
It runs with no API keys at all.
Demo video
Watch on YouTube — 2 minutes, narrated. Also in the repo as demo/scout-and-capy-demo.mp4.
A single unbroken screen recording of the real app against the real backend;
nothing on screen is a mock-up. The run in the video reports
5 gaps · 3 filled · 2 live, and the live Bright Data results are readable in
the trace rail at about 1:28. See demo/README.md for how it
was made and for the one claim the narration makes that the footage does not
show directly.
The idea
A vector store cannot tell you what it doesn't know. Ask it anything and it returns k chunks with confident similarity scores — the absence of an answer looks exactly like the presence of one. "I don't know" is not a shape a top-k retrieval can produce.
A knowledge graph makes absence structural, and therefore enumerable: a
Personnode with zero descriptive attributes; a volatile attribute whoseobserved_atis 14 months old; two entities the question needs joined with no asserted path between them. Those are queries over topology, typing and time — which is the reason to have a graph rather than an index.
And because each hole is a specific missing fact, it compiles to a specific fetch. The web call is not decoration bolted onto a graph demo; the graph states its own holes and Scout fills those holes, nothing more:
recall → detect gaps → fetch exactly those → ingest → re-answer
Cognee Cognee Bright Data Cognee Cognee
← ← ← Strands picks the order ← ← ←
Every gap becomes a FetchPlan(query, expects, attach_to). Every fetched fact lands tagged
node_set="web", trust="untrusted", with a source URL and a fetch time. Web text becomes graph data —
node attributes and citations — never agent instructions.
What each sponsor technology does here
| Technology | What it does here | API surface used |
|---|---|---|
Cognee 1.6.0 |
Capy. Builds the graph from the private corpus, answers over it, reports which graph elements the answer used, and rewires on feedback. | add(node_set=) · cognify(ontology_file_path=, temporal_cognify=) · search(query_type=SearchType.GRAPH_COMPLETION, session_id=, include_references=, feedback_influence=, top_k=15) · get_session() → used_graph_element_ids · add_feedback() + memify() · visualize_graph_json() · get_schema_inventory() / validate() |
| Bright Data | Scout. Fetches exactly the gaps the graph named, over a transport that needs no zone provisioning, with a provenance stamp on every result. | Remote MCP: search_engine, scrape_as_markdown · alternate POST https://api.brightdata.com/request (SERP brd_json=1, Unlocker data_format: "markdown") |
Strands 1.56.0 |
The agent that decides which move comes next, and streams every tool call into the UI. | Agent(model=, tools=, system_prompt=) · five @tool functions · agent.stream_async(q, limits=Limits(max_iterations=14)) · strands.tools.mcp.MCPClient(url=) · OpenAIModel / AnthropicModel |
Cognee — the memory layer (app/services/memory.py)
- Ingest, tagged by origin.
cognee.add(texts, dataset_name="scout_and_capy", node_set=[tag]), one tag per source (email,docs,notes,calendar) andwebfor everything Scout brings back. The tag is what lets the UI colour web-derived knowledge differently and what keeps it queryably separable from the user's own corpus. - Cognify with a shape.
cognee.cognify(datasets=[…], ontology_file_path="ontology/scout_capy.owl", temporal_cognify=True); web ingest addsincremental_loading=True. The ontology's job is to keep the graph a shape rather than a hairball —works_atis one edge type, not nine synonyms. - The ontology is what makes staleness structural.
ontology/scout_capy.owldeclares 7 top-level classes (Person, Organisation, Project, Meeting, Document, Claim, Topic) plus 9 specialisations, 16 object properties and 10 data properties. Six data properties are volatile —jobTitle,employer,headcount,fundingStage,pricing,version— andobservedAtis the field the staleness check reads. Naming volatility in the ontology is what turns "is this stale?" into a query instead of a guess. - Retriever-reported provenance, not inferred.
search(..., session_id=…, include_references=True)makes the retriever recordused_graph_element_ids = {node_ids, edge_ids}on the session's QA entry. The app reads it straight back —from cognee.api.v1.session import get_session; await get_session(session_id=…, last_n=1)— and emits those exact ids as ahighlightevent, so the pond lights up the elements Cognee actually used. Nothing is re-derived or guessed. - Feedback that steers, not feedback that is filed.
add_feedback(session_id=, qa_id=, feedback_text=, feedback_score=)→cognee.memify(dataset=), and once a rating is accepted, subsequent searches passfeedback_influence=0.35. Without that last step the correction is recorded and ignored, which is the quiet failure mode of most "we have a feedback loop" demos. - Renderer payload.
from cognee.api.v1.visualize import visualize_graph_json→{"nodes": […], "links": […]}(max_nodes=900), withget_graph_engine().get_graph_data()as the fallback. Both shapes normalise into oneGraphView, so gap detection and the frontend have a single code path whether the graph came from a live cognify or from a frozen snapshot. - Introspection worth showing.
get_schema_inventory(dataset=, samples_per_type=4)andvalidate(dataset=)for "what did extraction actually build, and does it match the ontology?" - Store:
ladybug(GRAPH_DATABASE_PROVIDER) — local, zero setup, no Docker and no Neo4j to demo.COGNEE_API_URL+COGNEE_API_KEYswitch memory to a managed Cognee Cloud tenant instead (app/services/cognee_cloud.py), which is preferred when configured because the tenant runs extraction and embeddings server-side and needs no LLM key of ours. Its component health and quota show at/api/health— that distinguishes "tenant down" from "our key is wrong", which are very different problems to have thirty seconds before a demo. - The closer, wired.
.mcp.jsonat the repo root pointscognee-mcpat the samebackend/.cogneedata root the app uses, so the same graph answers from inside Claude Code. The memory outlives the demo UI.
Search modes are exposed as mode=: graph → GRAPH_COMPLETION, rag → RAG_COMPLETION (the
split-screen baseline), plus cot → GRAPH_COMPLETION_COT, temporal → TEMPORAL,
triplet → TRIPLET_COMPLETION.
Bright Data — the retrieval layer (app/services/brightdata.py)
- Transport 1, remote MCP (default, verified live). Streamable HTTP against
https://mcp.brightdata.com/mcp?token=…:initialize(protocol2024-11-05) →notifications/initialized→tools/call, withdata:SSE frames parsed out, themcp-session-idcached, and one automatic re-handshake on400/401/404/409.tools/listreturns, live:search_engine,scrape_as_markdown,search_engine_batch,scrape_batch,ask_brightdata_assistant. We callsearch_engine {query, engine: "google", geo_location?}andscrape_as_markdown {url}. It needs only an API key — no zone to provision — which is why it is the default. - Transport 2, REST (opt-in).
POST https://api.brightdata.com/requestwith{"zone", "url", "format": "raw"}; SERP by appending&brd_json=1to the Google URL, Unlocker by adding"data_format": "markdown". RequiresBRIGHTDATA_SERP_ZONE/BRIGHTDATA_UNLOCKER_ZONE. - Transport 3, recorded fixtures. Author-curated files in
snapshots/web/, then the auto-recordedsnapshots/web/_cache/…; every live result is written to the cache on the way through, so a beat that worked once replays when the venue wifi dies. A curated fixture declares what it answers rather than relying on a query slug —"match": [["tomas","brandt"], ["brandt","security"]], a list of term groups, applying when every term in any one group appears in the query — because a one-word change to a compiled gap query would otherwise miss the fixture mid-demo. It may also carryextracted: [{relation, target, target_type, attrs, quote}], which lets the keyless tier write the same typed edgecognifywould have derived from the page (memory.ingest_facts, which resolves the target to the existing node so the path actually forms) instead of merely attaching a document. - The trust boundary. Bright Data's MCP wraps page content in
=====UNTRUSTED_<nonce>_BEGIN===== … =====UNTRUSTED_<nonce>_END=====. The regex is keyed on the nonce it was handed (a\1back-reference), so fetched text cannot forge its own closing marker. The envelope is stripped at the edge and everything downstream carriestrust="untrusted",node_set="web", asource_urland afetched_at. The agent's system prompt states that web content is data and that a page asking to change behaviour is to be ignored and reported. - Provenance stamp.
Provenance.label()renderslive · search_engine · 2s agoorcached · recorded, and the UI shows it verbatim. A cached result is never labelled live. - Budget.
SCOUT_FETCH_BUDGET=6per process; the planner takes at most 3 gaps per question and stops belowpriority < 0.25.BRIGHTDATA_TRANSPORT=auto|mcp|rest|fixture|offforces a transport, andScout.probe()reports reachability, the live tool list, remaining budget and fixture count.
Strands — the orchestrator (app/services/agent.py)
Agent(model=…, tools=[…], system_prompt=SYSTEM_PROMPT, name="scout-and-capy", callback_handler=None)— no callback handler because we consume the stream ourselves.- Five
@toolfunctions, all thin wrappers over one sharedToolbox:recall_memory,detect_knowledge_gaps,fetch_missing,trace_path,compare_flat_retrieval.detect_knowledge_gapshands back each gap's ready-madefetch_query, and the prompt tells the model to pass it verbatim and to fetch nothing a gap did not ask for. - Event mapping.
agent.stream_async(question, limits=Limits(max_iterations=14)):datadeltas becometokenevents, a newcurrent_tool_use.toolUseIdbecomes a "model chose a tool" step (deduped by id),force_stopbecomes an error event. TheToolboxemits its ownRUNNING/DONEsteps, so a model-chosen tool call animates exactly like a planned one. - Bright Data's MCP, directly.
MCPClient(url=f"{mcp_url}?token=…", prefix="bd", startup_timeout=20, continue_on_error=True)is appended to the tool list, so the agent can reachscrape_as_markdownon a specific URL when a SERP is not enough. Our ownfetch_missingstays the demo path because it carries provenance and writes to the graph. - Explicit model providers. Strands defaults to Amazon Bedrock, which this project does not use, so the
provider is always constructed by hand:
AnthropicModel(client_args={"api_key": …}, model_id="claude-sonnet-4-5", max_tokens=2048)orOpenAIModel(client_args={"api_key": …}, model_id=…, params={"temperature": 0.2}). - Degradation. With no model key — or if the agent errors mid-flight —
PlannedDriverruns the canonical sequence and emits the identical event stream. It is not a mock: same Cognee searches, same gap detector, same live Bright Data fetches. The only thing it lacks is an LLM choosing the order.
The six gap kinds
Defined in app/services/gaps.py. Ranked by priority = 0.65 · relevance + 0.35 · severity, deduped to
one gap per subject (highest priority wins), top 6 returned. Relevance dominates on purpose: a severe gap
nobody asked about is noise.
| Kind | Severity | What it detects | Why a vector store can't |
|---|---|---|---|
bridge |
1.00 | Two entities the question joins, with no asserted path within 4 hops. | "No path" is a traversal result. Top-k always returns k rows, so disconnection is not a thing it can report. |
unknown |
0.92 | A capitalised span in the question that matches no node at all. | The index returns its nearest neighbours anyway, with confident scores. This is the "a judge names a company" path. |
dangling |
0.88 | A named entity with relationships but zero descriptive attributes — not weakly connected, unattributed. | There is no chunk to retrieve: the missing information was never written down. |
conflict |
0.74 | One subject, one volatile attribute, two different values across nodes. | Both chunks rank, neither wins, and nothing flags the disagreement. |
stale |
0.66 | A volatile attribute past its own shelf life — version 120 days, pricing 180, headcount 240, job_title 400, employer 500, location 700 (SCOUT_STALE_DAYS is the fallback for unlisted attributes). |
Similarity has no time axis. A 14-month-old sentence ranks exactly like today's. |
undescribed |
0.45 | A typed node missing an attribute its class should carry (Person → job_title, employer; Topic → version; Organisation → headcount). |
Requires a schema. A chunk index has no notion of what a Person ought to have. |
Staleness is judged per attribute, not against one global cutoff, because the rates differ by an order of
magnitude: a pinned spec version is suspect after four months, a job title after a bit over a year, an
office location after two. That sharpens the compiled query as well as the detection — at 428 days
Priya's gap asks for job_title alone, because her employer has not reached its 500-day shelf life.
Two further details that keep the detector honest:
- Co-mention is not a relationship. Path-finding uses
path(a, b, semantic_only=True), which refuses to walkmentions-class edges — 38 of the demo graph's 95 edges. If two people named in the same email counted as connected, every entity would sit two hops from every other and "is there a path?" would always answer yes: the same false confidence a vector store gives you. - Relevance propagates.
graph_view.relevance_map(tokens, hops=1, decay=0.55)spreads lexical relevance one hop along asserted edges. The question "who should own the Acme security review?" contains no token matching "Tomas Brandt", yet Tomas is the one node standing between that question and its answer; he earnsrelevance 0.73from his neighbour. Without propagation the ranking surfaces whichever node happens to share a word and buries the one that actually blocks the answer. It is deliberately lexical, not embeddings, because gap ranking has to be explainable on stage and a cosine score is not an explanation.
Architecture
┌── frontend/ ── Next.js 16 · React 19 · pnpm ──────────────────────────────┐
│ components/pond/ Pond + pondController — 3d-force-graph / three, │
│ coloured by node_set (own corpus warm, web blue) │
│ components/trace/ TraceRail · StepCard · GapCard · ProvenanceRow │
│ components/answer/ AnswerPanel · CitationChips · TripleList · │
│ FeedbackBar │
│ components/compare/ CompareView — graph vs flat retrieval │
│ components/{time,status,mascots}/ TimeSlider · StatusRail (tier) · │
│ Scout & Capy sprites │
│ hooks/useAskRun.ts consumes the run; lib/api.ts opens the SSE │
└─────────────────────────────┬─────────────────────────────────────────────┘
│ GET /api/ask/stream (SSE), POST /api/ask as
│ fallback. One event contract, two drivers:
│ step · token · graph_delta · highlight ·
│ answer · done · error
┌─────────────────────────────┴── backend/ ── FastAPI · uv · Python 3.12 ───┐
│ app/main.py app, CORS, "no traceback is the demo" │
│ app/routes/api.py /api/{health,graph,gaps,ask,ask/stream, │
│ feedback,compare,timeline,inventory, │
│ demo/script,demo/reset} │
│ app/core/config.py tiers, env, fetch budget, paths │
│ app/core/events.py Phase / Status / Step — the UI contract │
│ │
│ app/services/agent.py ScoutCapy · Toolbox (the 5 tools) · │
│ StrandsDriver | PlannedDriver │
│ app/services/memory.py CogneeMemory | SnapshotMemory (one Answer) │
│ app/services/cognee_cloud.py managed-tenant client, probe() + quota() │
│ app/services/gaps.py GapDetector → Gap → FetchPlan │
│ app/services/graph_view.py GraphView · path() · relevance_map() │
│ app/services/brightdata.py Scout: mcp | rest | fixture + Provenance │
│ │
│ ontology/scout_capy.owl the classes, relations and volatile fields │
│ snapshots/pond.json frozen graph — 40 nodes, 95 edges │
│ snapshots/facts.py authored entity core + the planted gaps │
│ snapshots/web/ recorded fetches (curated, then _cache/) │
│ seed/email/*.md the private corpus (docs, notes, calendar │
│ directories are wired and currently empty) │
│ tests/ the suite: network-guarded, env-isolated │
└──────────┬──────────────────────────────────┬─────────────────────────────┘
│ │
Cognee (ladybug, local) Bright Data
or Cognee Cloud, + an LLM remote MCP · POST /request
key for cognify/search search_engine · scrape_as_markdown
Everything above graph_view.py is written against one GraphView, so there is exactly one code path
whether the graph came from a live cognify or from the frozen snapshot — and snapshots/facts.py +
scripts/build_snapshot.py produce the same {"nodes", "links"} shape visualize_graph_json() returns.
Run it
Backend
cd backend
uv venv --python 3.12 .venv
uv pip install -e .
cp .env.example .env
.venv/bin/python scripts/build_snapshot.py
.venv/bin/uvicorn app.main:app --reload
Frontend
cd frontend
pnpm install
pnpm dev
Backend on :8000, frontend on :3000. The UI reads NEXT_PUBLIC_API_BASE (see
frontend/.env.local.example) and defaults to http://localhost:8000. GET /api/health prints the tier,
the graph stats, Scout's reachability with its live tool list, and the installed cognee / strands-agents
versions; interactive docs are at /docs.
Tests
cd backend && .venv/bin/python -m pytest -q # 122 passed
The suite is env-isolated and network-guarded — it replaces httpx's outbound entry points with something
that raises, so an accidental live fetch fails loudly instead of quietly passing on the author's wifi.
tests/test_planted_gaps.py is the one that matters most: it fails if a later edit gives Tomas an
attribute or refreshes Priya's timestamp, which would make the gap detector go quiet and leave the demo
with nothing to find.
build_snapshot.py compiles snapshots/facts.py + seed/ into snapshots/pond.json and prints the
demo's invariants — Tomas Brandt's degree and attribute count, Priya's attribute age, and the hop counts
between the entities the demo turns on. It fails the build if a planted gap has been broken (if Tomas
gains an attribute, loses his only edge, or already has an employment edge to Helios Labs, there is no
demo).
Keys are all optional:
- No keys at all →
snapshottier. The frozen graph loads, gap detection is fully live (it is pure graph structure), fetches replay from recorded fixtures, and answers are composed by traversal. Nothing is stubbed out and nothing is blank. BRIGHTDATA_API_KEYalone →scouttier. Live web fetch over the remote MCP against snapshot memory. No zone setup needed.LLM_API_KEY(or Cognee Cloud creds) → live Cogneeadd/cognify/searchand the real Strands agent choosing the moves. Both together is thefulltier.
Windows (PowerShell)
Everything below the wrappers is portable — the Python uses pathlib and reads
every file as explicit UTF-8, nothing shells out, and lancedb, ladybug and
onnxruntime all publish Windows wheels. Only the Makefile is POSIX-only
(it needs /bin/bash, trap and wait), so run its steps directly. The
package manager is pnpm, not npm, in both frontend/ and demo/.
# backend — note Scripts\ rather than bin/
cd backend
uv venv --python 3.12 .venv
uv pip install --python .venv\Scripts\python.exe -e . pytest pytest-asyncio
Copy-Item ..\.env.example .env
.venv\Scripts\python.exe scripts\build_snapshot.py
.venv\Scripts\python.exe -m pytest -q
.venv\Scripts\uvicorn.exe app.main:app --reload --port 8000
# frontend, in a second terminal
cd frontend
pnpm install
pnpm dev --port 3000
Under Git Bash or WSL the Makefile works unchanged — it detects
.venv/Scripts and the .exe suffix — so make setup && make dev is fine
there.
Two Windows-specific notes:
- If
PYTHONUTF8is unset and you add files toseed/yourself, write them as UTF-8. The corpus is full of em dashes; the app always decodes explicitly, but your editor's default may not encode that way. .mcp.jsoninterpolates${PWD}, which resolves in PowerShell, Git Bash and WSL but not incmd.exe. Replace it with an absolute path if you usecmd.
Capability tiers
From app/core/config.py; reported at /health and printed in the UI status rail.
| Tier | Keys present | Memory | Retrieval |
|---|---|---|---|
snapshot |
none | frozen snapshots/pond.json + traversal answerer |
recorded fixtures only |
scout |
Bright Data | frozen snapshot | live Bright Data fetch |
full |
Bright Data + LLM (or Cognee Cloud) | live Cognee cognify / search / feedback | live Bright Data fetch |
SCOUT_FORCE_SNAPSHOT=1 pins snapshot memory for a rehearsal; SCOUT_ALLOW_LIVE_FETCH=0 keeps memory live
but takes Scout offline. One quirk worth knowing before you test it: the tier name tracks Scout, so an LLM
key without a Bright Data key still reports snapshot. The memory engine is reported separately and
independently — memory.engine is snapshot, cognee-local or cognee-cloud, derived from the same
branch memory() actually takes — and that pair is what the UI status rail shows.
Demo script (~2:30)
The beats are served by GET /api/demo/script (stale · multihop · live · cold · compare), so the
UI's demo buttons and this list cannot drift apart.
- 0:00–0:15 — the pond. Pre-built graph on screen, coloured by
node_set. Tier badge visible, top right. Say what the corpus is: one founder's email, docs, notes and calendar. - 0:15–0:55 — "Prep me for the Acme Robotics call." Recall answers from memory, then the gap panel
fills:
danglingon Tomas Brandt (0.592) andstaleon Priya Raghavan (0.546) — her title and employer last observed 428 days ago, past the 180-day shelf life. Scout fetches, Capy ingests, the answer comes back with a dated, cited correction. - 0:55–1:30 — "Who from Otterloop should own the Acme security review?" Top gap is
danglingon Tomas Brandt at0.779— one relationship, zero attributes — and the question never says his name; he is surfaced by relevance propagation. One fetch suppliespreviously_worked_at → Helios Labs; the new edge animates in;trace_pathprints "2 hop(s) (was 4)". The recommendation goes from "Dana, she owns compliance" to "Dana — she and Tomas overlapped at Helios Labs 2020–2024." - 1:30–1:55 — the baseline. Same question, same corpus, flat chunk retrieval
(
compare_flat_retrieval→RAG_COMPLETION). It returns four plausible paragraphs and cannot join evidence across four files — not because it is implemented badly, but because ranking chunks is the wrong operation for that question. - 1:55–2:15 — feedback. Thumbs-down with a correction →
add_feedback+memify→feedback_influence=0.35on the next search. Re-ask; retrieval has moved. (Atsnapshottier the same beat reweights the edges that answer leaned on and says on screen that the mechanism is the cruder stand-in.) - 2:15–2:30 — the closer, genuinely live. Either the
livebeat — "is our note on the Model Context Protocol version still current?", a real public fact our corpus pinned at2025-06-18— or hand over the keyboard: any company a judge names has no node, fires anunknowngap (priority 0.94) and is fetched on the spot, with the provenance stamp to prove it.
Repo layout
.
├── README.md
├── slides.html 5-slide pitch deck, self-contained
├── plan.md the build plan, kept for the record
├── .env.example every key, all optional — copy to backend/.env
├── .mcp.json cognee-mcp + Bright Data MCP, for the closer
├── backend/
│ ├── app/
│ │ ├── main.py FastAPI app
│ │ ├── core/{config,events}.py tiers · the Step event contract
│ │ ├── routes/api.py /api/* including the SSE run stream
│ │ └── services/{agent,memory,cognee_cloud,gaps,graph_view,brightdata}.py
│ ├── ontology/scout_capy.owl 7 classes + 9 subclasses, 16 relations
│ ├── scripts/
│ │ ├── build_snapshot.py compiles + asserts the demo's invariants
│ │ └── seed.py real cognee add + cognify over seed/
│ ├── seed/email/*.md the synthetic corpus
│ ├── snapshots/
│ │ ├── facts.py authored entity core, planted gaps
│ │ ├── pond.json frozen graph (40 nodes / 95 edges)
│ │ └── web/ curated fixtures, then _cache/
│ ├── tests/ 122 tests, network-guarded
│ └── pyproject.toml cognee · fastapi · strands-agents
└── frontend/
├── app/ layout, globals, page
├── components/{pond,trace,answer,compare,time,status,mascots,ui}/
├── hooks/, lib/ useAskRun · api (SSE) · palette · types
└── package.json next · 3d-force-graph · three · motion
Built With
- 3d-force-graph
- bright-data
- cognee
- elevenlabs
- fastapi
- next.js
- python
- react
- strands-agents
- three.js
- typescript