Claude CodeでBlueskyのbotを作る — アプリパスワードの扱いと権限設計
BlueskyのbotをClaude Codeに書かせる手順。アプリパスワードと発行済みセッションの置き場所、denyルール、ログインのレート制限までコードで示します。
Blueskyのbotは、SlackやTelegramのbotと認証の形が違います。Botトークンが存在せず、渡すのはアカウントのハンドルとパスワードです。そのパスワードでcreateSessionを呼ぶと、短命なアクセストークンと長命なリフレッシュトークンが返ります。
守る対象は、パスワード1つでは済みません。ログイン後に保存したセッションファイルも、読ませてはいけない資格情報になります。この記事は、Claude Codeにbotを書かせる前に決めておく設計を、順に示します。
Blueskyのbotは「アカウントがbotになる」仕組み
Blueskyのドキュメントは、botを「自動で投稿するアカウント」と定義しています。別枠のbot用APIやアプリ登録はありません。人間のアカウントと同じcom.atproto.server.createSessionでログインし、同じレコード作成APIで投稿します。
ログインに成功すると、セッションオブジェクトに2つのトークンが入って返ります。
accessJwt: リクエストの認証に使うトークン。数分で期限が切れるrefreshJwt: より長持ちするトークン。アクセストークンの更新にだけ使う
SDKのエージェントはこの2つを保持し、以降のリクエストのヘッダーに自動で付けます。つまり、bot側が守る秘密は「入口のパスワード」と「出口のトークン2つ」の3種類です。
3つの認証方式を比べる
| 方式 | 渡すもの | 権限 | 現在の位置づけ |
|---|---|---|---|
| メインパスワード | 渡すものアカウント本来のパスワード | 権限全権限 | 現在の位置づけbotには使わない |
| アプリパスワード | 渡すものxxxx-xxxx-xxxx-xxxx形式の別パスワード | 権限一部を制限 | 現在の位置づけbotテンプレートが採用 |
| OAuth | 渡すもの認可フローで得るトークン | 権限スコープで限定 | 現在の位置づけSDKが推奨 |
アプリパスワードは、プロトコルの仕様上、メインパスワードと同じ方法でログインに使えます。違いは権限です。アカウントの削除や認証設定の変更といった破壊的な操作ができず、アプリパスワード自体の作成や取り消しもできません。アカウントごとに複数発行でき、個別に取り消せます。
アプリパスワードの作成APIは、nameを必須、privilegedを任意で受け取ります。privilegedは、機微なアカウント状態にアクセスできる権限で、信頼できるクライアント向けと説明されています。botには付けない選択が素直です。
もう1つ、押さえておく事実があります。@atproto/apiのREADMEは、ユーザー名とパスワードによる認証を非推奨とし、OAuthベースのセッション管理への移行を勧めています。一方で、botのスターターテンプレートは今もパスワード方式です。自分1人のアカウントで動かす小さなbotならアプリパスワードで足ります。他人のアカウントに代わって投稿するサービスにするなら、OAuthが筋です。この記事は前者を扱います。
専用アカウントとアプリパスワードを用意する
最初にやるのは、コードではなく資格情報の分離です。
- bot専用のアカウントを作る。普段使いのアカウントでbotを動かすと、投稿の上限やモデレーションの扱いが本人のアカウントに及ぶ
- そのアカウントでアプリパスワードを発行し、名前を付ける(例:
claude-bot) - 発行された
xxxx-xxxx-xxxx-xxxx形式の値を、自分のエディタで.envに書く
このフォーマットは目印として使えます。ログイン処理側で値を検査し、ハイフンを含まないメインパスワードが入っていたら起動を止める、という防御が書けます。仕様自体も、アプリは「アプリパスワードを使うよう促す」ことがベストプラクティスで、形式の照合で誤入力を防げると説明しています。
環境変数名は、テンプレートのBLUESKY_USERNAMEとBLUESKY_PASSWORDから変えます。値が何かを名前で示すためです。
mkdir -p bsky-bot && cd bsky-bot
git init
printf 'BLUESKY_HANDLE=\nBLUESKY_APP_PASSWORD=\n' > .env.example
cp .env.example .env
printf '.env\n.bsky-session.json\nnode_modules/\n' > .gitignore.envの中身は、Claudeに触らせず自分で書きます。.bsky-session.jsonは、あとで作るセッション保存ファイルの名前です。まだ存在しなくても、先に.gitignoreへ入れておきます。
コードより先にdenyルールを入れる
実装を頼む前に、.claude/settings.jsonで資格情報の読み取りを止めます。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.bsky-session.json)",
"Bash(curl *)",
"Bash(wget *)"
]
}
}ファイルを2つ挙げる理由が、Blueskyらしい点です。.envにはパスワードがあり、.bsky-session.jsonにはリフレッシュトークンが入ります。後者を漏らせば、パスワードを知らなくても、トークンが有効な間はアカウントとして動かれます。
Read(./.env.*)とまとめて書くと、変数名を教えるための.env.exampleまで読めなくなります。そのため、ここでは名前を個別に挙げました。
denyルールの効き方と限界
Claude Codeの権限ドキュメントから、設計に関わる点を拾います。
- 評価順はdeny、ask、allowで、denyが最初に一致した時点で結果が決まる。allowでdenyの例外は作れない
Readのdenyは、同じパスへのEditとWriteもブロックする。編集側はv2.1.208以降、書き込み側はv2.1.228以降が必要- 組み込みのファイルツールと、
catやheadなどClaude Codeが認識するBashコマンドには効く - ファイル名を指定せずに読むコマンド(例えばそのディレクトリでの
grep -r pattern .)や、スクリプトが自分でファイルを開く場合には効かない Bash(curl *)は、書かれた形にしか一致しない。パス指定の/usr/bin/curlやsh -cの中は止まらない
最後の2点が重要です。botを実行すると、Nodeプロセスが.envを読みます。Claudeがnode bot.jsを走らせても、プロセス内の読み取りはdenyの対象外です。プロセスの標準出力やログにパスワードとトークンを出さなければ、Claudeには渡りません。ここは権限設定ではなく、コードの規律になります。
OSレベルで塞ぎたいときは、サンドボックスを有効にします。認証情報のファイルを隠す設定は、sandbox.credentials.filesの解説にまとめました。コマンド全文を検査したいなら、PreToolUseフックが使えます。書き方はHooksガイドにあります。
botの実装をClaude Codeに頼む
権限が固まったら、制約ごと依頼します。ここで効くのは、「何を読めないか」と「ログイン回数の上限」をプロンプトに入れることです。
Blueskyに3時間ごとに投稿するbotをTypeScriptで書いて。
- SDKは@atproto/api。ログインはBLUESKY_HANDLEとBLUESKY_APP_PASSWORD
- .envと.bsky-session.jsonは読めない。変数名は.env.exampleを参照
- ログインは起動時に1回だけ。成功したらセッションをファイルに保存し、
次回以降はresumeSessionで復元する。失敗したときだけ再ログイン
- ログにパスワードとトークンを出さない
- 初回だけプロフィールにbotの自己ラベルを付ける出力は毎回変わります。次は、この指示から期待する実装の一例です(botテンプレートとSDKのREADMEの形に沿っています)。
import "dotenv/config";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { AtpAgent } from "@atproto/api";
const SESSION_FILE = ".bsky-session.json";
const agent = new AtpAgent({ service: "https://bsky.social" });
async function authenticate(): Promise<void> {
if (existsSync(SESSION_FILE)) {
try {
await agent.resumeSession(JSON.parse(readFileSync(SESSION_FILE, "utf8")));
return;
} catch {
console.error("セッションの復元に失敗。ログインし直します");
}
}
await agent.login({
identifier: process.env.BLUESKY_HANDLE!,
password: process.env.BLUESKY_APP_PASSWORD!,
});
writeFileSync(SESSION_FILE, JSON.stringify(agent.session), { mode: 0o600 });
}
async function main(): Promise<void> {
await authenticate();
await agent.post({ text: "🙂" });
console.log("投稿しました");
}
main();生成されたコードを受け取ったら、次の3点を目で確かめます。
console.logにagent.sessionやprocess.envを丸ごと渡していないかloginがループや定期実行の内側に入っていないか- 自己ラベルの付与が、毎回の起動ではなく初回だけになっているか
自己ラベルは、テンプレートが勧める作法です。プロフィールを更新するupsertProfileで、com.atproto.label.defs#selfLabelsにbotの値を入れます。ユーザーやモデレーションの仕組みが自動化アカウントだと識別できるようになります。設定は1回で済みます。
テンプレートの「3時間ごと」に注意
Botsのページには、3時間おきに笑顔の絵文字を投稿するスクリプトが載っています。ただしこのスクリプトは、cronパッケージをimportしながら、main()を1回呼ぶだけで終わります。定期実行そのものは書かれていません。
そのままコピーして走らせると、1回投稿して終了します。定期実行は、OSのcronやホスティング側のスケジューラーで起動するか、cronパッケージでスケジュールを書くか、どちらかを自分で足します。後者を選ぶとき、loginをジョブの中に置くと、次の節のログイン上限に近づきます。
ログインと書き込みは別の上限で数えられる
レート制限の表を見ると、ログインの上限は書き込みよりずっと低く設定されています。
| 操作 | 単位 | 上限 |
|---|---|---|
com.atproto.server.createSession | 単位アカウントごと | 上限5分で30回、1日で300回 |
| レコード書き込み(全体) | 単位アカウントごと | 上限1時間5,000ポイント、1日35,000ポイント |
| 全APIリクエスト | 単位IPごと | 上限5分で3,000回 |
書き込みは、作成が3ポイント、更新が2ポイント、削除が1ポイントで数えます。作成だけなら、1時間に最大1,666件、1日に最大11,666件です。3時間おきに1投稿するbotには、はるかに余裕があります。
詰まるのはログインのほうです。cronのジョブごとにloginを呼ぶ構成でも、3時間おきなら1日8回にしかなりません。ところが、エラー時の再試行を無制限のループにすると、5分で30回の枠をすぐ使い切ります。上限を超えたリクエストは、通常HTTP 429で返ります。
セッション管理の節も同じ方針です。loginはセッションごとに1回だけ呼び、その後は何度でも投稿できます。ログイン回数を減らしたければ、セッションをディスクに保存してresumeSessionで復元します。さきほどのコードは、この形です。
再試行を書かせるときは、プロンプトに「リトライは回数上限と待機時間を付ける」と加えます。上限のない再試行は、ログインの枠を使い切る原因になります。
他人に絡むbotはオプトインにする
投稿するだけのbotは、歓迎される形です。一方、他のユーザーに反応するbotには条件が付いています。いいね、リポスト、返信などは、相手がbotのアカウントをタグ付けしたときだけに限ります。オプトインでなければ、スパムと見なされることがあります。
Claudeにメンション応答型のbotを頼むなら、この制約もプロンプトに入れます。「タグ付けされた投稿にだけ返信する。それ以外のフィードを走査して絡まない」と書いておくと、実装がその方針に沿います。
denyが効いているかを確かめる
設定を書いたら、実際に効いているかを検証します。Claudeに頼むのが手っ取り早い方法です。
.envの中身を表示してファイル読み取りが拒否されれば、Readのdenyが効いています。続けて、/permissionsで現在のルールを開き、4行のdenyが並んでいるかを見ます。コマンドの使い方は/permissionsコマンドの解説にあります。
ここで、拒否されたからと安心しすぎないでください。確認できたのは「ファイルツール経由の読み取り」だけです。プロセスのログ出力経由の漏れは、実際にbotを動かし、出力を自分の目で読んで確かめます。
よくあるつまずき
- ログインが「認証要素のトークンが必要」で失敗する:
createSessionにはAuthFactorTokenRequiredというエラーがあり、入力にauthFactorTokenの項目があります。アカウント側で追加の認証要素を設定していると、このエラーになる可能性があります。トークンの受け渡しが必要になるため、無人で動くbotには向きません - 接続先がずれる: サンプルの
serviceはhttps://bsky.socialです。自分でPDSをホストしているアカウントは、そのPDSのURLを指定します - 値の混入:
.envに、アプリパスワードではなくメインパスワードを入れてしまう。形式検査で起動を止めると防げます - セッションファイルの残骸: 古い
.bsky-session.jsonが残っていると、復元の失敗が続くことがあります。消せば次回起動で再ログインします。Claudeにファイルの内容を見せる必要はないので、削除は自分のターミナルで行います
アプリパスワードは、不要になったら取り消せます。漏れた疑いがあるときは、新しいものを発行して古いものを取り消す手順が早いです。メインパスワードを変える必要はありません。
他のbotとの比較で見える設計の差
同じ型で作るbotでも、資格情報の形は変わります。TelegramのBotトークンはURLに埋め込まれ、設計はトークン権限とwebhookが軸でした。SlackはBolt for JavaScriptとSocket Modeでトークンを分けて持ちます。
Blueskyでは、パスワードから得たセッションが第2の秘密になります。denyに入れるファイルが、.envの1つから.envとセッションファイルの2つへ増える点が、実装上の違いです。
まとめ
Blueskyのbotでは、アプリパスワードを.envに、ログイン後のセッションをファイルに置きます。この2つをpermissions.denyで読み取り不可にしてから、Claude Codeに実装を頼みます。ログインは起動時に1回、あとはresumeSessionで復元する構成にすると、1日300回のログイン上限に触れません。
denyが守るのは、Claudeのファイルツールを経由した読み取りです。botのプロセスが出す出力は別の経路なので、ログにトークンを出さないコードの規律を併せて持ちます。OAuthへの移行は、他人のアカウントを扱う段階で検討します。