Bluesky
Bluesky has no private posting: everything goes to your feed. It also has three rules that exist nowhere else, and two of them will bite anyone writing against the API by hand.
Copy this
Create an app password
In Bluesky: Settings → Privacy and security → App passwords → Add App Password.
Configure it
PIGEON_BLUESKY_IDENTIFIER=you.bsky.social
PIGEON_BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
channels: {
bluesky: true,
},
},
})
Post
export default defineEventHandler(async () => {
const post = await bluesky.post('Release 1.0 is out: https://nuxt.com #nuxt')
return { url: post.url }
})
The link and the hashtag are clickable, and you did nothing for that. Keep reading, because that is not free.
It links nothing by itself
A url in the text of a Bluesky post is dead text. To make it a link, the post has to carry a facet: a byte range plus what it points at.
{
"index": { "byteStart": 20, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://nuxt.com" }]
}
Every client computes this before posting, so the module does too, for links, hashtags
and mentions. facets: false turns it off and your text goes out flat.
Two limits, and length is neither
| Limit | Value |
|---|---|
| Graphemes | 300 |
| Bytes | 3000 |
'👨👩👧'.length is 8. Its grapheme count is 1. It weighs 18 bytes. Both real limits
are checked before anything is sent.
One embed per post
A post carries one embed, so images and a link card compete for the same slot.
// A link card. Nothing here is read from the linked page, Bluesky unfurls nothing.
await bluesky.post('Have a look', {
external: {
uri: 'https://nuxt.com',
title: 'Nuxt',
description: 'The Intuitive Vue Framework',
},
})
Give both media and external and the module decides rather than failing, and
says so in your server log:
[nuxt-pigeon] bluesky: a post carries one embed, so the link card wins.
Your first image became its thumbnail.
Refusing the whole post would be worse. Doing it silently would be worst.
Every option
| Option | What it does |
|---|---|
media | Up to four images, with alt. Uploaded as blobs |
external | A link card: uri, title, description, optional thumb |
facets | Computed by default. false turns linking off, or pass your own |
langs | BCP-47, ['en'] |
createdAt | ISO 8601, defaults to now |
Receiving
nuxtPigeon: {
channels: {
bluesky: { receive: true, intervalMs: 60000 },
},
}
Polled, there is no webhook. Needs a long running process, so not serverless.
What will cost you an hour
- There is no
edit, and that is not an omission.putRecordon a post answers200and the appview ignores the change, so an edit would look like it worked and do nothing. See atproto#3038. Delete and post again. - A blob is limited to about 1 MB. A normal screenshot is three to five times that. Official clients resize first, this module does not, and tells you the real number instead.
- An app password with DM scope is a separate switch. Without "Allow access to your
direct messages" you get
Bad token scopefrom the chat endpoints. - Deleting is idempotent. Twice is not an error, unlike Discord where it is a 404.
- A mention only becomes a facet if the handle resolves. A typo stays plain text rather than failing the post.
Read more
Mastodon
Post to Mastodon from Nuxt with an access token: visibility, content warnings, images, editing and deleting, plus polled notifications for your own account.
Any webhook
Send and receive webhooks in Nuxt: any url, any payload, Standard Webhooks signing, and one route for GitHub, Stripe, n8n or anything else that speaks HTTP.