Bluesky
Publish posts to Bluesky and answer mentions, replies and direct messages with your agent.
Setup
pnpm add @atproto/apiCreate an App Password
Bluesky bots sign in with an app password, never the account password:
- Sign in to the bot's account on bsky.app
- Open Settings, then Privacy and security, then App passwords, and add one
- Tick Allow access to your direct messages if the agent should answer DMs
- Save the handle as
BLUESKY_HANDLE=yourbot.bsky.socialand the password asBLUESKY_APP_PASSWORD=...
Publishing
BlueskyFeed publishes posts. Links, mentions and hashtags in the text become facets, so they are clickable. A link card gets its preview image uploaded first, and replies are placed in the thread of the post they answer. quote embeds the post it names, on its own or together with the images or link card (app.bsky.embed.recordWithMedia), and a quote can be a reply too.
import { BlueskyFeed, FileTokenStore } from '@cogitator-ai/channels';
const bluesky = new BlueskyFeed({
identifier: process.env.BLUESKY_HANDLE!,
appPassword: process.env.BLUESKY_APP_PASSWORD!,
store: new FileTokenStore(),
});
const post = await bluesky.publish({
text: 'The model that fits on a laptop, explained',
link: {
url: 'https://example.com/story',
title: 'Open models on laptops',
description: 'What changed and why it matters',
image: { type: 'image', mimeType: 'image/jpeg', url: 'https://example.com/cover.jpg' },
},
tags: ['AI'],
langs: ['en'],
});
console.log(post.url); // https://bsky.app/profile/did:plc:.../post/...A post holds 300 graphemes and 3000 bytes, up to four images of 2 MB with alt text, or one link card with a preview up to 1 MB. The feed checks this before it sends anything. To publish to several feeds with retries and schedules, see Social Feeds.
The session is kept in the store (in memory by default) and resumed after a restart. A session that cannot be resumed, or that expires while the bot runs, is replaced by a new sign-in, and the post being published goes out after it.
With an idempotency key from idempotencyKey() (which FeedPublisher passes), the post is published under that key as its record key, so publishing again with the key, as a retry, returns the post instead of a second one.
Answering People
BlueskyChannel lets an agent answer on Bluesky through the Gateway:
import { Gateway, blueskyChannel, FileTokenStore } from '@cogitator-ai/channels';
const gateway = new Gateway({
cogitator,
agent,
channels: [
blueskyChannel({
identifier: process.env.BLUESKY_HANDLE!,
appPassword: process.env.BLUESKY_APP_PASSWORD!,
store: new FileTokenStore(),
}),
],
});
await gateway.start();- Mentions, replies and quotes of the bot's posts reach the agent, without the bot's handle in front. The answer goes into that thread.
- Direct messages reach it too, conversation requests included, which are accepted when the agent answers.
- Long answers go out as a thread of posts within 300 graphemes, or several messages within 1000.
- Formatting. Markdown becomes plain text, since Bluesky shows text as written, and links stay visible and become clickable.
- No edits. Bluesky cannot edit posts, so the gateway sends each answer once it is finished instead of streaming it.
The channel polls every pollInterval (15 seconds), which keeps the process running until stop(). It keeps what it has seen on Bluesky itself, the notifications' seen time and each conversation's read state, so it needs no state of its own and answers what arrived while it was stopped, paging back to the first one already seen. A backlog longer than one poll reads (a thousand notifications, or five hundred messages of a conversation) is read on by the next poll, and marked seen or read only at its end. catchUp: false starts from now instead.
Channel ids
A message's channelId says where an answer goes:
channelId | Sends |
|---|---|
post:<at-uri> | a reply under that post |
dm:<convoId> | a message in that conversation |
feed | a new post on the bot's feed |
So gateway.injectMessage and the scheduler can post to the feed with channelId: 'feed'.
One Account for Both
A feed and a channel of the same account share a session through BlueskyAccount, so they sign in once:
import { BlueskyAccount, BlueskyFeed, blueskyChannel } from '@cogitator-ai/channels';
const account = new BlueskyAccount({
identifier: process.env.BLUESKY_HANDLE!,
appPassword: process.env.BLUESKY_APP_PASSWORD!,
});
const feed = new BlueskyFeed({ account });
const channel = blueskyChannel({ account, directMessages: false });Configuration
interface BlueskyConnectionConfig {
identifier: string; // handle or DID
appPassword: string;
service?: string; // the PDS, default https://bsky.social
store?: TokenStore; // where the session is kept, default in memory
storeKey?: string; // default `bluesky:<identifier>`
}
type BlueskyChannelConfig = ({ account: BlueskyAccount } | BlueskyConnectionConfig) & {
pollInterval?: number; // default 15 000 ms
posts?: boolean; // answer mentions, replies and quotes, default true
directMessages?: boolean; // answer DMs, default true
catchUp?: boolean; // answer what arrived while stopped, default true
};In cogitator.yml, channels.bluesky takes ownerIds (DIDs), posts, directMessages, pollInterval and tokenPath, and reads BLUESKY_HANDLE and BLUESKY_APP_PASSWORD from the environment.