How-to guide

How to builda Bluesky bot

A Bluesky bot is a normal account that posts on a schedule. Create the account, add the bot self-label Bluesky recommends, then run a script from cron or GitHub Actions. The script either logs in with an App Password and calls atproto, or calls OpenTweet with an API key after you connect the account once over OAuth.

Last updated: September 2026

7-day free trial. Cancel anytime.

The five steps

  1. Create a separate account for the bot

    A bot on your personal account mixes automated posts into your feed and puts your main account at risk if the script misbehaves. A custom domain handle works if you want one.

  2. Add the bot self-label

    Bluesky docs recommend it for every bot account: a self-label on the profile so people and moderation tools can tell it is automated. You do it once, at setup.

  3. Pick how the script authenticates

    Path A: an App Password in a repository secret, used by the atproto SDK. Path B: connect the account to OpenTweet once over atproto OAuth, then the script only holds an OpenTweet API key.

  4. Schedule it

    GitHub Actions cron is free and needs no server. A crontab on any machine or an AI agent with a tool call works the same way, since each run is one script or one HTTP request.

  5. Stay well under the limits

    Log in once per run, not once per post, and keep volume far below the ceiling. The limits are in the box below.

What Bluesky asks of bot accounts

The Bluesky developer docs have a bots page, and it makes three requests. Quoted, not paraphrased:

  • Label it. "As a best practice, bot accounts should identify themselves by adding a self-label to their profile." Their example uses the label value bot.
  • Only interact on opt-in. "If your bot interacts with other users, please only interact (like, repost, reply, etc.) if the user has tagged the bot account. It must be an opt-in interaction, or else your bot may be taken for spam."
  • Respect the limits. "Keep in mind that bots should respect the network's rate limits." They also note that calling login many times in a short period can trigger rate limits, and that once per session is enough.

The Bluesky Community Guidelines (updated September 2025) add the general rule every bot falls under: "Do not send spam or repeatedly post content in ways that disrupt normal conversations or service use."

Path A: App Password and a GitHub Actions cron

The route most tutorials take. Free, and you own every line.

Create an App Password for the bot account, then add BLUESKY_HANDLE and BLUESKY_APP_PASSWORD as repository secrets. The script uses TextBuilder so the link is clickable; a plain string would post the URL as text.

bot.py
# bot.py  (pip install atproto)
import os
from atproto import Client, client_utils

client = Client()
client.login(os.environ["BLUESKY_HANDLE"], os.environ["BLUESKY_APP_PASSWORD"])

text = (
    client_utils.TextBuilder()
    .text("Today's reading: ")
    .link("the changelog", "https://example.com/changelog")
)
client.send_post(text)
.github/workflows/bluesky-bot.yml
# .github/workflows/bluesky-bot.yml
name: bluesky-bot
on:
  schedule:
    - cron: "17 14 * * *"   # 14:17 UTC daily, off the top of the hour
  workflow_dispatch:
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install atproto
      - run: python bot.py
        env:
          BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }}
          BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}

What that secret can do

An App Password grants close to everything the account can do, and anyone with write access to the repository workflows can use it. Revoking it means deleting it in Bluesky settings. That is acceptable for a throwaway bot account and a poor fit for an account that matters.

Path B: OAuth once, then an API key from anywhere

Connect the bot account to OpenTweet over atproto OAuth. The workflow only holds an ot_ key.

Sign in to OpenTweet, connect the bot account in the dashboard (it works with a self-hosted PDS too), and add your API key as the OPENTWEET_API_KEY secret. The whole job is one curl, with no Python setup step:

.github/workflows/bluesky-bot.yml
# .github/workflows/bluesky-bot.yml
name: bluesky-bot
on:
  schedule:
    - cron: "17 14 * * *"   # 14:17 UTC daily
  workflow_dispatch:
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - name: Post to Bluesky
        env:
          OPENTWEET_API_KEY: ${{ secrets.OPENTWEET_API_KEY }}
        run: |
          TEXT="Daily reminder to ship something. $(date -u +%F)"
          curl -sS --fail-with-body -X POST https://opentweet.io/api/v1/posts \
            -H "Authorization: Bearer $OPENTWEET_API_KEY" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg text "$TEXT" '{text: $text, platforms: ["bluesky"], publish_now: true}')"

jq builds the JSON so quotes in the text cannot break it. Links in the text become clickable facets. If the post should go to X or LinkedIn as well, add them to platforms. The same request works from a crontab, a serverless function, or an agent through the hosted MCP server. For posts at an exact time, send scheduled_date ahead of time instead of relying on when the runner wakes up.

What OpenTweet will not do for a bot

It does not attach link cards, shorten links, split long text, or post video to Bluesky. A post over 300 graphemes is skipped on Bluesky with a reason in the response rather than cut. It does not read replies or mentions, so a bot that answers people needs Path A. Posts that contain a link need the Advanced or Agency plan: on Pro the URL is removed from the text before it publishes, which matters for an RSS bot.

