👤 Criar conta + App Password
O Bluesky é a rede mais amigável a desenvolvedores: não precisa aprovação, não tem fila de espera para API, e a autenticação é por App Password — uma senha derivada que você revoga sem afetar o login principal. Diferente do X/Twitter, ninguém precisa "aceitar" seu projeto.
Crie a conta em bsky.app com e-mail e senha. Seu identificador (handle) vai ser algo como seu-nome.bsky.social — esse é o "usuário" que o SDK pede no login.
Depois vá em Settings → Privacy and security → App Passwords → Add App Password. Dê um nome descritivo (ex.: mkblogs-bot) e copie a string gerada. Ela aparece uma única vez — guarde no .env imediatamente.
# .env — formato da App Password é xxxx-xxxx-xxxx-xxxx
BSKY_HANDLE=seu-nome.bsky.social
BSKY_APP_PASSWORD=abcd-efgh-ijkl-mnop
# Endpoint do PDS (Personal Data Server) — padrão Bluesky
BSKY_SERVICE=https://bsky.social
🔐 Dica prática
App Passwords não dão acesso a trocar a senha principal nem deletar a conta — só ao protocolo de publicação. Crie uma por integração: assim revogar o token do bot não desloga você do app oficial.
Conceitos-chave
Identificador público no formato user.bsky.social.
Credencial derivada, escopo limitado, revogável.
Personal Data Server que hospeda seus posts.
Decentralized Identifier único da conta.
📦 Instalar @atproto/api
O SDK oficial do AT Protocol é o @atproto/api, mantido pela Bluesky Social. Tem types TypeScript completos, suporta browser e Node, e abstrai os endpoints XRPC numa classe BskyAgent com métodos de alto nível.
# Iniciar projeto Node + TypeScript
mkdir bsky-bot && cd bsky-bot
npm init -y
npm install @atproto/api
npm install -D typescript @types/node tsx dotenv
npx tsc --init
Configure o tsconfig.json com "target": "ES2022" e "module": "NodeNext" para suportar top-level await e ESM nativo. O import básico do SDK fica assim:
// src/client.ts
import { BskyAgent, RichText } from "@atproto/api";
import "dotenv/config";
// Cliente apontando para o PDS padrão
export const agent = new BskyAgent({
service: process.env.BSKY_SERVICE ?? "https://bsky.social",
});
// Helpers de tipagem
export type PostRecord = {
text: string;
createdAt: string;
facets?: unknown[];
embed?: unknown;
};
// package.json — scripts úteis
{
"scripts": {
"post": "tsx src/post.ts",
"image": "tsx src/post-image.ts",
"rich": "tsx src/post-rich.ts"
}
}
💡 Dica prática
Use tsx em vez de ts-node — é mais rápido, suporta ESM out-of-the-box e não exige configuração extra. Para produção, transpile com tsc e rode o .js com Node puro.
Conceitos-chave
SDK oficial, isomórfico (Node + browser).
Classe principal com sessão e métodos de alto nível.
Protocolo RPC sobre HTTP usado pelo AT Protocol.
Runner TypeScript leve, ideal para scripts.
🔑 Login programático
O método agent.login() troca handle + App Password por um par de tokens (accessJwt e refreshJwt). O SDK mantém esses tokens em memória e os renova automaticamente nas chamadas seguintes — você só precisa logar uma vez por execução.
// src/login.ts
import { agent } from "./client.js";
async function authenticate(): Promise<void> {
const handle = process.env.BSKY_HANDLE;
const password = process.env.BSKY_APP_PASSWORD;
if (!handle || !password) {
throw new Error("BSKY_HANDLE e BSKY_APP_PASSWORD são obrigatórios");
}
const result = await agent.login({
identifier: handle,
password,
});
console.log("✅ Logado como:", result.data.handle);
console.log(" DID:", result.data.did);
console.log(" Email:", result.data.email);
}
authenticate().catch((err) => {
console.error("❌ Falha no login:", err);
process.exit(1);
});
Para reutilizar a sessão entre execuções (evitando login repetido em filas), persista o objeto agent.session e restaure com resumeSession():
// src/session-cache.ts
import fs from "node:fs/promises";
import { agent } from "./client.js";
import type { AtpSessionData } from "@atproto/api";
const SESSION_FILE = ".bsky-session.json";
export async function ensureSession(): Promise<void> {
try {
const raw = await fs.readFile(SESSION_FILE, "utf8");
const session = JSON.parse(raw) as AtpSessionData;
await agent.resumeSession(session);
console.log("♻️ Sessão restaurada");
} catch {
await agent.login({
identifier: process.env.BSKY_HANDLE!,
password: process.env.BSKY_APP_PASSWORD!,
});
await fs.writeFile(SESSION_FILE, JSON.stringify(agent.session));
console.log("🆕 Nova sessão criada");
}
}
✓ O que FAZER
- ✓Validar variáveis de ambiente antes do
login(). - ✓Persistir
agent.sessionem jobs longos. - ✓Capturar erro 401 e refazer o login automaticamente.
- ✓Logar o
didnos logs (handle pode mudar).
✗ O que NÃO fazer
- ✗Logar de novo a cada post — desperdiça tokens.
- ✗Usar a senha principal no lugar da App Password.
- ✗Commitar
.bsky-session.jsonno git. - ✗Compartilhar a App Password entre integrações.
Conceitos-chave
Token curto, autoriza chamadas autenticadas.
Token longo, renova o access expirado.
Restaura sessão sem novo handshake.
Handle ou e-mail — o SDK aceita ambos.
📝 Publicar texto
O método agent.post() recebe um record que segue o schema app.bsky.feed.post. Os dois campos obrigatórios são text (até 300 caracteres) e createdAt em ISO 8601.
// src/post.ts
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";
async function publish(): Promise<void> {
await ensureSession();
const result = await agent.post({
text: "Primeiro post via @atproto/api 🦋",
createdAt: new Date().toISOString(),
});
console.log("📤 Post publicado");
console.log(" URI:", result.uri);
console.log(" CID:", result.cid);
}
publish().catch(console.error);
O retorno traz uri (formato at://did:plc:.../app.bsky.feed.post/<rkey>) e cid (hash do conteúdo). Guarde os dois se quiser deletar ou referenciar o post depois.
// Validação de tamanho antes de publicar
const MAX_GRAPHEMES = 300;
function assertPostable(text: string): void {
const length = [...new Intl.Segmenter().segment(text)].length;
if (length > MAX_GRAPHEMES) {
throw new Error(`Post tem ${length} grafemas (máx ${MAX_GRAPHEMES})`);
}
}
assertPostable("Texto que cabe no limite ✨");
await agent.post({
text: "Texto que cabe no limite ✨",
createdAt: new Date().toISOString(),
langs: ["pt-BR"],
});
⚠️ Dica prática
O limite do Bluesky é em grafemas, não bytes nem code units. Um emoji composto (👨👩👧) conta como 1, mas "".length retorna 8. Use Intl.Segmenter para contar certo.
Conceitos-chave
Objeto tipado seguindo um Lexicon do AT Protocol.
Identificador único do post no formato at://.
Content ID — hash IPFS do conteúdo imutável.
Unidade visual percebida (uma "letra" para o usuário).
🖼️ Upload de imagem
Imagens no Bluesky são blobs separados do post. O fluxo é em duas etapas: primeiro uploadBlob() envia os bytes e devolve uma referência; depois você inclui essa referência no campo embed do post.
Limites: até 4 imagens por post, 1 MB por blob (após otimização), formatos image/jpeg, image/png e image/webp. Alt text é obrigatório para acessibilidade.
// src/post-image.ts
import fs from "node:fs/promises";
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";
type UploadedImage = {
alt: string;
image: {
$type: "blob";
ref: { $link: string };
mimeType: string;
size: number;
};
};
async function uploadImage(
path: string,
mimeType: string,
alt: string,
): Promise<UploadedImage> {
const bytes = await fs.readFile(path);
if (bytes.byteLength > 1_000_000) {
throw new Error(`Imagem ${path} excede 1 MB (${bytes.byteLength} bytes)`);
}
const { data } = await agent.uploadBlob(bytes, { encoding: mimeType });
return {
alt,
image: data.blob as UploadedImage["image"],
};
}
async function publish(): Promise<void> {
await ensureSession();
const hero = await uploadImage(
"./assets/capa.jpg",
"image/jpeg",
"Logo do projeto MkBlogs sobre fundo escuro",
);
const result = await agent.post({
text: "Lançamento oficial 🚀",
createdAt: new Date().toISOString(),
embed: {
$type: "app.bsky.embed.images",
images: [hero],
},
});
console.log("📤 Post com imagem:", result.uri);
}
publish().catch(console.error);
Para múltiplas imagens, faça Promise.all() dos uploads e passe o array no images:
const uploads = await Promise.all([
uploadImage("./a.jpg", "image/jpeg", "Captura da dashboard"),
uploadImage("./b.png", "image/png", "Gráfico de engajamento"),
uploadImage("./c.webp","image/webp", "Print da configuração"),
]);
await agent.post({
text: "Três capturas do release de hoje",
createdAt: new Date().toISOString(),
embed: {
$type: "app.bsky.embed.images",
images: uploads,
},
});
♿ Dica prática
O alt não é decorativo — leitores de tela dependem dele e o algoritmo do Bluesky usa o texto para ranqueamento. Descreva o que aparece, não "imagem" ou "foto". Imagem sem alt útil é tratada como spam por moderadores.
Conceitos-chave
Binário (imagem/vídeo) referenciado por CID.
Conteúdo anexo: imagens, link, quote ou record.
Necessário no encoding do uploadBlob.
Descrição textual para acessibilidade e indexação.
🔗 RichText e detecção de links
Diferente do Twitter, o Bluesky não detecta links automaticamente. URLs e menções viram texto puro a menos que você anexe facets — marcações posicionais que dizem ao cliente "esses bytes são um link, esses são uma menção".
A classe RichText do SDK faz o trabalho pesado: detecta padrões, calcula os offsets em UTF-8 (não UTF-16!) e gera o array de facets pronto para anexar ao post.
// src/post-rich.ts
import { RichText } from "@atproto/api";
import { agent } from "./client.js";
import { ensureSession } from "./session-cache.js";
async function publish(): Promise<void> {
await ensureSession();
const rt = new RichText({
text: "Lançamos a v1! Conheça em https://mkblogs.dev " +
"e siga @automationsai.net.bsky.social para novidades. #devtools",
});
// Detecta URLs, menções e hashtags + valida tamanho em grafemas
await rt.detectFacets(agent);
if (rt.graphemeLength > 300) {
throw new Error(`Texto tem ${rt.graphemeLength} grafemas`);
}
const result = await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
langs: ["pt-BR"],
});
console.log("📤 Post rico publicado:", result.uri);
console.log(" Facets detectadas:", rt.facets?.length ?? 0);
}
publish().catch(console.error);
Internamente, cada facet tem um index com byteStart/byteEnd e uma lista de features (link, mention, tag). Se quiser construir manualmente:
// Facet manual — útil quando o texto é gerado dinamicamente
const text = "Leia mais em mkblogs.dev";
const url = "https://mkblogs.dev";
const target = "mkblogs.dev";
const byteStart = new TextEncoder().encode(
text.slice(0, text.indexOf(target)),
).byteLength;
const byteEnd = byteStart + new TextEncoder().encode(target).byteLength;
await agent.post({
text,
createdAt: new Date().toISOString(),
facets: [
{
index: { byteStart, byteEnd },
features: [
{ $type: "app.bsky.richtext.facet#link", uri: url },
],
},
],
});
Para link preview (card com título, descrição e thumbnail), use embed.external com um blob de imagem opcional:
await agent.post({
text: "Novo guia publicado 🦋",
createdAt: new Date().toISOString(),
embed: {
$type: "app.bsky.embed.external",
external: {
uri: "https://mkblogs.dev/bluesky-guide",
title: "Guia completo do Bluesky API",
description: "Do zero ao primeiro bot em 30 minutos.",
// thumb opcional: blob retornado por uploadBlob()
},
},
});
✓ O que FAZER
- ✓Usar
RichText.detectFacets()para link/menção/hashtag automáticos. - ✓Calcular offsets em bytes UTF-8, nunca em chars JS.
- ✓Validar
graphemeLength <= 300antes do post. - ✓Anexar card de preview com
embed.external.
✗ O que NÃO fazer
- ✗Esperar autodetecção sem chamar
detectFacets(). - ✗Usar
str.indexOf()direto como byteStart. - ✗Colocar tanto
imagesquantoexternalnum único embed. - ✗Misturar text com markdown — Bluesky renderiza puro.
Conceitos-chave
Marcação posicional sobre um trecho do texto.
Helper que detecta links, menções e hashtags.
Posição em bytes, não em chars JavaScript.
Card de preview com título, descrição e thumb.
🎯 Resumo do Módulo
user.bsky.social e credencial revogável guardada no .env.tsx e BskyAgent configurado.agent.login() com persistência de sessão via resumeSession().agent.post() com validação de 300 grafemas via Intl.Segmenter.detectFacets(), card de preview com embed.external.Próximo Módulo:
3.2 — Mastodon: instâncias, tokens e a Fediverse API