Developer docs
Add Atmosphere “Open in…” links, smart client recommendations, compose intents, and AT-URI resolution to your own app, with two MIT-licensed packages and a public API.
@aturi.to/waypointson npmx ↗Building with an LLM or coding agent? Grab the whole page as Markdown.
Overview
The same waypoint catalog, recommendations, and link logic that power aturi.to are published as two standalone, MIT-licensed npm packages so you can drop them into any Atmosphere (AT Protocol) app:
@aturi.to/waypoints, a zero-dependency, framework-agnostic core: the client catalog, per-client “Open in…” link builders, recommendations, and URL ⇄ AT-URI resolution. Works in the browser, Node 18+, and edge runtimes.@aturi.to/waypoints-react: a headless-first React picker UI plus client icons, built on the core. Ships zero CSS by default and is fully themeable.
Both are dual-licensed MIT (the aturi.to app itself is GPL-3.0) to remove the adoption barrier. Prefer not to install anything? The hosted Resolve API does the same work over HTTP.
@aturi.to/waypoints
The zero-dependency core. Turn an AT URI into per-client links, recommend the best client for a record type, and reverse-resolve a pasted URL back into an AT URI.
npm install @aturi.to/waypointsResolve an AT URI or a pasted URL
import { resolveAtUri, resolveUrl } from '@aturi.to/waypoints';
// AT URI -> waypoints
const result = resolveAtUri('at://did:plc:abc/app.bsky.feed.post/3k7');
result?.waypoints; // [{ id: 'anisota', name: 'Anisota', category, url }, ...]
result?.recommended; // { ids: ['bluesky', 'anisota', ...], label: 'Recommended for Posts' }
// Pasted page URL -> waypoints (offline pattern match)
const fromUrl = await resolveUrl('https://bsky.app/profile/alice.bsky.social/post/3k7');What’s included
- High-level resolvers:
resolveAtUri,resolveUrl,buildWaypointsForParsed, andresolveViaApi(a typed client for the hosted endpoint). - Catalog & recommendations:
getWaypointDataForType,getCategorizedWaypointsData,getRecommendedWaypointsData, and the rawWAYPOINT_DESTINATIONS_DATAcatalog. - Parsing:
parseURI,parseAtUri,matchSupportedUrl,resolveHandle. - Capabilities:
supportsComposeIntent,getComposeIntentUrl,getComposeIntentWaypoints— see compose intents.
A handful of destinations (pdsls, atp.tools, Margin, Grain, Popfeed) only produce useful URLs when a DID is known; they’re filtered out unless a DID is available, so pass one in or supply a resolveHandle to resolveUrl. Full reference in the package README.
@aturi.to/waypoints-react
A drop-in React “Open in…” picker. Headless-first: it ships zero CSS and emits stable, namespaced styling hooks, so you can use your own design system, opt into the polished theme, or drop down to a hook and render everything yourself. It re-exports the entire core, so a single install gives you the components and the resolvers.
npm install @aturi.to/waypoints-react
# peers (you almost certainly already have react/react-dom):
npm install react react-dom lucide-react1. Drop-in picker
Renders clean semantic markup with no CSS attached. Every element carries a data-aturi-wp attribute and an aturi-wp-* class; map your own via classNames, pass unstyled to drop the built-ins, or replace rows with the renderWaypoint prop.
import { WaypointPicker } from '@aturi.to/waypoints-react';
<WaypointPicker
type="post"
handle="alice.bsky.social"
collection="app.bsky.feed.post"
rkey="3k7qw..."
/>;2. The useWaypoints hook
For full control, the hook returns render-ready data plus copy / open helpers: no markup at all.
import { useWaypoints } from '@aturi.to/waypoints-react';
function MyPicker() {
const { recommended, categories, waypoints, copy, open } = useWaypoints({
type: 'post',
handle: 'alice.bsky.social',
collection: 'app.bsky.feed.post',
rkey: '3k7qw...',
});
return (
<ul>
{waypoints.map((w) => (
<li key={w.id}>
{w.icon}
<button onClick={() => open(w.url)}>{w.name}</button>
<button onClick={() => copy(w.url)}>Copy</button>
</li>
))}
</ul>
);
}3. The polished theme (opt-in)
Want the Aturi look without writing CSS? Import the stylesheet once. It targets the namespaced classes and is fully themeable via --aturi-wp-* CSS custom properties (with light/dark defaults).
import '@aturi.to/waypoints-react/styles.css';
import { WaypointPicker } from '@aturi.to/waypoints-react';Server vs. client: the package is a client component (it carries "use client"), so it works out of the box in the Next.js App Router. For framework-agnostic helpers inside a Server Component, import them from @aturi.to/waypoints directly.
Resolve API
Don’t want to install anything? Hit the hosted endpoint from a share sheet, an Apple Shortcut, or any client: no login, no API keys. It returns the resolved waypoints and recommendations for a page URL or an AT URI.
GET https://aturi.to/api/resolve?url=<encoded-page-url>
GET https://aturi.to/api/resolve?atUri=at://...The core package’s resolveViaApi() is a typed client for this endpoint. It’s the right choice from a browser, where fetching arbitrary pages is blocked by CORS.
To ask about the catalog itself rather than a specific record — what’s in it, and which clients can do what — there’s a companion endpoint: GET /api/waypoints, filterable by ?type= and ?capability=.
Compose intents
bsky.app can be handed a link that opens its composer pre-filled: /intent/compose?text=… (see the intent link docs). Clients forked from the official social app inherit the same route, so the catalog records which ones do — and every waypoint carries a composeIntent, null when the client has no confirmed route.
import {
WAYPOINT_DESTINATIONS_DATA,
getComposeIntentUrl,
getComposeIntentWaypoints,
supportsComposeIntent,
} from '@aturi.to/waypoints';
// Which clients will open a composer for you?
getComposeIntentWaypoints().map((w) => w.id);
// ['anisota', 'bluesky', 'impro', 'blacksky', 'witchsky', 'mu', 'deer', 'northsky']
supportsComposeIntent(WAYPOINT_DESTINATIONS_DATA.deer); // true
getComposeIntentUrl(WAYPOINT_DESTINATIONS_DATA.deer, 'hello from my app');
// 'https://deer.social/intent/compose?text=hello%20from%20my%20app'Two nuances worth reading off the data rather than assuming. prefillsText is false for a client that routes the intent but ignores the text, so a “share this” link would open an empty composer — fine as a jump, useless as a share. And appUrl appears only where the client publishes a native scheme, so it’s a bonus, not a fallback.
Over HTTP, the same data comes back on both endpoints. Pass the text to get finished links, or take urlTemplate and substitute the URL-encoded text for {text} yourself.
GET https://aturi.to/api/waypoints?capability=compose
GET https://aturi.to/api/waypoints?capability=compose&text=<encoded-text>
# or inline, on any resolve call:
GET https://aturi.to/api/resolve?atUri=at://...&composeText=<encoded-text>{
"id": "deer",
"name": "Deer",
"category": "blueskyForks",
"composeIntent": {
"url": "https://deer.social/intent/compose?text=hello",
"urlTemplate": "https://deer.social/intent/compose?text={text}",
"textParam": "text",
"prefillsText": true
}
}In React, each useWaypoints entry carries the same composeIntent; pass composeText to the hook to have the links built for you.
Build an aturi.to link
A universal link is the client-agnostic address of a record: paste an aturi.to/… URL anywhere and the recipient gets a preview plus every client that can open it, rather than being pushed into whichever app you happen to use. It’s just a URL, so no SDK is required. The core package builds it from anything that names a record, and adds the strings a copy button or a share sheet needs around it.
import {
buildUniversalLink,
describeUniversalLink,
} from '@aturi.to/waypoints';
// Anything that names a record: an AT URI, a handle, a DID, or a page URL
// from any client in the catalog.
buildUniversalLink('at://did:plc:abc/app.bsky.feed.post/3k7');
// 'https://aturi.to/profile/did:plc:abc/post/3k7'
buildUniversalLink('https://bsky.app/profile/alice.bsky.social/post/3k7');
// 'https://aturi.to/profile/alice.bsky.social/post/3k7'
// Everything a copy button or a share sheet needs:
const link = describeUniversalLink('at://alice.bsky.social/app.bsky.feed.post/3k7');
link.label; // 'Post by @alice.bsky.social'
link.share; // { title, text, url }; hand it to navigator.share()
link.snippets.markdown; // '[Post by @alice.bsky.social](https://aturi.to/…)'parseUniversalLink goes the other way, turning an aturi.to URL back into an AT URI. In React, <UniversalLinkButton> is the whole control: a native share sheet in browsers that implement navigator.share, the clipboard in the ones that don’t. useUniversalLink is the same logic without markup.
import { UniversalLinkButton } from '@aturi.to/waypoints-react';
// Native share sheet on phones, clipboard everywhere else.
<UniversalLinkButton target={post.uri} />Make your own pages resolvable
If your app renders atproto records, buildUniversalLinkTags writes the <head> tags that let the rest of the Atmosphere find its way back to them.
import { buildUniversalLinkTags } from '@aturi.to/waypoints';
buildUniversalLinkTags('at://did:plc:abc/app.bsky.feed.post/3k7').html;
// <meta name="at:canonical" content="at://did:plc:abc/app.bsky.feed.post/3k7" />
// <meta name="at:author" content="at://did:plc:abc" />
// <link rel="alternate" href="at://did:plc:abc/app.bsky.feed.post/3k7" />
// <link rel="alternate" type="application/json+oembed" href="https://aturi.to/api/oembed?url=…" />at:canonical is the AT Tags proposal. The Aturi extension reads it off the live page and the Resolve API reads it off your HTML, so a link to your page resolves into every other client that can open the record, without your app being in the catalog at all. The oEmbed pointer is emitted for posts, so a link to your page previews as the post it is. They’re static strings describing a record you already display, and serving them hands nothing to aturi.to.
License
@aturi.to/waypoints and @aturi.to/waypoints-react are MIT © atpotato, LLC. The aturi.to web app and browser extension are licensed GPL-3.0; the packages are intentionally dual-licensed MIT so other Atmosphere developers can adopt them freely.
Building an Atmosphere client and want it in the catalog? See the supported waypoints or open a PR.