洪 民憙 (Hong Minhee) :nonbinary:'s avatar

洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1,096 following1,869 followers

An intersectionalist, feminist, and socialist living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in , , , & . They/them.

서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. , , , 等으로 自由 소프트웨어 만듦.

()

@julian@fietkau.social

Having some nice ActivityPub design/implementation conversations with @hongminhee in the @fedify Matrix chat at :matrix.org

And if it sometimes boils down to an exchange of 😮‍💨 and 😞, then maybe that's what we need that day, and 💪 can be for tomorrow.

@kodingwarrior@silicon.moe

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.

@kodingwarrior@hackers.pub

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.

@hongminhee@hollo.social

One thing I keep coming back to when comparing and AT Protocol: ActivityPub is basically a convention on top of the 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 , 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.

@hongminhee@hollo.social

Requested a ID through a 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.

@hongminhee@hollo.social · Reply to Beady Belle Fanchannel

@Profpatsch Oh, the quote seems still pending. I'm quoting it: https://tldr.nettime.org/@tante/116880003584050912.

tldr.nettime.org

tante (@tante@tldr.nettime.org)

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.

@tante@tldr.nettime.org

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.

@hongminhee@hollo.social

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.

botkit.fedify.dev

Standalone ActivityPub bots in TypeScript

BotKit is a TypeScript framework for building standalone ActivityPub bots for the fediverse: its own server, not a Mastodon or Misskey account. Built by the Fedify team.

@botkit@hackers.pub

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 redesigned botkit.fedify.dev homepage: a framed dinosaur mascot in a model-kit sprue, a one-file install snippet, and a rotating code sample.

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.

A bot's redesigned profile page: a banner and avatar, bio with a link and mention, custom properties, follower and post counts, and a follow button, all tinted in the bot's chosen accent color.

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:

const weatherBots = instance.createBot(async (ctx, identifier) => {
  if (!identifier.startsWith("weather_")) return null;
  const region = await db.getRegion(identifier.slice("weather_".length));
  if (region == null) return null;
  return { username: identifier, name: `${region.name} Weather Bot` };
});

weatherBots.onMention = async (session, message) => {
  const code = session.bot.identifier.slice("weather_".length);
  await message.reply(text`Current weather: ${await db.getWeather(code)}`);
};

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.

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:

const message = await session.publish(text`This message quotes another one.`, {
  quoteTarget: quoted,
});
console.log(message.quoteApprovalState); // "pending"

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.onQuoteRequest = async (session, request) => {
  if (request.state === "pending") await request.accept();
};

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.

matrix.to

You're invited to talk on Matrix

You're invited to talk on Matrix

@botkit@hackers.pub

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.

hackers.pub

BotKit 0.5.0: A new design language, multi-bot instances, and consent-respecting quotes

@botkit@hackers.pub

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 redesigned botkit.fedify.dev homepage: a framed dinosaur mascot in a model-kit sprue, a one-file install snippet, and a rotating code sample.

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.

A bot's redesigned profile page: a banner and avatar, bio with a link and mention, custom properties, follower and post counts, and a follow button, all tinted in the bot's chosen accent color.

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:

const weatherBots = instance.createBot(async (ctx, identifier) => {
  if (!identifier.startsWith("weather_")) return null;
  const region = await db.getRegion(identifier.slice("weather_".length));
  if (region == null) return null;
  return { username: identifier, name: `${region.name} Weather Bot` };
});

weatherBots.onMention = async (session, message) => {
  const code = session.bot.identifier.slice("weather_".length);
  await message.reply(text`Current weather: ${await db.getWeather(code)}`);
};

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.

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:

const message = await session.publish(text`This message quotes another one.`, {
  quoteTarget: quoted,
});
console.log(message.quoteApprovalState); // "pending"

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.onQuoteRequest = async (session, request) => {
  if (request.state === "pending") await request.accept();
};

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.

matrix.to

You're invited to talk on Matrix

You're invited to talk on Matrix

@botkit@hackers.pub

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 redesigned botkit.fedify.dev homepage: a framed dinosaur mascot in a model-kit sprue, a one-file install snippet, and a rotating code sample.

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.

A bot's redesigned profile page: a banner and avatar, bio with a link and mention, custom properties, follower and post counts, and a follow button, all tinted in the bot's chosen accent color.

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:

const weatherBots = instance.createBot(async (ctx, identifier) => {
  if (!identifier.startsWith("weather_")) return null;
  const region = await db.getRegion(identifier.slice("weather_".length));
  if (region == null) return null;
  return { username: identifier, name: `${region.name} Weather Bot` };
});

weatherBots.onMention = async (session, message) => {
  const code = session.bot.identifier.slice("weather_".length);
  await message.reply(text`Current weather: ${await db.getWeather(code)}`);
};

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.

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:

const message = await session.publish(text`This message quotes another one.`, {
  quoteTarget: quoted,
});
console.log(message.quoteApprovalState); // "pending"

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.onQuoteRequest = async (session, request) => {
  if (request.state === "pending") await request.accept();
};

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.

matrix.to

You're invited to talk on Matrix

You're invited to talk on Matrix

@nyanrus@blog.atfedi.de
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.

Try it

git clone https://github.com/sukhi-social/sukhi-fedi

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.)

https://blog.atfedi.de/en/sukhi-fedi/

blog.atfedi.de

Folds small, scales out — sukhi-fedi

A federated SNS server I'm building. The same codebase runs on a small free-tier box, and also on a multi-node setup, because I put the OTP boundaries in the right place. And it already federates with the fediverse.

@hongminhee@hollo.social

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.

@tante@tldr.nettime.org

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.

@federatedmind@techhub.social

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.

Part 3 of the "Fediverse Beyond Mastodon" series.

federatedmind.com/the-written-

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.
ALT text

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.

@yurume@hackers.pub

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...

The Unison-native font editor (Uniform) in development.
ALT text

The Unison-native font editor (Uniform) in development.

@botkit@hollo.social

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@hackers.pub

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.

github.com

Fedify

A collection of development tools for fediverse. Fedify has 26 repositories available. Follow their code on GitHub.

@res260@infosec.exchange

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!

hackers.pub/@fedify/2026/why-a

hackers.pub

Why implementing ActivityPub is hard, and why it doesn't have to be

Implementing the ActivityPub protocol from scratch introduces massive technical hurdles, including fragmented signature standards, unpredictable JSON-LD document variations, complex distributed systems engineering, and critical security vulnerabilities. Developers frequently encounter silent failures like out-of-order message deliveries that cause permanently orphaned posts, undocumented platform-specific interoperability quirks, and exposure to server-side request forgery. Fedify, a TypeScript framework compatible with Deno, Node.js, and Bun, abstracts these exhausting complexities by automating multi-spec HTTP signatures, normalizing highly variable document shapes into typed immutable classes, and offering robust queue management with guaranteed ordered delivery. By handling the delicate details of federation, including secure-by-default network routing and extensive developer tooling, the library allows creators to focus on building actual products. This post demonstrates how shifting the burden of low-level protocol compliance to a specialized framework enables developers to build secure, highly interoperable federated applications with minimal effort.

@res260@infosec.exchange · Reply to Fedify

@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.

@bryan@dusty.ninja