Hello! I'm Hong Minhee (洪 民憙), an open source software engineer in my late 30s, living in Seoul, Korea. I'm bisexual and non-binary (they/them), and an enthusiastic advocate of free/open source software and the fediverse.
I work full-time on @fedify, an ActivityPub server framework in TypeScript, funded by @sovtechfund. I'm also the creator of @hollo, a single-user ActivityPub microblog; @botkit, an ActivityPub bot framework; Hackers' Pub, a fediverse platform for software developers; and LogTape, a logging library for JavaScript and TypeScript.
I have a long interest in East Asian languages (CJK) and Unicode. I post mostly in English here, though occasionally in Japanese or in mixed-script Korean (國漢文混用體), a traditional writing style that interleaves Chinese characters with the native Korean alphabet. Wanting to write in that style was actually one of the reasons I joined the fediverse. Feel free to talk to me in English, Korean, Japanese, or even Literary Chinese!
安寧하세요! 저는 서울에 살고 있는 30代 後半의 오픈 소스 소프트웨어 엔지니어 洪民憙입니다. 兩性愛者(bisexual)이자 논바이너리(non-binary)이며, 自由·오픈 소스 소프트웨어(F/OSS)와 聯合宇宙(fediverse)의 熱烈한 支持者이기도 합니다.
STF(@sovtechfund)의 支援을 받아 TypeScript用 ActivityPub 서버 프레임워크 @fedify 開發에 專業으로 任하고 있습니다. 그 外에도 싱글 유저用 ActivityPub 마이크로블로그 @hollo, ActivityPub 봇 프레임워크 @botkit, 소프트웨어 開發者를 위한 聯合宇宙 플랫폼 Hackers' Pub, JavaScript·TypeScript用 로깅 라이브러리 LogTape 等의 製作者이기도 합니다.
東아시아 言語(이른바 CJK)와 Unicode에도 關心이 많습니다. 이 計定에서는 主로 英語로 포스팅하지만, 때때로 日本語나 國漢文混用體 韓國語로도 씁니다. 聯合宇宙에 오게 된 動機 中 하나가 바로 國漢文混用體로 글을 쓰고 싶었기 때문이기도 하고요. 韓國語, 英語, 日本語, 아니면 漢文으로도 말을 걸어주세요!
I'd love to see a coordinated campaign across some of the more active instances — recommending well-known and influential accounts to follow. I'm especially trying to curate lists for Fosstodon and Hachyderm, but doing this alone is tough. Would anyone be interested in collaborating?
Mastodon 4.6 has just been released, introducing a new "Collections" feature. Like Bluesky's Starter Packs, this lets us curate and recommend accounts to newcomers on the fediverse, so they don't land on an empty, lifeless timeline when they first join.
Sometimes, while designing some hypothetical fediverse software, I run into this feeling
setting aside ActivityPub spec compatibility itself, there are these frustrating moments where I hit preconditions like 'this won't work because of which FEPs (e.g. Activity Intents) other fediverse software happens to support' and it just kills the momentum.
One thing I keep coming back to when comparing #ActivityPub and AT Protocol: ActivityPub is basically a convention on top of the #web stuff we already have. An actor is a JSON-LD document you GET. An inbox is an endpoint you POST to. If you already run a website, you can bolt this on without changing the site's basic shape. Ghost didn't set out to join the #fediverse, and then years later it could, just by adding an endpoint.
AT Protocol feels less like that to me. Running a PDS means a signed repo, a Merkle search tree, a firehose, a DID. It's not a layer you add to an existing site. It's closer to standing up another backend beside it.
I think that's why ActivityPub keeps making sense to me. You can join later. You don't have to have built the whole thing with federation in mind from day one.
Requested a #CVE ID through a #GitHub Security Advisory on June 27 and still nothing, almost two weeks now. GitHub's docs say it usually takes up to three days.
Anyone else seeing longer waits from the GitHub CNA lately, or is this just me? Curious whether it's a general slowdown or something specific to my request.
If you are a member of Codeberg e.V. please take the time to participate in the poll that was just sent out about banning vibe-coded projects on Codeberg.
Please agree to the proposal. Slop can live on GitHub.
Just dropped a new BotKit release, and BotKit now can host multiple bots per instance! Also we have a new beautiful website as well: botkit.fedify.dev.
We're pleased to announce BotKit 0.5.0. This release changes how both the docs site and every bot's own pages look, and it stops limiting a server to one bot: a single process can now host a whole fleet of them. Quoting, and being quoted, goes through a consent step under FEP-044f, and a Redis-backed repository joins the SQLite and PostgreSQL ones for shared production storage. A few of these changes are breaking; see below for what to check before you upgrade.
A new look for botkit.fedify.dev
Until this release, botkit.fedify.dev read like a stock VitePress site: a workable but generic shell wrapped around the docs, with none of BotKit's own personality showing through.
The theme and the landing page are both new. The palette now matches the real logo greens instead of a generic default, and headlines are set in Space Grotesk, paired with Inter for everything else; the display font is self-hosted, so no visitor's IP is handed to a font CDN. The new landing page frames BotKit's dinosaur mascot inside its signature unassembled-model-kit sprue frame, then leads with a tabbed installer for Deno, npm, pnpm, and Yarn and a one-file bot example before getting into what BotKit actually does: messages, events, multi-bot instances, and the pages it builds for a bot without any extra work from you.
The deployment guides grew alongside the new landing page: they were split apart and fleshed out, and a new Cloudflare Workers guide joins the existing Deno Deploy, Docker, and self-hosting guides.
A new look for your bot's pages
The pages BotKit serves for your bot (its profile, its posts, its follower list) got the same attention, in the opposite direction. Until now they were built on Pico CSS pulled from an external CDN: a fine default, but a generic one that made every BotKit-hosted bot look like a demo of the same template, and that quietly sent every visitor's browser off to fetch a stylesheet from someone else's server.
That's gone. Bot pages now use a self-contained design system, bundled with the package and served from BotKit's own content-addressed, cache-forever path: no external CDN, no build step, on either Deno or Node.js. The whole look is driven by a single accent color you choose (twenty names, the same legend Pico CSS uses, so your existing choice of color still works), tinting links, the follow button, and small highlights, while everything else stays quiet so your bot's name, avatar, and posts are what a visitor actually notices. A new PagesOptions.theme option ("auto", "light", or "dark") controls the color scheme independently of the accent. A repost is now marked and attributed to its original author instead of blending into the bot's own timeline.
If you want to go further than the accent color allows, PagesOptions.css still lets you inject custom CSS on top of BotKit's own stylesheet.
Multi-bot instances
Until this release, a BotKit process could only ever be one bot. Running a second bot meant standing up a whole second server, even when the two bots could easily have shared the same infrastructure. That limitation, raised in #16, is gone: the new createInstance() function creates an Instance that owns the shared plumbing (the key–value store, the message queue, the repository, and HTTP handling), and any number of bots can live on it side by side, each with its own actor identity and event handlers.
For a fixed, known set of bots, Instance.createBot() takes an identifier and a profile:
import { createInstance, text } from "@fedify/botkit";import { MemoryKvStore } from "@fedify/fedify";const instance = createInstance<void>({ kv: new MemoryKvStore() });const greetBot = instance.createBot("greet", { username: "greetbot", name: "Greeting Bot",});greetBot.onFollow = async (session, followRequest) => { await followRequest.accept(); await session.publish(text`Welcome, ${followRequest.follower}!`);};
For a family of bots resolved on demand (one per region, one per customer, thousands of them backed by a database), pass a dispatcher function instead of a fixed identifier, and BotKit resolves and federates each one lazily:
Incoming activities are routed only to the bots they actually concern: the followed bot, the owner of a liked or replied-to message, mentioned bots, and bots followed by the sender. Multi-bot instances also serve a list of hosted bots at the web root, with each bot's own pages moving to /@{username}; a reserved instance actor signs shared-inbox requests when there's no single bot whose key obviously should.
None of this touches single-bot deployments: createBot() keeps working exactly as it always has, with the bot's pages staying at the web root and its data migrated to the new storage layout automatically on startup. If you maintain a custom Repository implementation, though, this is a breaking change worth planning for: every method now takes the owning bot's identifier as its first parameter, Session.bot is a read-only ReadonlyBot instead of a mutable Bot, and local object URIs carry the owning bot's identifier (old-format URIs are still recognized and permanently redirected, so links other servers stored keep working). The full picture, including how to move an existing single-bot deployment onto a multi-bot instance later, is in the new Instance concept document.
Thanks to @moreal, whose early work-in-progress explorations of this problem surfaced its two hardest design questions (mapping usernames to identifiers for dynamic bots, and routing object URIs that used to carry no owner information) well before this implementation settled on its final shape.
Consent-respecting quotes with FEP-044f
BotKit has supported quoting since 0.2.0, but only in the Misskey family's style: a quoteUrl property and a Link tag, sent without ever asking the quoted author. Mastodon 4.4 and 4.5 do things differently: they verify quotes through FEP-044f's consent handshake before showing them as quotes at all. Without that handshake, a BotKit bot's quotes never rendered as quotes on Mastodon, and quotes of a BotKit bot showed up as unverifiable.
BotKit now handles the FEP-044f handshake in both directions. When you quote a message, it sets the FEP-044f quote property and sends a QuoteRequest to the quoted author, alongside the Create it already sent. Publishing stays non-blocking: the post goes out immediately, and the quote is upgraded once (or if) approval arrives:
On the receiving side, a new quotePolicy option (on createBot(), and per message on Session.publish()) controls how your bot answers incoming quote requests: automatically for everyone ("public", the default), automatically for followers only, never ("nobody"), or held for manual review through the new Bot.onQuoteRequest event:
Bot.onQuoteAccepted, Bot.onQuoteRejected, and Bot.onQuoteRevoked cover what happens next for quotes your own bot sent, Message.quoteApproved reports whether an incoming quote carries a valid authorization stamp, and AuthorizedMessage.unauthorizeQuote() lets you revoke one you previously granted. The legacy quoteUrl and Misskey-style tags are still sent alongside the new property, so nothing about quoting on Misskey and its relatives changes. The full design is spread across #27 through #33.
A Redis repository (@fedify/botkit-redis)
BotKit has had a SQLite repository since 0.3.0 and a PostgreSQL one since 0.4.0, but neither is the natural fit for a bot that runs as several worker processes sharing one store, which is exactly the shape a Redis-backed deployment usually takes. The new @fedify/botkit-redis package fills that gap with RedisRepository, built directly on Redis strings, sets, and sorted sets rather than going through a generic key–value abstraction:
import { createBot, MemoryKvStore } from "@fedify/botkit";import { RedisRepository } from "@fedify/botkit-redis";const bot = createBot({ username: "mybot", kv: new MemoryKvStore(), repository: new RedisRepository({ url: "redis://localhost:6379/0" }),});
Because several workers can share one Redis instance, the read-modify-write paths that matter most under concurrency (message updates, follower bookkeeping, quote authorization indexes) are protected by short-lived locks that get renewed while a slow update is still running, rather than by assuming only one process ever touches the data at a time. The package supports both a connection URL it manages itself and an existing node-redis client you inject and keep control of, and it's available for both Deno and Node.js. #12 and #35 cover the rest of it.
Smaller improvements
The npm package's TypeScript declaration files no longer accidentally include the runtime Temporal polyfill code, which had been leaking into consumers' .d.ts output. Fedify was upgraded to 2.3.1, Hono to 4.12.27, and LogTape to 2.2.3.
As always, the full list of changes is in CHANGES.md, and every API mentioned above is documented at botkit.fedify.dev. Thank you to everyone who filed issues, opened discussions, and tried BotKit out.
If you build something with BotKit, run into a rough edge, or just want to talk through an idea before opening an issue, GitHub Discussions is the place for exactly that. For something closer to real time, BotKit's chat now lives on Matrix at #fedify:matrix.org. Drop in and say hello.
BotKit 0.5.0 is out: a redesigned botkit.fedify.dev and bot pages, multi-bot instances on a single process, consent-respecting quotes via FEP-044f, and a new Redis repository.
We're pleased to announce BotKit 0.5.0. This release changes how both the docs site and every bot's own pages look, and it stops limiting a server to one bot: a single process can now host a whole fleet of them. Quoting, and being quoted, goes through a consent step under FEP-044f, and a Redis-backed repository joins the SQLite and PostgreSQL ones for shared production storage. A few of these changes are breaking; see below for what to check before you upgrade.
A new look for botkit.fedify.dev
Until this release, botkit.fedify.dev read like a stock VitePress site: a workable but generic shell wrapped around the docs, with none of BotKit's own personality showing through.
The theme and the landing page are both new. The palette now matches the real logo greens instead of a generic default, and headlines are set in Space Grotesk, paired with Inter for everything else; the display font is self-hosted, so no visitor's IP is handed to a font CDN. The new landing page frames BotKit's dinosaur mascot inside its signature unassembled-model-kit sprue frame, then leads with a tabbed installer for Deno, npm, pnpm, and Yarn and a one-file bot example before getting into what BotKit actually does: messages, events, multi-bot instances, and the pages it builds for a bot without any extra work from you.
The deployment guides grew alongside the new landing page: they were split apart and fleshed out, and a new Cloudflare Workers guide joins the existing Deno Deploy, Docker, and self-hosting guides.
A new look for your bot's pages
The pages BotKit serves for your bot (its profile, its posts, its follower list) got the same attention, in the opposite direction. Until now they were built on Pico CSS pulled from an external CDN: a fine default, but a generic one that made every BotKit-hosted bot look like a demo of the same template, and that quietly sent every visitor's browser off to fetch a stylesheet from someone else's server.
That's gone. Bot pages now use a self-contained design system, bundled with the package and served from BotKit's own content-addressed, cache-forever path: no external CDN, no build step, on either Deno or Node.js. The whole look is driven by a single accent color you choose (twenty names, the same legend Pico CSS uses, so your existing choice of color still works), tinting links, the follow button, and small highlights, while everything else stays quiet so your bot's name, avatar, and posts are what a visitor actually notices. A new PagesOptions.theme option ("auto", "light", or "dark") controls the color scheme independently of the accent. A repost is now marked and attributed to its original author instead of blending into the bot's own timeline.
If you want to go further than the accent color allows, PagesOptions.css still lets you inject custom CSS on top of BotKit's own stylesheet.
Multi-bot instances
Until this release, a BotKit process could only ever be one bot. Running a second bot meant standing up a whole second server, even when the two bots could easily have shared the same infrastructure. That limitation, raised in #16, is gone: the new createInstance() function creates an Instance that owns the shared plumbing (the key–value store, the message queue, the repository, and HTTP handling), and any number of bots can live on it side by side, each with its own actor identity and event handlers.
For a fixed, known set of bots, Instance.createBot() takes an identifier and a profile:
import { createInstance, text } from "@fedify/botkit";import { MemoryKvStore } from "@fedify/fedify";const instance = createInstance<void>({ kv: new MemoryKvStore() });const greetBot = instance.createBot("greet", { username: "greetbot", name: "Greeting Bot",});greetBot.onFollow = async (session, followRequest) => { await followRequest.accept(); await session.publish(text`Welcome, ${followRequest.follower}!`);};
For a family of bots resolved on demand (one per region, one per customer, thousands of them backed by a database), pass a dispatcher function instead of a fixed identifier, and BotKit resolves and federates each one lazily:
Incoming activities are routed only to the bots they actually concern: the followed bot, the owner of a liked or replied-to message, mentioned bots, and bots followed by the sender. Multi-bot instances also serve a list of hosted bots at the web root, with each bot's own pages moving to /@{username}; a reserved instance actor signs shared-inbox requests when there's no single bot whose key obviously should.
None of this touches single-bot deployments: createBot() keeps working exactly as it always has, with the bot's pages staying at the web root and its data migrated to the new storage layout automatically on startup. If you maintain a custom Repository implementation, though, this is a breaking change worth planning for: every method now takes the owning bot's identifier as its first parameter, Session.bot is a read-only ReadonlyBot instead of a mutable Bot, and local object URIs carry the owning bot's identifier (old-format URIs are still recognized and permanently redirected, so links other servers stored keep working). The full picture, including how to move an existing single-bot deployment onto a multi-bot instance later, is in the new Instance concept document.
Thanks to @moreal, whose early work-in-progress explorations of this problem surfaced its two hardest design questions (mapping usernames to identifiers for dynamic bots, and routing object URIs that used to carry no owner information) well before this implementation settled on its final shape.
Consent-respecting quotes with FEP-044f
BotKit has supported quoting since 0.2.0, but only in the Misskey family's style: a quoteUrl property and a Link tag, sent without ever asking the quoted author. Mastodon 4.4 and 4.5 do things differently: they verify quotes through FEP-044f's consent handshake before showing them as quotes at all. Without that handshake, a BotKit bot's quotes never rendered as quotes on Mastodon, and quotes of a BotKit bot showed up as unverifiable.
BotKit now handles the FEP-044f handshake in both directions. When you quote a message, it sets the FEP-044f quote property and sends a QuoteRequest to the quoted author, alongside the Create it already sent. Publishing stays non-blocking: the post goes out immediately, and the quote is upgraded once (or if) approval arrives:
On the receiving side, a new quotePolicy option (on createBot(), and per message on Session.publish()) controls how your bot answers incoming quote requests: automatically for everyone ("public", the default), automatically for followers only, never ("nobody"), or held for manual review through the new Bot.onQuoteRequest event:
Bot.onQuoteAccepted, Bot.onQuoteRejected, and Bot.onQuoteRevoked cover what happens next for quotes your own bot sent, Message.quoteApproved reports whether an incoming quote carries a valid authorization stamp, and AuthorizedMessage.unauthorizeQuote() lets you revoke one you previously granted. The legacy quoteUrl and Misskey-style tags are still sent alongside the new property, so nothing about quoting on Misskey and its relatives changes. The full design is spread across #27 through #33.
A Redis repository (@fedify/botkit-redis)
BotKit has had a SQLite repository since 0.3.0 and a PostgreSQL one since 0.4.0, but neither is the natural fit for a bot that runs as several worker processes sharing one store, which is exactly the shape a Redis-backed deployment usually takes. The new @fedify/botkit-redis package fills that gap with RedisRepository, built directly on Redis strings, sets, and sorted sets rather than going through a generic key–value abstraction:
import { createBot, MemoryKvStore } from "@fedify/botkit";import { RedisRepository } from "@fedify/botkit-redis";const bot = createBot({ username: "mybot", kv: new MemoryKvStore(), repository: new RedisRepository({ url: "redis://localhost:6379/0" }),});
Because several workers can share one Redis instance, the read-modify-write paths that matter most under concurrency (message updates, follower bookkeeping, quote authorization indexes) are protected by short-lived locks that get renewed while a slow update is still running, rather than by assuming only one process ever touches the data at a time. The package supports both a connection URL it manages itself and an existing node-redis client you inject and keep control of, and it's available for both Deno and Node.js. #12 and #35 cover the rest of it.
Smaller improvements
The npm package's TypeScript declaration files no longer accidentally include the runtime Temporal polyfill code, which had been leaking into consumers' .d.ts output. Fedify was upgraded to 2.3.1, Hono to 4.12.27, and LogTape to 2.2.3.
As always, the full list of changes is in CHANGES.md, and every API mentioned above is documented at botkit.fedify.dev. Thank you to everyone who filed issues, opened discussions, and tried BotKit out.
If you build something with BotKit, run into a rough edge, or just want to talk through an idea before opening an issue, GitHub Discussions is the place for exactly that. For something closer to real time, BotKit's chat now lives on Matrix at #fedify:matrix.org. Drop in and say hello.
We're pleased to announce BotKit 0.5.0. This release changes how both the docs site and every bot's own pages look, and it stops limiting a server to one bot: a single process can now host a whole fleet of them. Quoting, and being quoted, goes through a consent step under FEP-044f, and a Redis-backed repository joins the SQLite and PostgreSQL ones for shared production storage. A few of these changes are breaking; see below for what to check before you upgrade.
A new look for botkit.fedify.dev
Until this release, botkit.fedify.dev read like a stock VitePress site: a workable but generic shell wrapped around the docs, with none of BotKit's own personality showing through.
The theme and the landing page are both new. The palette now matches the real logo greens instead of a generic default, and headlines are set in Space Grotesk, paired with Inter for everything else; the display font is self-hosted, so no visitor's IP is handed to a font CDN. The new landing page frames BotKit's dinosaur mascot inside its signature unassembled-model-kit sprue frame, then leads with a tabbed installer for Deno, npm, pnpm, and Yarn and a one-file bot example before getting into what BotKit actually does: messages, events, multi-bot instances, and the pages it builds for a bot without any extra work from you.
The deployment guides grew alongside the new landing page: they were split apart and fleshed out, and a new Cloudflare Workers guide joins the existing Deno Deploy, Docker, and self-hosting guides.
A new look for your bot's pages
The pages BotKit serves for your bot (its profile, its posts, its follower list) got the same attention, in the opposite direction. Until now they were built on Pico CSS pulled from an external CDN: a fine default, but a generic one that made every BotKit-hosted bot look like a demo of the same template, and that quietly sent every visitor's browser off to fetch a stylesheet from someone else's server.
That's gone. Bot pages now use a self-contained design system, bundled with the package and served from BotKit's own content-addressed, cache-forever path: no external CDN, no build step, on either Deno or Node.js. The whole look is driven by a single accent color you choose (twenty names, the same legend Pico CSS uses, so your existing choice of color still works), tinting links, the follow button, and small highlights, while everything else stays quiet so your bot's name, avatar, and posts are what a visitor actually notices. A new PagesOptions.theme option ("auto", "light", or "dark") controls the color scheme independently of the accent. A repost is now marked and attributed to its original author instead of blending into the bot's own timeline.
If you want to go further than the accent color allows, PagesOptions.css still lets you inject custom CSS on top of BotKit's own stylesheet.
Multi-bot instances
Until this release, a BotKit process could only ever be one bot. Running a second bot meant standing up a whole second server, even when the two bots could easily have shared the same infrastructure. That limitation, raised in #16, is gone: the new createInstance() function creates an Instance that owns the shared plumbing (the key–value store, the message queue, the repository, and HTTP handling), and any number of bots can live on it side by side, each with its own actor identity and event handlers.
For a fixed, known set of bots, Instance.createBot() takes an identifier and a profile:
import { createInstance, text } from "@fedify/botkit";import { MemoryKvStore } from "@fedify/fedify";const instance = createInstance<void>({ kv: new MemoryKvStore() });const greetBot = instance.createBot("greet", { username: "greetbot", name: "Greeting Bot",});greetBot.onFollow = async (session, followRequest) => { await followRequest.accept(); await session.publish(text`Welcome, ${followRequest.follower}!`);};
For a family of bots resolved on demand (one per region, one per customer, thousands of them backed by a database), pass a dispatcher function instead of a fixed identifier, and BotKit resolves and federates each one lazily:
Incoming activities are routed only to the bots they actually concern: the followed bot, the owner of a liked or replied-to message, mentioned bots, and bots followed by the sender. Multi-bot instances also serve a list of hosted bots at the web root, with each bot's own pages moving to /@{username}; a reserved instance actor signs shared-inbox requests when there's no single bot whose key obviously should.
None of this touches single-bot deployments: createBot() keeps working exactly as it always has, with the bot's pages staying at the web root and its data migrated to the new storage layout automatically on startup. If you maintain a custom Repository implementation, though, this is a breaking change worth planning for: every method now takes the owning bot's identifier as its first parameter, Session.bot is a read-only ReadonlyBot instead of a mutable Bot, and local object URIs carry the owning bot's identifier (old-format URIs are still recognized and permanently redirected, so links other servers stored keep working). The full picture, including how to move an existing single-bot deployment onto a multi-bot instance later, is in the new Instance concept document.
Thanks to @moreal, whose early work-in-progress explorations of this problem surfaced its two hardest design questions (mapping usernames to identifiers for dynamic bots, and routing object URIs that used to carry no owner information) well before this implementation settled on its final shape.
Consent-respecting quotes with FEP-044f
BotKit has supported quoting since 0.2.0, but only in the Misskey family's style: a quoteUrl property and a Link tag, sent without ever asking the quoted author. Mastodon 4.4 and 4.5 do things differently: they verify quotes through FEP-044f's consent handshake before showing them as quotes at all. Without that handshake, a BotKit bot's quotes never rendered as quotes on Mastodon, and quotes of a BotKit bot showed up as unverifiable.
BotKit now handles the FEP-044f handshake in both directions. When you quote a message, it sets the FEP-044f quote property and sends a QuoteRequest to the quoted author, alongside the Create it already sent. Publishing stays non-blocking: the post goes out immediately, and the quote is upgraded once (or if) approval arrives:
On the receiving side, a new quotePolicy option (on createBot(), and per message on Session.publish()) controls how your bot answers incoming quote requests: automatically for everyone ("public", the default), automatically for followers only, never ("nobody"), or held for manual review through the new Bot.onQuoteRequest event:
Bot.onQuoteAccepted, Bot.onQuoteRejected, and Bot.onQuoteRevoked cover what happens next for quotes your own bot sent, Message.quoteApproved reports whether an incoming quote carries a valid authorization stamp, and AuthorizedMessage.unauthorizeQuote() lets you revoke one you previously granted. The legacy quoteUrl and Misskey-style tags are still sent alongside the new property, so nothing about quoting on Misskey and its relatives changes. The full design is spread across #27 through #33.
A Redis repository (@fedify/botkit-redis)
BotKit has had a SQLite repository since 0.3.0 and a PostgreSQL one since 0.4.0, but neither is the natural fit for a bot that runs as several worker processes sharing one store, which is exactly the shape a Redis-backed deployment usually takes. The new @fedify/botkit-redis package fills that gap with RedisRepository, built directly on Redis strings, sets, and sorted sets rather than going through a generic key–value abstraction:
import { createBot, MemoryKvStore } from "@fedify/botkit";import { RedisRepository } from "@fedify/botkit-redis";const bot = createBot({ username: "mybot", kv: new MemoryKvStore(), repository: new RedisRepository({ url: "redis://localhost:6379/0" }),});
Because several workers can share one Redis instance, the read-modify-write paths that matter most under concurrency (message updates, follower bookkeeping, quote authorization indexes) are protected by short-lived locks that get renewed while a slow update is still running, rather than by assuming only one process ever touches the data at a time. The package supports both a connection URL it manages itself and an existing node-redis client you inject and keep control of, and it's available for both Deno and Node.js. #12 and #35 cover the rest of it.
Smaller improvements
The npm package's TypeScript declaration files no longer accidentally include the runtime Temporal polyfill code, which had been leaking into consumers' .d.ts output. Fedify was upgraded to 2.3.1, Hono to 4.12.27, and LogTape to 2.2.3.
As always, the full list of changes is in CHANGES.md, and every API mentioned above is documented at botkit.fedify.dev. Thank you to everyone who filed issues, opened discussions, and tried BotKit out.
If you build something with BotKit, run into a rough edge, or just want to talk through an idea before opening an issue, GitHub Discussions is the place for exactly that. For something closer to real time, BotKit's chat now lives on Matrix at #fedify:matrix.org. Drop in and say hello.
Hello, I'm nyanrus. Let me introduce sukhi-fedi, the software behind the small federated server I run. The core idea is only one. I put the OTP boundaries in the module structure, not in the network, so the same codebase folds onto a free-tier box (~130 MiB flat on one core), and when it needs to, it spreads across nodes. Delivery goes on Postgres and NATS, features on a distributed-Erlang plugin layer. And it is not just an idea. It already round-trips with Mastodon, Misskey, and hackers.pub, and the ActivityPub translation is native Elixir. I also add the story of four months, piling up 461 commits one day at a time. If you like, please take a look.
Hello, I'm nyanrus. Let me introduce sukhi-fedi, the software behind the small federated server I run, sukhi.f3liz.casa.
sukhi-fedi is a federated SNS server that speaks ActivityPub. Its main client-facing surface is a Mastodon-compatible API, and it does the JSON-LD and HTTP-signature work itself, in Elixir. Right now it is federating with the wider fediverse. AGPL-3.0, and the current version is v0.4.14.
There is only one thing I really want to say. It is not about a feature, but about the order I built things in. The same codebase runs on a box small enough for a free tier, and when it needs to, it spreads across nodes. And the federated core already works. Let me go through it in order.
The bet I made
When you set up a federated server, most of them make you choose a size first. The big ones can federate with anything, but they want a real server. The light ones fit on a small box, but scaling past that box is not really the plan. I did not want that to be a fork in the road. So the same code folds onto a 1 GB free-tier box, and when it gets tight, it spreads across nodes. That works because the seams live in OTP modules, not in the deployment.
I also wanted one server I could manage in the same way, whether it was small or large.
The other thing I like is the way features scale. The client API is a plugin catalogue. One file is one capability, and it registers itself at boot, so I do not touch a router. Capabilities are grouped into addons, and a set of them can run on its own node. To extend the server, I add a file. I do not patch the core. So new endpoints, and whole groups of features, do not get in each other's way.
None of this is magic. I just put each boundary in one place, and pay for it only once. But because of that, you can start sukhi-fedi as a hobby box, scale it without a rewrite, and extend it without a fork. I think that is not the usual deal.
How I split it
The reason both hold with the same code is mostly here. sukhi-fedi splits into separate OTP applications, one per job.
gateway (:sukhi_fedi) — the only entry point for HTTP from people. Login, posting, receiving the inbox, WebFinger.
delivery (:sukhi_delivery) — reads the outbox and POSTs to remote inboxes. Oban queues also handle the retries.
API plugin (:sukhi_api) — the Mastodon-compatible REST surface.
The important thing is that these boundaries are drawn in the module structure, not in the network. Each one is an independent OTP app. So I can run them as separate BEAM nodes, or I can fold all three into one BEAM. The boundaries survive even when folded. This is not a layer I added later for speed. It is how I split things from the start.
Between processes it is messages, and state is left to supervision. If I just keep to the plain way of using OTP, then changing the topology is no longer a rewrite. It becomes only a choice of how to boot. Folding small and scaling out are the two ends of the same dial.
Making it small
One of my goals is to run sukhi-fedi on a small box, 512 to 768 MB of memory.
When you layer docker-compose.combined.yml, gateway and delivery become one BEAM (the combined release). It is a setup for a 1-core / 2 GB box. In one 1-core endurance run, this combined stayed flat at ~130 MiB under read, write, and inbox load, all the way through. Even the memory breakdown for a 512 MB budget (combined 192M, postgres 112M, and so on) is written in the compose comments. It probably runs on 768 MB too. On a 1 GB box, I have even run it as a bot-only server with no UI (watch-mjw.f3liz.casa, which now lives on the same box as sukhi.f3liz.casa).
"It runs even on a small server" is not a slogan for me. It is true because I split it so that it folds. That is the order.
By the way, in this small-box story, the first thing that fell over was not Elixir. It was another runtime, Bun. Now Elixir does the ActivityPub translation itself, and Bun is retired from production. That whole story is in a separate post on this blog: We used fedify, then graduated from it.
Scaling out
In the opposite direction from running small, scaling out is also the same code. The direction of scaling has two.
Delivery throughput is handled by Postgres and NATS, not by distributed Erlang. Postgres is the system of record, and NATS JetStream is the event plane. The gateway only writes outbox rows, and delivery only reads them and delivers. Because the roles are split across that one seam, adding delivery capacity does not need any clustering work. Outbox.Relay, the singleton GenServer at the center of delivery, has said "for future horizontal scale" from the very start.
The feature surface is handled by distributed Erlang. The Mastodon REST API is a plugin catalogue on its own node, reached by :rpc. One file is one capability, registered automatically at boot (SukhiApi.Registry) and grouped into addons. So to add an endpoint, I just drop a file, and I do not touch the router. plugin_nodes is a list, and the gateway uses the first node it can reach. So I can run several plugin nodes, and scale only the feature layer on its own nodes, without touching gateway or delivery.
So there are two planes. Postgres and NATS carry delivery, distributed Erlang carries features. I start from the folded state, and I carve out only the parts that need their own node. Without redoing the design.
It actually runs
The most honest thing I want to say in this introduction is here. This is not a plan. It is something that is running.
The ones I have actually federated with, and checked the round-trip, are Mastodon, Misskey, and hackers.pub. The ActivityPub translation is native Elixir (SukhiFedi.Fedi), and an activity that goes through it reaches the other side and comes back. For other servers' quirks, like the signature schemes (draft-cavage and RFC 9421), how quotes are written, and emoji reactions, I have already put in interop code. But I have not tested those against a live peer yet. On the Mastodon-compatible API, clients like Elk, Phanpy, Ivory, and Tusky have enough small fixes that they no longer error after login.
You can also see it running, not only read about it. sukhi's /map is a public page that draws the structure I have described, from the gateway, through the NATS switchyard, to delivery, and out into the fediverse, as a live rail map. The number of trains on it is not decoration. It is how much really flowed that day. Stations and rails are a metaphor, but the way they connect is one-to-one with the real wiring. How it was built is written in a separate post on this blog: I put trains inside my server.
Some things are still missing. Full-text search, the streaming WebSocket, a native Misskey client API. They are lined up in TODO.md and OPEN_QUESTIONS.md. These are gaps at the edges, not in the core. So please look forward to what comes next.
How it got here
My first commit was 16 March 2026, an "inital commit" with the typo still in it. From there, over four months, I piled up 461 commits, and it became what it is now.
In April, I split delivery out of the main body, and the skeleton of federation appeared. May was a month only of fleshing it out. Quotes, emoji reactions, MFM, DMs, and the paths for talking to remote servers grew all at once. It was not an easy month. Near the end of it, there was even a day when my signed POSTs kept getting bounced by hackers.pub with 401. My commit log from that day just goes on and on, logging, re-signing, and self-verifying, and the last line is "sign only the mandatory header set, like Mastodon". On the nights when federation does not go through, I learned that the cause is usually on my side.
The biggest rebuild is gathered on 12 June. I moved the ActivityPub translation that Bun was doing into Elixir, I carved out the combined release for small boxes, I threaded the Ed25519 keys from end to end, and on that same day I marked the Bun sidecar as "retired". Because I had split it so that it can be made small, this was not a redesign, but only a change in how it boots.
After that too, there was a day I stopped two irreversible data losses just in time, and I built /map only last week. Nothing dramatic, but this is how I have piled it up, one day at a time.
If you follow the Quick start in the README, you can build the whole stack on your machine and run it. It comes up at localhost:4000. For a proper self-host, see SETUP.md. If you want to know the inside, read docs/ARCHITECTURE.md. Both of them are written so that you can rebuild the whole thing from that one file. It is AGPL-3.0, so you can take it and make it your own.
That is sukhi-fedi. A federated server that folds small and scales out. If it seems useful to you, please take a look.
(sukhī is Pali for "happy". It is a name with a small wish, that a place where people meet can be woven, and kept, with ease.)
I'm concerned about this, though maybe I'm missing some context. A maintainer saying “I don't want AI-assisted patches in this project” seems fair enough. A forge-wide ban feels like a different thing. Code usually doesn't tell you how it was written, so a rule like this may mostly teach people not to disclose AI use rather than not to use it. Honest disclosure gets punished, concealment doesn't. That seems like the opposite of what the policy wants.
If you are a member of Codeberg e.V. please take the time to participate in the poll that was just sent out about banning vibe-coded projects on Codeberg.
Please agree to the proposal. Slop can live on GitHub.
This week, we cover the writing layer of the fediverse: Ghost, WriteFreely, Micro.blog, Plume, WordPress, and Flipboard. Text was ActivityPub's first real workload, but these platforms didn't all arrive at federation the same way.
Ghost: a UK non-profit that funds Fedify (by @hongminhee) the framework its federation is built on. it's a publisher and a fediverse client at once, with an inbox as well as an outbox. Our newsletter is also powered by it, so @index is followable from any Mastodon app right now.
WriteFreely: one primary maintainer, Matt Baer (@matt), roughly 1,400 of 1,900+ commits, going since 2015. Funded by his own hosted product, Write.as.
Micro.blog: a Kickstarter By Manton Reese (@manton) that funded the platform before it had a single user. POSSE, native ActivityPub, and native Bluesky, no bridge.
Plume: a simple platform that also supported collaborative multi-author blogs. But no longer actively developed - it recommends WriteFreely and WordPress instead of itself.
WordPress: built-in ActivityPub (using plugin by Matthias Pfefferle - @pfefferle) plus a native AT Protocol plugin and real moderation tooling.
Flipboard: curating other people's writing rather than hosting its own.
An antique quill and inkwell against a vibrant multicolor cosmic backdrop, with constellation lines connecting points of light across a deep starfield. Magenta, cyan, violet, and amber tones.
Back in time I used to make a stupid little font called Unison. It was a bitmap-vector hybrid font defined by text-based font description files which then get compiled by Python script. During the development of Unison, I had so frustrating bug that I had to write this comment in addition to its workaround:
def custom_sort_key((name, _)): # what, the, real, fuck. # it seems that Uniscribe has some bug with Hangul and possibly more scripts: # some characters, when they are located in specific glyph indices, are correctly # mapped via ScriptGetCMap but considered to be missing via ScriptShape. # combined with SSA_FALLBACK it causes the wrong *and* inconsistent fallback behavior. # given that the range of those indices abruptly end with 2^n boundaries, # I strongly suspect that this is something to do with the internal lookup mechanism. # for now, reorder problematic scripts to (empirically) avoid the problem... *sigh* if not name.startswith('uni'): return (2, name) try: c = int(name[3:], 16) except ValueError: return (2, name) return (0 if 0x1100 <= c <= 0x11ff or 0x3130 <= c <= 0x318f or 0xa960 <= c <= 0xa97f or 0xac00 <= c <= 0xd7ff else 1, c)
Uniscribe isn't exactly a household name so here's a brief description. It's an internal name for Windows' font rendering---or more accurately, shaping---system. OpenType fonts consist of a list of zero-numbered glyphs and a character mapping (cmap) from a character code, typically Unicode, to the glyph index. The problem was that Uniscribe correctly found a glyph from the cmap but failed to actually make use of that glyph. It is easy to demonstrate with Windows Notepad; you can configure its font, but it will use a fallback system font if the glyph doesn't exist in the specified font. When the problematic character was typed in, every non-space character before that character would suddenly switch to the fallback font!
For a long time the bug was thought to be Windows' because of its peculiar behavior over the problematic glyph indices. The bug starts to appear around the glyph index 1200 and persists right until the glyph index 2048 at which the bug suddenly disappears. Also I found a workaround to relocate problematic glyphs to lower glyph indices. 2048 = 2^11, what a suspicious number. My gut reaction was that it should be something to do with binary search, as OpenType's cmap contains related fields, but my compiler internally used fontTools for font creation and they should have been automatically calculated. So the culprit must have been Windows instead.
As there was a workaround I had no reason to continue at this point, so I documented this weird behavior and moved on. Fast forward to today: after 11 busy years, LLM has completely transformed how we do programming and I realized that LLM should be able to solve this 11-year-old mystery. You know, in this agentic era any little idea is worthy to execute. So I asked and got some additional infos:
The bug was traced back to GetCharacterPlacementW, which is a GDI function. Note that GDI is a part of Windows kernel (due to the performance concern) so I ended up with kernel debugging! Dang it!
Workaround turned out to be incomplete, as it only moved problematic glyph indices from here to there.
More precisely, problematic glyph indices were two ranges: [1216, 1664) and [1792, 2048). Every single end point here are multiples of 64. So a hypothesis was set up: there must have been some list of 64-entry bitmaps, and for some reason they were partially initialized. A partially initialized bitmap causes other glyphs in that bitmap to fail, hence this behavior.
I was able to (finally) confirm that fonts had the correct cmap and correct metadata. For example, there are fields called ulUnicodeRange1--4 which are bitmaps for script coverage. Unison had these fields incorrect set but fixing them or setting every single bit didn't help much.
And you can easily imagine my struggle with Claude because of the kernel debugging---oh, you must be doing cybersecurity so I'll block you, thank you so much. After two blocks I gave up with Claude and let Codex to finish the investigation. Codex too struggled, but this time it was able to infer the following from Claude's investigation notes:
Connecting the existing experiment results with the kernel path surfaces one strong pattern. Every failing page contains at least one glyph with 4 or more top-level composite components, while adjacent OK pages top out at 3. Page-mate victims such as AC00-AC27 are also explained this way: they fail not because of their own glyphs, but because a 4+ component glyph lives in the same 64-glyph page.
In simple words, the bug manifests itself when the number of subglyphs in composite glyphs is too high.
And it went to rewrite the font with fontTools until it concluded that:
I found the decisive difference. A/B use up to 8 top-level components in actual composite glyphs, but maxp.maxComponentElements == 0; I has the same glyf size, but maxp.maxComponentElements == 8. That explains why I stays OK even with 4+ component pages. So the general workaround is not Hangul-specific: it is to make the composite limit values in maxp match the actual glyph data.
Was it all about the buggy font?
maxp is an OpenType font header containing various maximums and minimums to aid implementations. It is not exactly metadata, but it is easily derivable from other font data and fontTools must have correctly set them automatically. At this point I looked maxp.maxComponentElements up from fontTools' issue tracker and voilà, old versions of fontTools somehow forgot to set that exact field!!!! So Windows wasn't a culprit; all it did wrong was the over-reliance on incorrect maxp values.
So, yeah, that was my hunt towards 11-year-old bug that traced back to another bug only found in 2019. Bittersweet, eh? At least it wasn't my fault, nor Windows' fault. That should be enough.
Ah, as for what's going on Unison today...
ALT text
The Unison-native font editor (Uniform) in development.
The official account for the BotKit project is moving to @botkit. This account will be replaced by the new one. Followers should automatically follow the new account unless any issues occur.
Fedify keeps growing a family of sibling projects, each one solving a different piece of what it takes to build on ActivityPub.
Hollo is a single-user microblogging server built on Fedify. It has a Mastodon-compatible API, CommonMark support, and quotes in both Mastodon and Misskey style.
BotKit is a framework for building standalone ActivityPub bots, no Mastodon or Misskey account required behind it. A complete bot fits in a single TypeScript file.
Two more are still incubating. DrFed is a web-based platform for inspecting ActivityPub objects and activities and tracing where federation breaks down. Feder is a lightweight ActivityPub server framework in Rust, a sibling to Fedify for those working outside the TypeScript ecosystem.
All of them live under the @fedify-dev organization on GitHub.
Great post, I suggest to anyone interested in the ActivityPub spec and technical architecture to read it. I had no idea the tool echosystem had this many problems, and Fedify handles a lot of them for you! #activitypub https://hackers.pub/@fedify/2026/why-activitypub-is-hard
@fedify Awesome work for the fediverse! The fact that so many servers behave in non-compatible ways seems to suggest the spec has problems, right? Some examples are frustrating to read, I can't imagine building around them without such framework.
@fedify This sounds like an amazing piece of software! Far outside my wheelhouse, but I can imagine this makes it far easier to create new services for the Fediverse!