Blueskyへの投稿は3つの呼び出しで完結します。トークンを取得するcom.atproto.server.createSession、画像がある場合のcom.atproto.repo.uploadBlob、そしてapp.bsky.feed.postレコードを書き込むcom.atproto.repo.createRecordです。アプリ審査もOAuthの同意画面の承認も不要です。そのシンプルさの代償として、リンクとメンションは自動検出されません。UTF-8上のバイトオフセットを自分で計算する必要があります。
Bluesky APIで何ができて、何ができないのか?
投稿レコードは単純なJSONドキュメントです。必須フィールドは$type(app.bsky.feed.post)、text、そしてISO 8601形式のタイムスタンプであるcreatedAtです。それ以外はすべて任意の構造です。
| 機能 | 対応状況 |
|---|---|
| 1投稿あたりの画像数 | 最大4枚 |
| 画像サイズ | 投稿ドキュメントに記載の1枚あたり1,000,000バイト |
| 1投稿あたりのblob合計サイズ | 最大2,000,000バイト |
| 代替テキスト | 画像ごとに必須、アスペクト比付き |
| 引用投稿 | app.bsky.embed.record |
| リンクカード | app.bsky.embed.external(サムネイルは自前で用意) |
| 返信 | rootとparentの強参照を伴うreply |
| 言語タグ | ["en-US"]のような配列のlangs |
先に押さえておくべきギャップです。
- 自動検出は一切ありません。 リンク、メンション、ハッシュタグはファセットを付けない限りただのテキストです。ファセットなしで貼り付けたURLはクリックできません。
- リンクカードは自分の仕事です。
app.bsky.embed.externalにはタイトル、説明、サムネイルが必要です。ページを自動でスクレイピングしてくれる仕組みはありません。 - メンションには解決済みのDIDが必要です。 ファセットにハンドルをそのまま入れることはできず、事前にハンドルをDIDへ解決しておく必要があります。
- **画像はアップロード前にEXIFを削除する必要があります。**ドキュメントに記載の通りです。
- 投稿ドキュメントには文字数・書記素数の上限が明記されていません。 そのページから数字を確認できなかったため、ここでは数値を示しません。クライアント側で実際に課されている制限については、Blueskyの文字数制限に関する記事を参照してください。
注: ここに記載の数値は、Bluesky開発者ドキュメント(docs.bsky.app、現在はbsky.network/docsへリダイレクト)を2026年9月時点で確認したものです。プラットフォームはこれらを予告なく変更します。
認証モデルはどうなっているのか?
ソーシャルプラットフォームの認証セクションとして、これはおそらく最も短い部類に入ります。
アプリパスワード方式にはアプリ登録もアプリ審査もOAuth同意画面も一切ありません。ユーザーがBlueskyの設定でアプリパスワードを作成し、それをあなたのソフトウェアに渡します。あなたはハンドルとそのアプリパスワードをcom.atproto.server.createSessionに渡して、アクセスJWTとリフレッシュJWTを取得します。アクセストークンは短命なので、リフレッシュJWTで更新します。
ここには2つの帰結があります。アプリパスワードはユーザーが直接あなたに渡す認証情報です。同意画面がないということはプラットフォーム側で仲介されるスコープ付与もないということなので、保管の責任はすべてあなたに委ねられます。暗号化して保管し、ユーザーが目に見える形で接続を解除できる手段を用意してください。
そしてこのネットワークは一社が所有しているわけではありません。AT Protocolのアカウントはパーソナルデータサーバー上で動作し、PDSは自前でホストできます。あなたのクライアントはユーザーのPDSホストと通信するため、bsky.socialをハードコードすれば今日は動きますが、それはプロトコルのあるべき姿ではありません。ホストはユーザーのDIDドキュメントから読み取ってください。
実際にどう投稿するのか? 呼び出しの順序
identifier(ハンドルまたはDID)とpassword(アプリパスワード)を添えてPOST /xrpc/com.atproto.server.createSessionを呼び出します。accessJwt、refreshJwt、didが返ります。- 画像がある場合: 画像ごとに
POST /xrpc/com.atproto.repo.uploadBlobを、生のバイトデータと正しいContent-Typeとともに呼び出します。それぞれblobリファレンスが返ります。 - リンクやメンションのファセットを、
textのUTF-8エンコーディングに対するバイトオフセットとして計算します。 repoにあなたのDID、collectionにapp.bsky.feed.post、そしてレコード本体を添えてPOST /xrpc/com.atproto.repo.createRecordを呼び出します。
const base = 'https://bsky.social/xrpc';
const auth = await fetch(`${base}/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: handle, password: appPassword }),
}).then((r) => r.json());
const text = 'Notes on the release: https://example.com/changelog';
const url = 'https://example.com/changelog';
const enc = new TextEncoder();
const byteStart = enc.encode(text.slice(0, text.indexOf(url))).length;
await fetch(`${base}/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessJwt}`,
},
body: JSON.stringify({
repo: auth.did,
collection: 'app.bsky.feed.post',
record: {
$type: 'app.bsky.feed.post',
text,
createdAt: new Date().toISOString(),
facets: [
{
index: { byteStart, byteEnd: byteStart + enc.encode(url).length },
features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }],
},
],
},
}),
});
ファセットの算出を細かく見る
byteStartとbyteEndは、テキストのUTF-8バイトに対するオフセットであり、JavaScriptの文字列インデックスでも文字数でもありません。
"café".lengthはJavaScriptでは4ですが、UTF-8エンコーディングでは5バイトです。リンクの前に絵文字が1つあるだけで、文字列インデックスは2しかずれないのに、オフセットは4バイトもずれます。これを間違えると、サーバーからは何のエラーも返らないまま、リンクが壊れたテキストとして表示されたり、間違った範囲がハイライトされたりします。レコード自体は有効なままで、単に誤ったバイト位置を指しているだけだからです。
文字列は一度だけエンコードし、バイト配列上でオフセットを求め、この2つの座標系を決して混同しないようにしてください。リンクのファセット機能が使うのはuriであり、urlではありません。
Blueskyのレート制限はどうなっているのか?
書き込みはアカウントごとのポイント制で管理されています。CREATEは3ポイント、UPDATEは2ポイント、DELETEは1ポイントが消費され、上限は1時間あたり5,000ポイント、1日あたり35,000ポイントです。これはおおよそ1時間あたり1,666件、1日あたり11,666件のcreateに相当します。
| 制限 | 値 |
|---|---|
| 書き込みポイント | 5,000/時間、35,000/日 |
| create数(算出値) | 約1,666/時間、約11,666/日 |
| PDS全体のリクエスト | IPごとに5分あたり3,000件 |
createSession | アカウントごとに5分あたり30回、1日あたり300回 |
| blobアップロードの上限 | 52,428,800バイト(50MB) |
createSessionの制限は、スケジューラーがよく引っかかるところです。アカウントごとに5分あたり30回ということは、セッションをキャッシュして更新すべきであり、投稿のたびにログインし直すべきではないということです。ジョブごとにセッションを作成するワーカーは、投稿の上限に達するよりずっと前に自らレート制限にかかってしまいます。
2つのblobの数値の違いに注意してください。PDSは最大50MBまでのblobを受け付けますが、投稿ドキュメントでは1画像あたり1,000,000バイト、合計2,000,000バイトという制限が示されています。小さい方に合わせてサイズを調整してください。
注: ここに記載の数値は、Bluesky開発者向けレート制限ドキュメントを2026年9月時点で確認したものです。プラットフォームはこれらを予告なく変更します。
実際に3週間分の工数を食うのは何か?
Blueskyは主要プラットフォームの中でも間違いなく連携コストが最も低い部類に入りますが、「安い」は「無料」ではありません。
実際のテキストに対するファセット計算。 URL、末尾の句読点、ハンドル、ハッシュタグを検出し、それぞれのマッチをUTF-8バイトオフセットに変換し、ユーザーがテキストを編集した際にも正しさを保つこと。ここにバグが潜みます。
Blobの容量管理。 最大4枚の画像で合計2MBという制限は、サーバー側でのリサイズと再エンコード、EXIF除去、そしてユーザーの写真が収まらない場合の対応を必要とします。
リンクカード。 投稿を投稿らしく見せるには、対象ページを取得してタイトル・説明・画像を抽出し、その画像をblobとしてアップロードしてexternal埋め込みを構築する必要があります。これはタイムアウトと失敗処理を備えた小さなクローラーそのものです。
セッションのキャッシュ(5分あたり30回という上限のため)、そしてPDSホストの解決(bsky.socialを前提にすると、自前ホスト型アカウントであなたの側では直せない形で壊れるため)。
コンテンツをミラーしている場合は、XからBlueskyへのクロス投稿とThreadsからBlueskyへのクロス投稿の両方で、レコードが有効になる前に解消しておくべき文字数と メディアの違いを扱っています。
要点まとめ
- 3つの呼び出し:
createSession、画像用のuploadBlob、app.bsky.feed.postレコードを伴うcreateRecord。 - アプリ審査もOAuth画面もなし。ユーザーから渡されるアプリパスワードのみ。
- リンクとメンションにはUTF-8上のバイトオフセットを伴うファセットが必要です。自動検出は一切ありません。
- 1投稿あたり画像4枚、1枚あたり1,000,000バイト、合計2,000,000バイト、代替テキスト必須。
- 書き込みは1createあたり3ポイントを消費し、1時間あたり5,000、1日あたり35,000が上限です。
createSessionは5分あたり30回です。 - アカウントは自前ホスト型のPDS上でも動作できます。ホストをハードコードしないでください。
Blueskyと他14のネットワークをあわせて投稿する
Blueskyは扱いやすい部類です。同じ製品では通常、X(OAuth 2.0 PKCE、分割メディアアップロード、リクエスト単位の課金)、Threads(Metaのアプリ審査、コンテナ経由の公開、60日ごとのトークン更新)、TikTok(投稿できるようになる前提としてContent Posting APIの審査)も必要になります。それぞれに固有の認証、メディアパイプライン、非同期の失敗モデルがあります。
BulkPublishはBlueskyを含む15のプラットフォームを一つのREST APIでカバーし、ファセット計算、blobのリサイズ、セッション更新をサーバー側で処理します。開発者ドキュメントとREST APIリファレンスにエンドポイントの詳細があり、Bluesky投稿のスケジューリングではコードを書かずに行う方法を扱っています。