A walk along ActivityPlug
ActivityPlug gives ActivityPub servers with incompatible client APIs — Mastodon, Misskey, Pleroma, Hollo, Hackers' Pub — one shared API. You can hold it two ways: as TypeScript adapters plugged into your own code, or as a proxy server that speaks unified GraphQL and REST. It is two months old, version 0.0.0, not yet on npm, and there is no README. It is also the most honest API design I have walked through in a long time: every feature answers supported, unsupported, or unknown, with a reason attached, and the errors keep that same posture at runtime.
I spent an evening plugging real servers into it. Everything below comes from that walk, and if you build anything that touches more than one kind of fediverse server — a bot, a client, a bridge — this is a layer worth knowing now, before it has a version number.
The problem it solves
Fediverse servers agree on federation: server to server, everyone speaks ActivityPub. Client to server, they agree on nothing. Mastodon speaks REST with Link headers, Misskey speaks its own POST-everywhere RPC, Hackers' Pub speaks GraphQL. Every client author fills that gap by hand, every time. ActivityPlug fills it once, underneath.
Setup is short: clone the repo and run pnpm build. Node 24 and pnpm 10 are all it takes.
Library mode: plug an adapter into a server
I started with my home server, sukhi.f3liz.casa — it speaks the Mastodon-compatible API but is not Mastodon.
import { createActivityPlugClient } from "@activityplug/core";
import { createMastodonAdapter } from "@activityplug/mastodon";
const client = createActivityPlugClient({
adapter: createMastodonAdapter(),
origin: "https://sukhi.f3liz.casa",
});
const page = await client.timelines.public({ page: { limit: 3 } });No auth, no ceremony. The first "post" that came back made me smile:
text: This server does not serve a federated timeline (the muddy
torrent of every public post on the network). …sukhi has an odd design — instead of a federated firehose it returns a single guide post — and ActivityPlug delivered that quirk exactly as the server sent it, unfixed and unhidden. The layer translates APIs; it does not editorialize the servers behind them.
With local: true the real local timeline flows, and pagination just walks:
const page2 = await client.timelines.public({
local: true,
page: { limit: 3, after: page1.pageInfo.endCursor },
});IDs travel in envelopes
Post IDs come back wrapped:
ap_1_WyJtYXN0b2RvbiIsImh0dHBzOi8vc3VraGkuZjNsaXouY2FzYSIsInBvc3QiLCI1MTY4MzI0ODA2NTg3NDcyIl0Decode the base64 and the payload is ["mastodon", "https://sukhi.f3liz.casa", "post", "5168324806587472"] — an envelope that remembers which adapter, which server, and what kind of ID it carries. Cursors (apc_1_…) go further and bind the operation name too, so a local-timeline cursor pasted into a home-timeline call fails loudly instead of returning the wrong page. Mix Misskey IDs, Mastodon IDs, and UUIDs in one app once, and you will never want raw identifiers again.
Server mode: detection and capability tables
The proxy only talks to origins you explicitly allow:
node packages/server/dist/bin.mjs --port 4100 \
--allow-origin https://sukhi.f3liz.casa \
--allow-origin https://misskey.io \
--allow-origin https://hackers.pubAsk it who a server is:
curl -X POST localhost:4100/api/v1/instances/detect \
-d '{"origin":"https://misskey.io"}'misskey.io comes back identified from nodeinfo as the misskey adapter, with a full capability table. Every entry carries a three-valued status and a reason:
{ "name": "posts.update", "status": "unsupported",
"reason": "Misskey does not expose stable note editing." }
{ "name": "instance.oauthMetadata", "status": "unknown" }APIs that explain why they cannot do something are rare. Build your feature flags straight off this table and you skip a whole class of per-server special cases.
My sukhi announces itself as sukhi-fedi in nodeinfo, so auto-detection refused to guess — an honest failure. Pass "adapter": "mastodon" and everything works: a compatible server's "treat me as Mastodon" is taken as an explicit statement, never an assumption.
One query, two very different servers
The unified GraphQL is the clearest demonstration. This single query —
query ($origin: String!, $adapter: AdapterKind) {
publicTimeline(origin: $origin, adapter: $adapter,
local: true, page: { limit: 2 }) {
nodes { ref { id rawId } author { handle } createdAt }
pageInfo { hasNextPage }
}
}— runs unchanged against https://sukhi.f3liz.casa (Mastodon-compatible REST underneath) and against https://hackers.pub (GraphQL underneath). Snowflake IDs on one side, UUIDs on the other, both back in the same ap_1_ envelopes, in the same shape. Mail from different worlds, one postbox.
Auth, and errors that tell the truth
Already hold a token? Inject it directly; OAuth flows exist alongside.
const session = await client.auth.injectToken({ accessToken: token });
const viewer = await client.auth.verifyCredentials(session);
// → logged in as: shiro_mudita / シロThat logged me into my own account. Then I asked for something impossible on purpose — a Misskey-style emoji reaction on a Mastodon-family server:
await client.social.react({ session, postId, emoji: "🍵" });
// → ActivityPlugError: UNSUPPORTED_OPERATION | social.reactionNot a silent null, not a 500. A typed error that names the missing operation, matching the capability table exactly. Catch UNSUPPORTED_OPERATION and fall back — the bot example in the repo does exactly that, reacting where it can and favouriting where it cannot.
Where it stands
The facts, as of this walk: version 0.0.0, no npm release, no README, API still moving. Against that: E2E tests run against five real servers pinned by revision, documentation ships in English, Korean, and Japanese sibling files, and "say you cannot when you cannot" runs through the whole design, from capability tables to runtime errors.
Building a multi-server fediverse tool in TypeScript? Clone it, build it, and try your own server first — that is where the design shows its character fastest. These observations are from 2026-07-02, at commit 5eed56d; the shape will keep changing, and that is what being two months old means.
See you in the next one.