Opyt is an MCP server. It exposes twelve tools to whatever
AI client you already use, and stores everything in a single SQLite database at
~/.opyt. There is no UI, no account and no daemon: nothing is installed that
runs on a timer. Your client calls the tools, and the tools read and write that one
file.
It talks over stdio, which means your client starts it as a local process on your own machine and pipes to it. So it works in a desktop client: Claude Code, Claude Desktop, Cursor, Windsurf. It cannot work in a browser tab at claude.ai, which has no way to start a process on your machine.
Twelve tools, in roughly the order you meet them. Every argument, its type and its default, and what comes back.
| Tool | Effect | What it does |
|---|---|---|
| onboard | writes · re-entrant | Acquires the one key, reads your browser session, takes consent, runs the first import. |
| oracle | writes · spends | Screens the people your own signals already point at, confirms them, ingests them. |
| add_oracle | two-phase · spends | Adds one person by handle or URL, finds everywhere they publish, pulls the archive. |
| search | read-only | Ranked retrieval over the store, filterable by tag, kind, source, author and date. |
| open | read-only · free | The full stored text of one atom, plus its live source URL. |
| aggregate | read-only · free | Counts across the whole store rather than a sample: by topic, author, kind, date. |
| sitting | writes · spends | Reads every atom on one topic end to end, in publication order, through a lens. |
| frontier | writes · free | The ranked review queue of what your standing questions found. |
| hopper | two-phase · spends | Takes any URL, works out what it is, and saves it as an atom. |
| share | two-phase · publishes | Shows you what is in your store, then hands you a link that lets someone search it. |
| accept | single-phase · writes | Registers a knowledge base somebody shared with you, from the link they sent. |
| unshare | two-phase · deletes | Cuts off one reader, or every reader plus the served copy. |
The server owns only the last segment of each name, and a client
that namespaces adds its own prefix: search reaches Claude Code as
mcp__Opyt__search. Argument names are the ones below in every client.
Required arguments have no default and the call fails without them; optional
ones name their default. All twelve return a JSON object.
Sets up this machine. It recomputes where you are from disk on every
call, so calling it twice never repeats a finished step. It runs in up to three
calls, because two steps wait on a human: OpenRouter is authorised by OAuth
(a browser tab opens, you click Approve; nothing is pasted into chat), your browser
session is read for X cookies on this machine only, and consent is one
question with two separate commitments. Then it runs the free curation collectors and
hands off to oracle.
onboard(browser_profile=None, consent=None,
skip_github=True, guided_login=False) -> dict
settings.yaml, so you answer once."both" ·
"backlog" · "refresh" · "none".
backlog is the one-time X bookmark import, bounded by a $1.00/day
ceiling. refresh is the recurring job that keeps your Oracles
current. The recurring half can be switched off later; the one-time import
cannot be un-run.TrueFalse"ok" · "in_progress" · "needs_choice" ·
"needs_consent" · "needs_login" ·
"awaiting_login" · "warned" · "blocked" ·
"error".keys → browser →
consent → curation → done.needs_* status it is the question.oracle to screen."oracle" once setup is done.Decides which people the knowledge base deep-ingests. Candidates are drawn only from your own signals (follows, Lists, subscriptions, bookmarks, likes), so an author you never saved or followed is never a candidate. Ranking is lexicographic: person-level acts (follow, subscribe, add to a List) outrank content-level ones (bookmark, like), and anyone with two or more independent signals arrives pre-ticked. Four actions run the whole flow in chat.
oracle(action="screen", canonical_ids=None, add_handles=None,
top_n=30, floor=15, force=False, x_lookback=None,
web_lookback=None, query="", min_signals=1) -> dict
| action | Reads | What it does |
|---|---|---|
| "screen" | floor | Default. The ranked candidate list, each with its reflected signal
("you follow · subscribe · bookmarked 12×"), its pre_ticked flag and
its canonical_id. Also carries oracle_freshness
unasked: per-Oracle, per-source last-pulled times, worst first. |
| "candidates" | query, top_n, min_signals |
The same people ranked by what they write rather than how hard you
vouched. Evidence is either probed (a ~25-post timeline sample,
unvetted) or saved (a post you kept, so it is a real atom). Scores
compare only within a basis. |
| "confirm" | canonical_ids, add_handles |
Commits picks into the oracles table. Idempotent. Raw handles are
resolved at confirm time. |
| "ingest" | canonical_ids, force, x_lookback, web_lookback |
Deep-ingests each confirmed Oracle: discovery mines their blog or Substack for their other profiles, then each trusted personal profile is pulled as atoms. An org link becomes an affiliation edge instead. |
"screen"confirm, the candidates you
kept, verbatim from screen. On ingest, which confirmed Oracles to
pull; omitting it means all of them.confirm only. Raw X @handles or
Substack URLs to add beyond the ranked list.30candidates only. Not a screen knob: screen classifies and returns
every candidate.15screen
only. Minimum candidates shown before "see all". Nothing is ever hidden, only
folded.Falseingest only, and it means two things: ingest a source the
single-author gate would skip, and re-run discovery from scratch,
ignoring the trust cache."6mo""6mo" · "1yr" · "2yr" ·
"since_last". Hard-capped at two years whatever you pass.
since_last pulls only what has appeared since this Oracle's last
pull, so a five-day gap costs one request instead of about nineteen, and errors
rather than guessing if there was no previous pull."all""1yr" · "2yr" · "5yr" ·
"all". The archive is free and durable, so it already pulls
everything. This only narrows.""candidates only. A topic question to rank people by ("agent
memory"). Omit it for "who has been sampled".1candidates only. Floor on distinct curation signals; 1 is everyone.total_candidates, recommended_count,
shown_by_default_count, floor, classify,
candidates[], lookback_options,
oracle_freshness, and model_routing only when a model
is dead or fragile. Each candidate card carries canonical_id,
name, handle, kind,
is_person, corroborated, pre_ticked,
shown_by_default, distinct_signals,
total_count, reflected, signals,
identity_links, members.confirmed, unresolved (handles a lookup could not
find; nothing was written for those), unknown,
total_oracles.ingested_oracles, lookback (the windows that
actually ran, including the two-year X clamp), and
results[], one per-source outcome record each.kind (person, org, media, project, aggregator), so a
non-person can be described rather than silently dropped.classify.ran = false means the kind classifier degraded open because
the model was unavailable. Everyone stays person-eligible rather than being
filtered out.Adds a single person as an Oracle. Opyt discovers where they actually publish (X, GitHub, a Substack, a blog found by feed or sitemap), and verifies each profile belongs to them through links they attest to themselves, so it never merges two people with the same name. Two phases: a free preview showing who it resolved and what a confirm would do, then the pull once you agree.
add_oracle(reference, confirm=False, x_lookback=None,
web_lookback=None, extra_source_urls=None,
force=False) -> dict
"@karpathy"), a Substack, blog or site URL
("https://simonwillison.net"), or a canonical_id from
oracle(action="screen"), to promote a below-the-cut candidate.
There is no name-search endpoint: a name has to be resolved to a handle or URL
first.False"6mo""6mo" · "1yr" · "2yr" ·
"since_last", hard-capped at two years. since_last is
the top-up for someone already on the roster."all""1yr" · "2yr" · "5yr" ·
"all". Narrows only.followup below). They enter as low-confidence candidates and
are trust-checked like any other source, never trusted on the caller's say-so.
Individual posts and videos are dropped; send homes.Falseconfirm_required, reference, lookback,
on_confirm, mode ("new" or
"existing") and resolved: name, bio, followers, site
for an X handle; the roster entry for someone already known. A reference that
does not resolve comes back as unresolved with nothing to
confirm.added (canonical_id, name,
source, was_already_oracle), lookback,
ingest, and followup when the open-web step is worth
running.results[] plus counters: ingested,
blocked, errors, atoms_added,
discovery_ran_fresh, stage_seconds.ingested is not the whole story. blocked counts sources
where the host stopped the pull (Cloudflare, a truncated archive), and those wrote
nothing and retry on the next run. atoms_added and
dispatched diverge whenever posts are paywalled or fail the quality
gate: the lookback bounds posts attempted, not atoms saved.followup, because it already has web search and Opyt would
otherwise pay a second model for a worse version of it.Ranked retrieval over the whole store. An optional
tag / kind / source / author pre-filter, then a BM25 arm and a semantic arm, fused by
rank. Ranking is pure relevance: there is no trust or popularity re-rank. This is
a router, not an answer: each hit is a thin card carrying the matched chunk, a
pointer and the ranks, not a statement of what the source says. That split is the trust
model, and open is the
other half of it.
search(query, tags=None, what_kind=None, source_type=None,
who=None, who_id=None, date_from=None, date_to=None,
entry_mode=None, k=8, mode="hybrid", kb=None) -> dict
"ai-agents"). Matched as slugs: a value that
normalizes to nothing matches nothing, never "no filter", and a notice
says which value was dropped or rewritten."opinion" (saved posts) or
"artifact" (repos, papers)."x" · "github" · "substack" ·
"blog" · "paper"."@karpathy", "karpathy", a Substack or blog URL, or an
id. This is how you ask what one person said about a topic; putting their name in
query matches by content, so posts merely mentioning them
rank alongside posts they wrote. Resolved locally against people already in the
store: free, no network, and it never invents anyone."x:user:33836629"). A person's atoms sit under a per-platform id
each, so one id alone returns one platform's worth of them."2026", "2026-05" or "2026-05-11"; a
partial widens to its natural edge, so "2026" means 2026-01-01.
Any other shape raises rather than being silently dropped."2026" means 2026-12-31."user-saved" · "oracle-footprint" ·
"author_referenced" · "frontier". Leave it off
for the normal case, which returns a sectioned answer (see Returns). Set it to
scope the whole answer to one population instead: "frontier" with a
larger k is how you dig into the crawl. Scoping returns one list and
no frontier_atoms.8trace.cutoff gives the score at the boundary, so you can tell whether
raising it would have helped."hybrid""hybrid" · "semantic" · "bm25".
bm25 is the only mode that makes no network call at all.kb it came from,
"me" for your own.citation_id, atom_id,
kb, source_type, what_kind,
who_id, who_name, when_ts,
when_precision, description, snippet,
chunk_span, source_url, raw_ref,
score, entry_mode, bm25_rank,
sem_rank, body_state, body_basis and
payload.authors, sources,
topics, date_span, body_state,
saved_vs_crawled, corpus_newest,
filter_cost, and resolved_who when you passed
who. These describe hits only.ran (which arms
actually ran), score_scale, candidates,
ranked, showing, cutoff,
fts_query, filters as applied.entry_mode is left off. Same card shape, capped at 8, floored at a
fraction of its own top score. Its scores are ranked against other frontier
atoms and mean nothing next to the ones in hits.mode="bm25" sends
nothing at all, giving up the semantic arm in exchange.score is not comparable across calls unless
trace.score_scale matches. It is a raw cosine under
semantic, a reciprocal rank under bm25, and a fused rank sum
under a true hybrid run. 0.03 in one scale can outrank 0.7 in another.mode="hybrid" often runs only the semantic arm. A conceptual
query with no literal token (most natural-language questions over three words)
gives BM25 a weight of zero and the keyword arm is skipped. trace.ran
says what really ran.body_state qualifies every snippet: complete,
partial (a paywall teaser, a truncated feed entry), absent,
or pending. body_basis says how that was decided:
observed, stated or assumed.when_precision qualifies every date, and when_ts always
looks like a day. "year" means only the year is known and the
timestamp is that January 1 as a floor, so such an atom is included whenever its year
overlaps the window, deliberately. "push" is GitHub's last-push date,
not a publication date: a repo matching "after May" was active then and may
have been created years earlier. Atoms with no recorded date are excluded by either
bound, and a notice says how many.payload is not a fixed schema and is not filterable: it is
whatever that atom's source had, verbatim. GitHub atoms carry stars and
code_language, X atoms like_count and is_thread, papers citationCount and venue.Follows an atom's pointer and returns its real stored snapshot text plus
the live source URL, so a claim can always be traced back to where it was published.
search routes you to
an atom; this is the ground truth to reason from.
open(atom_id, kb=None) -> dict
{"error": "not found"} rather than
raising.kb
either finds nothing or hands back your own copy of the same source.raw_available says whether it is there at all."complete" · "partial" · "absent" ·
"pending", with body_basis saying how it was
determined.null for an atom read out
of a shared knowledge base. raw carries the body either way.atom_id, kb, source_type,
what_kind, who_id, when_ts,
when_precision, description, payload.raw is only as complete as body_state says. On
partial you hold a knowing fragment, so quoting it as the full article
invents a citation; attribute what is there and send the reader to
source_url.Counts across the entire store rather than a sample. This is what answers distributional questions: what you saved most of, when your interests moved, how much of the store comes from people you confirmed. Scope is optional: omit everything for the whole store.
aggregate(tags=None, what_kind=None, source_type=None,
who_id=None, date_from=None, date_to=None,
kb=None) -> dict
search."opinion" or
"artifact"."x" · "github" ·
"substack" · "blog" · "paper".search(who="@handle") first and pass its
insights.resolved_who[].who_ids here: resolution has one home
rather than two that can drift.search; any other shape raises.trusted_atoms then counts atoms whose author
that owner confirmed, not you.{topic, count}.{who_id, count}.{atom_id, description, who_id, when_ts}. These
are mechanical descriptions, so they can be read without opening, but a claim
still needs open.scope as applied, kb, and notices in the
same shape search uses, so one channel serves both.search, this tool does not report how many undated atoms that
dropped.Reads every atom on one topic end to end, in publication order. It is
the opposite of search:
search finds the few best-matching items, this assembles every item on a topic and
reads the whole set in date order. Search answers "where is it"; this answers "what
happened". A topic too big for one sitting is read in parts, oldest stretch first,
each part carrying forward the claims the earlier ones established and being asked to
confirm, revise or refute them.
sitting(action="preview", query=None, sitting_id=None, atom_ids=None,
floor=None, budget_tokens=None, lens=None, claim=None,
add=None, drop=None) -> dict
| action | Cost | What it does |
|---|---|---|
| "preview" | one embedding | Default. Names a topic and reports what is actually there: the region's size, the stretch of time it covers, how many people wrote it, a few items by name, and anything about its shape that would make a read disappointing. Calls no model. Not a permission step: it exists because a phrase can resolve to four items or two hundred and nobody can tell which in advance. |
| "read" | spends | Reads the assembled region with a model, under one of two lenses. Lens
queries (the default) emits the standing research questions that
drive
frontier.
Lens claims extracts 8–15 falsifiable claims instead, each naming
specifics, citing the atoms that support it, and stating what would prove it
wrong. |
| "render" | free | Hands back a region that was already built, as the document a reader would see. Nothing is re-grown and nothing is re-read. |
| "lens" | only on new material | Hands back an instruction and a document for the
calling model to read in the conversation and answer from. Each stretch
is summarized once ever, so asking the same lens again, or a different question
of the same lens, is free. |
| "watchlist" | free | The standing questions currently being watched, with how often each runs, how many times it has come up, and whether it was typed or proposed by a read. |
"preview"render; on read and lens it is the
alternative to query.read:
"queries" (default) or "claims". On lens:
"briefing" (what the material says, as knowledge) ·
"trajectory" (how the thinking moved: what changed, reversed, got
abandoned) · "disconfirmation" (what here would undermine a belief) ·
"gaps" (answer using only this material, and say how close it comes
if nothing does) · "sprouts" (everything no sitting has ever read;
needs no region at all). On preview, which lens's warnings to
compute.lens="disconfirmation" or lens="gaps". Without one,
disconfirmation red-teams the material's own apparent thesis.watchlist only. Questions to
start watching. These never decay and are removed only by drop.watchlist only. Retires a
question everywhere: the list is one list of questions, not a copy per
region."preview" · "ok" · "skipped" (with a
reason, most often already read) · "failed" ·
"error". A skipped or failed read wrote no queries and left the
region unread, so it can be retried once the reason is fixed.read with lens queries: how the conversation
moved, what reversed, what is unresolved, alongside the queries it emitted.read with lens claims. Each is
{claim, falsified_by, atom_ids}.lens. The document is not raw text: a
topic read across several sittings comes back as those per-stretch summaries,
labeled with the dates they cover.read of the same
region with the same lens is refused as already read: it would be the same input for
the same money. The two read lenses do not share that guard: a region read for
queries can still be read for claims.read or
lens actually consumes it.lens produces is never written anywhere (no queries, no
table, no record), because it is about the topic as it stands today and would be
wrong the moment anything is added.The review queue for everything the standing questions found: arXiv preprints, GitHub repos, and published literature across every discipline via OpenAlex. Ranked by convergence: how many of your independent standing questions turned up the same artifact. The ranking is recomputed on every call and never stored, so it measures your own interests overlapping rather than anyone's popularity. Nothing is ever pushed into your context, and nothing is filtered away.
frontier(limit=20, dismiss=None, include_dismissed=True) -> dict
20candidate_ids to stop
surfacing. Dismissals are written before the ranking runs, so anything
dismissed in a call comes back in that same response labeled
dismissed rather than vanishing between request and answer.TrueFalse hides dismissed candidates and reports how many it hid, under
hidden_by_include_dismissed.candidate_id, source,
title, url, published,
summary, payload, state,
shown_before, queries (how many standing questions
found it), score, and why, the ordering, explained."new" · "seen" · "dismissed" ·
"materialized" (stage 3 put it in the knowledge base) ·
"rejected" (the fetch mechanically failed, never a quality
judgment; stage 3 has no judge).Takes any URL (a paper, repo, Substack post, article or single X post),
detects what it is, routes it to the right ingester, and shows you what it found before
writing anything. This is the only manual save path; there is no separate
save-a-paper or save-a-repo tool. Every atom it stores is stamped
entry_mode="user-saved", the same mark an X bookmark gets, and that is
load-bearing downstream: hand-saved items steer the standing research queries.
hopper(reference, confirm=False, kind_hint=None) -> dict
.pdf), a GitHub repo, a Substack post, or an
x.com status link. Anything that is not an http(s) URL comes back
unroutable with nothing written, rather than being filed somewhere
plausible.False"substack" is the one value worth passing: a Substack post on a
custom domain cannot be detected without fetching, and routed as a plain article
it gets a different atom id and never dedupes against the same post saved from a
bookmark. "paper", "github" and "x" cannot
override anything: those adapters check the host themselves."preview" · "saved" ·
"already_present" · "rejected" (the page was nav or
promo boilerplate, not an error) · "blocked" (a bot-check;
retryable) · "failed" · "unroutable" ·
"budget_paused". Every non-saved status wrote nothing.routable, kind, why (what the route was
decided from), atom_id, already_present,
entry_mode, cost, and a note for the two
kinds whose id cannot be predicted offline.rejected, blocked and failed: what
actually happened, and whether it is worth retrying.saved result, marks a degraded success: most often a
paper whose full text landed but whose metadata lookup was throttled, so it is
stored with no title, date or author. The atom is searchable by its body, but
re-saving will not repair it.x.com status: the post is read (about $0.00015) and
returned as a description, because a bare id like
x:2086520133909168332 is unverifiable by a human and that description is
the only way to catch a wrong link.failed: the same cookie-less public endpoint a
reader hits, so it cannot see past a wall either.already_present in a preview means a confirm is a no-op.budget_paused can come back from either phase. It means the
daily runaway guard tripped: that much spend in one day means something is looping.
Nothing was fetched and nothing was written. It resets at UTC midnight.add_oracle
is the only way in.Takes an invite somebody sent you and registers their knowledge base
locally. After one call, search, open and
aggregate accept kb="their-name". There is deliberately no
preview: a grant code buys one reader token and then dies, so checking it would spend it,
and pasting the invite is already the yes.
accept(invite, name=None) -> dict
kb= should call this knowledge
base on this machine. Omitted, the owner's own name is used."accepted" ·
"code_unavailable" (already used, or not one this service issued) ·
"not_an_invite". Both failures registered nothing.kb=. Use
this string: a name already taken on this install is suffixed rather than
overwritten, because the registry holds the only copy of each reader token in
existence and replacing one destroys access nothing can restore.Six curation feeds are read from the browser session you are already signed into, with no API key and no password. Everything else is public or handed over by you.
| X | bookmarks · likes · follows · Lists | your browser session |
|---|---|---|
| Substack | subscriptions · saved posts, full text | your browser session |
| GitHub | a tracked person's repos and READMEs | public |
| Blogs | a tracked person's whole archive, found by feed or sitemap | public |
| arXiv · OpenAlex | papers matching your standing questions | public |
| Any URL | whatever you hand to hopper | you |
Opyt only reads. It never posts, follows, likes, or changes anything on any account. Opening a session is what starts a pull: the server fires each feed as a one-pass job that outlives the session, so a long pull finishes after you close the client and a session opened minutes ago starts nothing new. Nothing runs on a timer, so the store grows on the days you open a session and not on the days you don't.
This is the part that runs without you. Reading a topic is what creates the watchers for it.
sitting raises questions,
and those become durable queries. A question's identity is a hash of its normalized text,
so asking the same thing twice does not create a second watcher.frontier
when you feel like it, ranked by how many
of your own questions independently converged on the same thing.You say "share my knowledge base" and get a link. They paste it. Their next question can draw on everything you have read. Nobody opens a terminal, and the copy keeps itself current.
share shows you what
is in there first (how many atoms, whose, over what span), then registers you, starts
the upload, and hands back a link. Publishing is self-service: no invitation, no
account, no waiting. The first upload runs in the background and takes a minute or two;
the link works immediately.accept registers
your knowledge base on their machine, and from then on their agent passes
kb="your-name" to
search,
open and
aggregate.
Those three over HTTPS, plus the lookup that tells their install which embedding model
to match. No upload, no grant, no write of any kind.unshare(reader="Leo")
cuts off Leo and leaves the copy serving for everybody else.
unshare()
cuts off every reader and deletes the copy from the service, in one act. Either way it
takes effect on that reader's very next request: there is no refresh cycle and no
window.Every result from a shared knowledge base is labeled with whose it is, so an answer built from a friend's research says so. It goes one way: accepting gives you read access and gives them nothing, and Opyt offers once, after your first search that finds something, to share yours back.
The invite code travels in the URL fragment, which a browser never transmits, so no server ever sees it. The name a reader searches under is theirs to pick and never has to be unique; the address in the URL is an assigned key nobody types. What the service records is that a read happened, never what was searched for. TELEMETRY.md is the full schema, checked against the code by a test.
Opyt is free and MIT-licensed. The reading and reasoning run on the AI subscription you already pay for. One key covers everything else.
| Your AI client | every question, every answer, all the reasoning | your existing subscription |
|---|---|---|
| OpenRouter | sorting, extracting, and embedding what comes in, on a cheap open model | pay-as-you-go credits |
| X | posts, timelines, profiles, threads | free, through your own browser |
| Everything else | papers, blogs, GitHub, Substack, any URL you hand it | free |
Anything that costs money shows you a preview and waits for a yes. A call that fails writes nothing and is not marked done, so a failure never leaves a partial record behind.