Something went wrong. Try again.
GET /xrpc/tech.waow.typeahead.searchActors typeahead.waow.tech
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288import { FAVICON } from "../types";import { THEME_HEAD, THEME_CSS, THEME_SCRIPT, ogTags } from "./theme";
export function docsPage(): string { return `<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> ${ogTags({ title: "typeahead — docs", description: "Point your app at typeahead in one line. The canonical tech.waow.typeahead.searchActors endpoint, a compatible app.bsky.actor.searchActorsTypeahead alias, and profile cards without viewer state.", path: "/docs" })} ${FAVICON} ${THEME_HEAD} <style>${THEME_CSS} body { display: flex; justify-content: center; padding: calc(1.5rem + env(safe-area-inset-top)) 1rem calc(2rem + env(safe-area-inset-bottom)); } .container { max-width: 620px; width: 100%; } .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } h1 { font-size: 1.1rem; font-weight: 400; color: var(--muted); } .subtitle { font-size: 0.8rem; color: var(--dim); margin-bottom: 1.5rem; } h2 { font-size: 0.85rem; font-weight: 600; color: var(--text); margin: 1.5rem 0 0.6rem; } h3 { font-size: 0.8rem; font-weight: 600; color: var(--muted); margin: 1.2rem 0 0.5rem; } p, li { font-size: 0.8rem; color: var(--muted); line-height: 1.6; } p { margin-bottom: 0.7rem; } ul { margin: 0 0 0.7rem 1.2rem; } li { margin-bottom: 0.3rem; } code { font-size: 0.75rem; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 0.1rem 0.35rem; color: var(--text); } pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.8rem; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: 0.7rem; } pre code { background: none; border: none; padding: 0; font-size: 0.72rem; color: var(--text); } .diff-add { color: var(--accent); } .diff-del { color: var(--red); } .kw { color: var(--violet); } .str { color: var(--accent); } .fn { color: var(--blue); } .cm { color: var(--dim); font-style: italic; } .op { color: var(--muted); } .callout { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 8px; padding: 0.7rem 0.9rem; margin-bottom: 1rem; font-size: 0.78rem; color: var(--muted); line-height: 1.6; } .callout strong { color: var(--text); } .callout.warn { border-left-color: var(--amber); } table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.75rem; } th { text-align: left; color: var(--dim); font-weight: 400; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--line); } td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--line); color: var(--muted); } td code { font-size: 0.7rem; } .yes { color: var(--accent); } .no { color: var(--dim); } footer { padding-top: 1.5rem; border-top: 1px solid var(--line); font-size: 0.7rem; color: var(--faint); display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 0.4rem 0.6rem; } footer a { color: var(--dim); text-decoration: none; padding: 0.35rem 0.15rem; } footer a:hover { color: var(--muted); } a { color: var(--blue); text-decoration: none; } a:hover { text-decoration: underline; } </style></head><body> <div class="container"> <div class="header"> <h1><a href="/" class="wordmark">typeahead</a> docs</h1> </div> <p class="subtitle">community actor search for atproto</p>
<div class="callout"> <strong>tl;dr</strong> — use <code>/xrpc/tech.waow.typeahead.searchActors</code> on <code>typeahead.waow.tech</code>. migrating from bluesky's typeahead? just swap the base URL — your existing <code>app.bsky.*</code> path keeps working (see <a href="#switching-from-bluesky">switching from bluesky</a>). </div>
<h2>what this is</h2> <p> typeahead is a community-run actor search for <a href="https://atproto.com">atproto</a>, served under its own lexicon. it covers everything bluesky's <code>app.bsky.actor.searchActorsTypeahead</code> does — same query params, compatible but slimmer response (see <a href="#response-comparison">response comparison</a> below) — without bluesky in the request path. </p> <p> accounts are discovered from the relay's event stream (<a href="https://atproto.com/specs/event-stream">com.atproto.sync.subscribeRepos</a>) — the relay announces every new identity and handle change, and we also pick up anyone who writes a profile, post, like, or follow, wherever their PDS is hosted. accounts that predate the index and have been quiet since, or whose PDS the relay doesn't crawl, can be surfaced on demand (see <a href="#request-indexing">request indexing</a> below). </p> <p> every search is served entirely from our own data: a precomputed prefix-index snapshot merged with a live overlay for recent renames, deletes, moderation changes, and brand-new accounts, then edge-cached for 60s. no request does work proportional to the full corpus (millions of actors), and bluesky is never in the request path — it's a rollback-only kill switch, not a fallback. </p>
<h2>the endpoint</h2> <p> the canonical endpoint lives under our own <a href="https://atproto.com/specs/lexicon">lexicon</a> namespace (<a href="https://tangled.org/zzstoatzz.io/typeahead/blob/main/lexicons/tech.waow.typeahead.searchActors.json">schema</a>): </p> <pre><code>GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=...&limit=10</code></pre> <p> params are <code>q</code> and <code>limit</code> (1–100), returning <code>{ "actors": [...] }</code>. this is the endpoint to use — <code>app.bsky.*</code> names belong to bluesky's lexicons, and this service isn't bluesky. there's also a machine-readable summary at <a href="/llms.txt">/llms.txt</a>. </p>
<h2 id="switching-from-bluesky">switching from bluesky</h2> <p> for backwards compatibility, the bluesky path <code>/xrpc/app.bsky.actor.searchActorsTypeahead</code> is served as an alias — it behaves identically to the canonical endpoint and is kept indefinitely, so migrating an existing integration is just a base-URL swap: </p> <pre><code><span class="diff-del">- https://public.api.bsky.app</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&limit=10<span class="diff-add">+ https://typeahead.waow.tech</span>/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&limit=10</code></pre>
<h3>before</h3> <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( <span class="str">\`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&limit=10\`</span>);</code></pre>
<h3>after</h3> <pre><code><span class="kw">const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>;
<span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&limit=10\`</span>);</code></pre>
<p> extracting the base URL into a constant (or env var) makes it easy to switch back if you ever need to. that rollback only works while you're on the alias path — bluesky doesn't serve <code>tech.waow.typeahead.searchActors</code>. once you no longer need the escape hatch, move the path to the canonical endpoint too — the response is identical. </p>
<h2>identify your app</h2> <p> set the <code>X-Client</code> header so your app shows up by name in our <a href="/stats">traffic stats</a> instead of as "unknown": </p> <pre><code><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( <span class="str">\`https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=\${<span class="fn">encodeURIComponent</span>(query)}&limit=10\`</span>, { headers: { <span class="str">'X-Client'</span>: <span class="str">'my-app.example.com'</span> } });</code></pre> <p> ideally use your domain — the <a href="/stats">stats page</a> links each source to <code>https://<your-value></code>, so a domain gives you a working link (a bare app name works too, it just won't link anywhere). browser-based apps will also be identified automatically via the <code>Origin</code> header, but <code>X-Client</code> is preferred since it works everywhere (server-side, CLI, native apps). </p>
<h2 id="response-comparison">response comparison</h2> <p> both return <code>{ "actors": [...] }</code>. the actor objects differ: </p> <table> <tr><th>field</th><th>bluesky</th><th>typeahead</th></tr> <tr><td><code>did</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>handle</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>displayName</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>avatar</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>associated</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>labels</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>createdAt</code></td><td class="yes">✓</td><td class="yes">✓</td></tr> <tr><td><code>viewer</code></td><td class="yes">✓</td><td class="no">—</td></tr> </table>
<div class="callout warn"> <strong>if you depend on <code>viewer</code></strong> — this API doesn't return it (it requires authentication). we return did + handle + displayName + avatar + associated + labels + createdAt, which covers the full <code>profileViewBasic</code> surface minus <code>viewer</code>. </div>
<h2 id="profiles">profiles</h2> <p> once a user picks an actor, render a card without a second trip to bluesky. <code>app.bsky.actor.getProfiles</code> (up to 25 <code>actors</code>) and <code>app.bsky.actor.getProfile</code> (one <code>actor</code>) serve the profile-card subset of <code>profileViewDetailed</code>: <code>displayName</code>, <code>description</code>, <code>avatar</code>, <code>banner</code>, <code>followersCount</code> / <code>followsCount</code> / <code>postsCount</code>, <code>associated</code>, <code>labels</code>, <code>createdAt</code>, and <code>indexedAt</code> (when our row was last written). </p> <pre><code>GET https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfile?actor=zzstoatzz.io</code></pre> <p> counts come from the bluesky appview via enrichment and are <em>omitted</em>, not zero, until that path has seen the actor. viewer state, verification, and live status are not served — they need the caller's social graph, which typeahead does not hold. </p>
<h2>operational notes</h2> <ul> <li><strong>rate limited</strong> — 60 req/min per IP</li> <li><strong>cached</strong> — results are edge-cached for 60s, so very recent profile changes may lag</li> <li><strong>limit range</strong> — <code>1–100</code> (bluesky defaults to 10)</li> <li><strong>moderation</strong> — see <a href="#moderation">moderation</a> below</li> <li><strong>CORS</strong> — enabled, so browser-based apps can call it directly</li> </ul>
<h2>inspect ranking</h2> <p> if a result looks surprising, use the <a href="/reputation">ranking report</a>. it shows the stored ranking score, the score if recalculated now, percentile among visible scored actors, and a few example query positions. </p> <p> ranking score and visibility are separate. follower/post/age signals affect the score; fresh accounts are penalized while very new. moderation labels, domain blocks, and overrides decide whether an actor can appear at all. </p>
<h2 id="moderation">moderation</h2> <p> atproto is a network, not a single app. bluesky PBC runs the largest appview and applies its own moderation labels — <code>!hide</code>, <code>!takedown</code>, <code>!suspend</code>, <code>spam</code> — which hide actors from its search results. we respect these labels by default: actors flagged by bluesky's moderation service (<code>did:plc:ar7c4by46qjdydhdevvrndac</code>) are excluded from search. </p> <p> however, not every account banned by bluesky PBC is harmful. some were suspended for policy disputes, handle issues, or reasons unrelated to abuse. because atproto is designed for <a href="https://atproto.com/articles/intent">credible exit</a>, we think these people should still be findable — identity is not content moderation. </p> <p> to support this, when bluesky's API refuses to serve an account the index enriches it from <a href="https://blacksky.community" target="_blank" rel="noopener">blacksky's</a> appview or from its PDS directly (<code>com.atproto.repo.getRecord</code>), so the profile stays discoverable. an appview takedown alone does not hide an account here; the labels above, the slur filter below, and admin overrides do. accounts a PDS has suspended or deleted leave the network's event stream as inactive and are removed from the index. </p> <p> handles containing explicit slurs are always filtered, regardless of override status. these regexes are <a href="https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/explicit-slurs.ts">ported from bluesky's own PDS</a> and catch Unicode diacritics, Cyrillic substitutions, and leetspeak. </p>
<h2>example: plyr.fm</h2> <p> <a href="https://tangled.sh/zzstoatzz.io/plyr.fm">plyr.fm</a> uses typeahead for actor search. the integration looks roughly like: </p> <pre><code><span class="cm">// config.ts</span><span class="kw">export const</span> TYPEAHEAD_URL <span class="op">=</span> <span class="str">'https://typeahead.waow.tech'</span>;
<span class="cm">// HandleSearch.svelte</span><span class="kw">const</span> response <span class="op">=</span> <span class="kw">await</span> <span class="fn">fetch</span>( <span class="str">\`\${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=\${<span class="fn">encodeURIComponent</span>(query)}&limit=10\`</span>);<span class="kw">const</span> data <span class="op">=</span> <span class="kw">await</span> response.<span class="fn">json</span>();<span class="kw">const</span> actors <span class="op">=</span> (data.actors ?? []).<span class="fn">map</span>(actor <span class="op">=></span> ({ did: actor.did, handle: actor.handle, display_name: actor.displayName ?? actor.handle, avatar_url: actor.avatar ?? <span class="kw">null</span>,}));</code></pre>
<h2 id="request-indexing">request indexing</h2> <p> if someone isn't showing up in results, you (or your users) can request indexing from the <a href="/">homepage</a>. newly created accounts are picked up automatically from the relay's event stream, but accounts created before the index existed — or hosted on a PDS the relay doesn't crawl — may need a manual nudge. </p>
<footer> <a href="/">← home</a> · <a href="/stats">stats</a> <button class="theme-btn" id="theme-toggle" type="button">theme: auto</button> </footer> </div> <script>${THEME_SCRIPT}</script></body></html>`;}