The RSS bot version

Run it hourly. It posts each entry published in the last hour.

rss_bot.py
# rss_bot.py  (pip install feedparser requests)
# Run every hour; posts entries published in the last hour.
import calendar
import os
import time

import feedparser
import requests

FEED = "https://example.com/blog/rss.xml"
WINDOW = 60 * 60  # seconds, match your cron interval

feed = feedparser.parse(FEED)
cutoff = time.time() - WINDOW

for entry in feed.entries:
    published = entry.get("published_parsed")
    if not published or calendar.timegm(published) < cutoff:
        continue
    title = entry.title[:240]  # leave room for the link under 300
    r = requests.post(
        "https://opentweet.io/api/v1/posts",
        headers={"Authorization": f"Bearer {os.environ['OPENTWEET_API_KEY']}"},
        json={
            "text": f"{title}\n\n{entry.link}",
            "platforms": ["bluesky"],
            "publish_now": True,
        },
        timeout=30,
    )
    print(r.status_code, (r.json().get("posts") or [{}])[0].get("results"))

The time window keeps it stateless, which suits GitHub Actions. The trade-off: a delayed or skipped run can miss an entry, so for a feed where every item matters, store the last posted link somewhere (a file committed back to the repo, or a key-value store) and compare against that instead. Python's slice counts code points, not graphemes, which is why the title is cut to 240 to leave margin under 300. Check a sample with the Bluesky character counter.

Rate limits, in numbers

  • 5,000 points an hour and 35,000 a day, per account.
  • Creating a record costs 3 points, an update 2, a delete 1.
  • So at most 1,666 posts an hour and 11,666 a day. A 10-part thread is 10 records, 30 points.
  • Logins: 30 per 5 minutes and 300 a day. A job every 5 minutes that logs in each run uses 288 of those 300.
  • Past the limit, HTTP 429. Through OpenTweet, that post's Bluesky result comes back failed with a rate-limit message.

Source: Bluesky developer docs, rate limits page, as of September 2026. The full math is in how many posts a Bluesky bot can make per day.

Path A vs Path B

For a scheduled botApp Password + SDKOAuth + OpenTweet key
Cost
Free
From $11.99/mo, Bluesky on every plan
Posts that contain a link
Unlimited
Advanced and Agency only. On Pro the URL is removed from the text
Secret in the repo
App Password, close to full account access
OpenTweet API key; the Bluesky OAuth grant stays server-side
Revoking it
Delete the App Password in Bluesky settings
Rotate the key, or disconnect Bluesky in the dashboard
Clickable links
Build facets (TextBuilder, RichText)
Facets added for you
Link cards
Yes, if you build app.bsky.embed.external
No
Login limits
30 sessions per 5 min, 300 per day
Not your concern: no login per run
Exact-time posts
Cron timing, may be delayed under load
Also accepts scheduled_date, published by OpenTweet
Same post to X or LinkedIn
Separate integrations
Add them to platforms
Replying to mentions, reading feeds
Yes
No, publishing only

GitHub schedules: shortest interval 5 minutes, runs can be delayed at the start of every hour, and in a public repo they are disabled after 60 days without activity.

Frequently asked questions

How do I make a Bluesky bot?

Create a separate Bluesky account for the bot, add the bot self-label to its profile, and run a script on a schedule that creates posts. The script either logs in with an App Password and calls the atproto SDK, or calls OpenTweet with an API key after you connect the account once over OAuth. GitHub Actions cron is the usual free scheduler.

Do Bluesky bots have to be labeled?

Bluesky developer docs say that, as a best practice, bot accounts should identify themselves by adding a self-label to their profile, which helps users and moderation tools recognize automated accounts. The label value in their example is "bot". It is a one-time step when setting up the account.

Can my Bluesky bot reply to or like other posts?

Bluesky docs say a bot should only interact (like, repost, reply) with users who have tagged the bot account, because the interaction must be opt-in or the bot may be taken for spam. A bot that only posts to its own feed does not run into this.

How many posts can a Bluesky bot make per day?

Up to 11,666 records a day and 1,666 an hour per account. Bluesky gives each account 35,000 points a day and 5,000 an hour, and creating a record costs 3 points. Past that, the API returns HTTP 429.

How do I make a Bluesky RSS bot?

Run a scheduled job that reads the feed, picks entries published since the last run, and posts the title plus link for each. The code below does it with feedparser and one HTTP call per entry. Keep title plus link under 300 graphemes, and make sure the link is clickable, which needs a link facet.

Is GitHub Actions reliable enough for a Bluesky bot?

For most bots, yes, with caveats from GitHub docs: the shortest schedule is every 5 minutes, scheduled runs can be delayed under high load (the start of every hour is a known peak), and in a public repository scheduled workflows are disabled after 60 days without repository activity.

A Bluesky bot without an App Password in the repo

Connect the bot account over OAuth once, then post from GitHub Actions, cron or an agent with one API key. From $11.99 a month, Bluesky on every plan.