diff --git a/scripts/patch-ap-mastodon-short-post-bypass.mjs b/scripts/patch-ap-mastodon-short-post-bypass.mjs
new file mode 100644
index 00000000..491be99a
--- /dev/null
+++ b/scripts/patch-ap-mastodon-short-post-bypass.mjs
@@ -0,0 +1,157 @@
+/**
+ * Patch: skip Micropub blog post creation for short standalone Mastodon posts.
+ *
+ * Very short status texts (≤ 280 chars, no media, no reply) are fediverse-only
+ * interactions — they federate via AP but must NOT create a permanent blog entry.
+ * Posts with in_reply_to, media, or text > 280 characters go through the full
+ * Micropub pipeline as before.
+ *
+ * Short posts get a canonical URL at /activitypub/objects/note/{uuid} (same as
+ * DMs), are stored in ap_timeline, and delivered to followers via Fedify's
+ * ctx.sendActivity({ "followers" }) — the same mechanism the AP syndicator uses.
+ */
+
+import { access, readFile, writeFile } from "node:fs/promises";
+
+const candidates = [
+ "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
+ "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
+];
+
+const MARKER = "// [patch] short-post-bypass";
+
+const OLD = ` // Resolve in_reply_to URL from status ID (cursor or ObjectId)
+ let inReplyTo = null;
+ if (inReplyToId) {`;
+
+const NEW = ` // Short standalone posts (≤ 280 chars, no media, no reply) skip Micropub
+ // and go directly to AP — no blog entry is created. // [patch] short-post-bypass
+ const _shortPostLimit = 280;
+ const _isShortPost =
+ !inReplyToId &&
+ (!mediaIds || mediaIds.length === 0) &&
+ visibility !== "direct" &&
+ (statusText || "").trim().length > 0 &&
+ (statusText || "").trim().length <= _shortPostLimit;
+
+ if (_isShortPost) {
+ const _spPluginOptions = req.app.locals.mastodonPluginOptions || {};
+ const _spPublicationUrl = (_spPluginOptions.publicationUrl || baseUrl).replace(/\\/+$/, "");
+ const _spFederation = _spPluginOptions.federation;
+ const _spHandle = _spPluginOptions.handle || "user";
+ const _spHostname = (() => { try { return new URL(_spPublicationUrl).hostname; } catch { return ""; } })();
+ const _spProfile = await collections.ap_profile.findOne({});
+
+ const { randomUUID } = await import("node:crypto");
+ const _spUuid = randomUUID();
+ const _spNoteId = \`\${_spPublicationUrl}/activitypub/objects/note/\${_spUuid}\`;
+ const _spNow = new Date().toISOString();
+
+ const _spText = (statusText || "").trim();
+ const _spHtml = _spText
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/(https?:\\/\\/[^\\s<>&"')\\]]+)/g, '$1')
+ .replace(/\\n/g, "
");
+
+ const _spTlItem = await addTimelineItem(collections, {
+ uid: _spNoteId,
+ url: _spNoteId,
+ type: "note",
+ content: { text: _spText, html: \`
\${_spHtml}
\` }, + author: { + name: _spProfile?.name || _spHandle, + url: _spProfile?.url || _spPublicationUrl, + photo: _spProfile?.icon || "", + handle: \`@\${_spHandle}@\${_spHostname}\`, + emojis: [], + bot: false, + }, + published: _spNow, + createdAt: _spNow, + inReplyTo: null, + visibility: visibility || "public", + sensitive: sensitive === true || sensitive === "true", + category: [], + counts: { likes: 0, boosts: 0, replies: 0 }, + }); + + if (_spFederation) { + try { + const { Note, Create } = await import("@fedify/fedify/vocab"); + const _spCtx = _spFederation.createContext( + new URL(_spPublicationUrl + "/"), + { handle: _spHandle, publicationUrl: _spPublicationUrl }, + ); + const _spActorUri = _spCtx.getActorUri(_spHandle); + const _spNote = new Note({ + id: new URL(_spNoteId), + attributedTo: _spActorUri, + content: \`\${_spHtml}
\`, + published: new Date(_spNow), + }); + const _spCreate = new Create({ + id: new URL(\`\${_spNoteId}#create\`), + actor: _spActorUri, + object: _spNote, + }); + await _spCtx.sendActivity( + { identifier: _spHandle }, + "followers", + _spCreate, + { preferSharedInbox: true, syncCollection: true, orderingKey: _spNoteId }, + ); + console.info(\`[Mastodon API] Short post federated to followers (no blog entry): \${_spNoteId}\`); + } catch (_spApErr) { + console.warn(\`[Mastodon API] Short post AP federation error: \${_spApErr.message}\`); + } + } + + const _spStatus = serializeStatus(_spTlItem, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + + if (idempotencyKey && collections.ap_idempotency) { + const { createHash } = await import("node:crypto"); + const _spKey = createHash("sha256").update(\`\${baseUrl}:\${idempotencyKey}\`).digest("hex"); + await collections.ap_idempotency.insertOne({ key: _spKey, response: _spStatus, createdAt: new Date() }).catch(() => {}); + } + + return res.json(_spStatus); + } + + // Resolve in_reply_to URL from status ID (cursor or ObjectId) + let inReplyTo = null; + if (inReplyToId) {`; + +async function exists(p) { + try { await access(p); return true; } catch { return false; } +} + +let patched = false; +for (const filePath of candidates) { + if (!(await exists(filePath))) continue; + const src = await readFile(filePath, "utf8"); + if (src.includes(MARKER)) { + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: already applied in ${filePath}`); + patched = true; + break; + } + if (!src.includes(OLD)) { + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: target snippet not found in ${filePath}`); + continue; + } + await writeFile(filePath, src.replace(OLD, NEW), "utf8"); + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: applied to ${filePath}`); + patched = true; + break; +} + +if (!patched) { + console.log("[postinstall] patch-ap-mastodon-short-post-bypass: no target file found"); +}