community actor search for atproto
/xrpc/tech.waow.typeahead.searchActors on typeahead.waow.tech.
migrating from bluesky's typeahead? just swap the base URL — your existing
app.bsky.* path keeps working (see
switching from bluesky).
typeahead is a community-run actor search for atproto,
served under its own lexicon. it covers everything bluesky's
app.bsky.actor.searchActorsTypeahead does — same query params, compatible
but slimmer response (see response comparison below) —
without bluesky in the request path.
accounts are discovered from the relay's event stream (com.atproto.sync.subscribeRepos) — 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 request indexing below).
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.
the canonical endpoint lives under our own lexicon namespace (schema):
GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=...&limit=10
params are q and limit (1–100), returning
{ "actors": [...] }. this is the endpoint to use —
app.bsky.* names belong to bluesky's lexicons, and this service isn't
bluesky. there's also a machine-readable summary at /llms.txt.
for backwards compatibility, the bluesky path
/xrpc/app.bsky.actor.searchActorsTypeahead 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:
- https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&limit=10
+ https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=...&limit=10
const response = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=10`
);
const TYPEAHEAD_URL = 'https://typeahead.waow.tech';
const response = await fetch(
`${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=10`
);
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 tech.waow.typeahead.searchActors. once you no
longer need the escape hatch, move the path to the canonical endpoint too — the
response is identical.
set the X-Client header so your app shows up by name in our
traffic stats instead of as "unknown":
const response = await fetch(
`https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=${encodeURIComponent(query)}&limit=10`,
{ headers: { 'X-Client': 'my-app.example.com' } }
);
ideally use your domain — the stats page links each source
to https://<your-value>, 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 Origin header,
but X-Client is preferred since it works everywhere (server-side, CLI, native apps).
both return { "actors": [...] }. the actor objects differ:
| field | bluesky | typeahead |
|---|---|---|
did | ✓ | ✓ |
handle | ✓ | ✓ |
displayName | ✓ | ✓ |
avatar | ✓ | ✓ |
associated | ✓ | ✓ |
labels | ✓ | ✓ |
createdAt | ✓ | ✓ |
viewer | ✓ | — |
viewer — this API doesn't return it
(it requires authentication). we return did + handle + displayName + avatar +
associated + labels + createdAt, which covers the full profileViewBasic
surface minus viewer.
once a user picks an actor, render a card without a second trip to bluesky.
app.bsky.actor.getProfiles (up to 25 actors) and
app.bsky.actor.getProfile (one actor) serve the profile-card
subset of profileViewDetailed: displayName, description,
avatar, banner, followersCount / followsCount /
postsCount, associated, labels, createdAt,
and indexedAt (when our row was last written).
GET https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfile?actor=zzstoatzz.io
counts come from the bluesky appview via enrichment and are omitted, 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.
1–100 (bluesky defaults to 10)if a result looks surprising, use the ranking report. it shows the stored ranking score, the score if recalculated now, percentile among visible scored actors, and a few example query positions.
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.
atproto is a network, not a single app. bluesky PBC runs the largest appview and applies its
own moderation labels — !hide, !takedown, !suspend,
spam — which hide actors from its search results. we respect these labels by
default: actors flagged by bluesky's moderation service
(did:plc:ar7c4by46qjdydhdevvrndac) are excluded from search.
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 credible exit, we think these people should still be findable — identity is not content moderation.
to support this, the index enriches banned/suspended accounts via their PDS directly
(using com.atproto.repo.getRecord), so profiles are discoverable even when
bluesky's API refuses to serve them. these accounts start hidden and can be explicitly
un-hidden via an admin override on a case-by-case basis.
handles containing explicit slurs are always filtered, regardless of override status. these regexes are ported from bluesky's own PDS and catch Unicode diacritics, Cyrillic substitutions, and leetspeak.
plyr.fm uses typeahead for actor search. the integration looks roughly like:
// config.ts
export const TYPEAHEAD_URL = 'https://typeahead.waow.tech';
// HandleSearch.svelte
const response = await fetch(
`${TYPEAHEAD_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=10`
);
const data = await response.json();
const actors = (data.actors ?? []).map(actor => ({
did: actor.did,
handle: actor.handle,
display_name: actor.displayName ?? actor.handle,
avatar_url: actor.avatar ?? null,
}));
if someone isn't showing up in results, you (or your users) can request indexing from the homepage. 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.