Bluesky.bot

Schedule posts

A bot does not need to run all the time. The simplest reliable setup is a script that runs every few minutes, posts what is due, and exits.

Post only what is due

Give the script a schedule and a fixed run interval. Each run posts the items whose time fell since the previous run, so nothing is posted twice and nothing is missed, as long as the runs stay on time.

scheduled.ts
// scheduled.ts: run every 15 minutes (cron, GitHub Actions...). Each run
// posts what fell due since the previous run, so nothing is posted twice.
import { AtpAgent } from "@atproto/api";

const RUN_EVERY_MS = 15 * 60 * 1000;

const SCHEDULE = [
  { at: "2026-11-02T08:00:00Z", text: "Good morning! Today's forecast is up." },
  { at: "2026-11-02T18:00:00Z", text: "Evening update: clear skies tonight." },
];

const now = Date.now();
const due = SCHEDULE.filter((post) => {
  const at = Date.parse(post.at);
  return at <= now && at > now - RUN_EVERY_MS;
});

if (due.length > 0) {
  const agent = new AtpAgent({ service: "https://bsky.social" });
  await agent.login({
    identifier: process.env.BLUESKY_HANDLE ?? "",
    password: process.env.BLUESKY_APP_PASSWORD ?? "",
  });
  for (const post of due) {
    await agent.post({ text: post.text, createdAt: new Date().toISOString() });
    console.log(`Posted: ${post.text}`);
  }
} else {
  console.log("Nothing due.");
}

The same in Python:

scheduled.py
# scheduled.py: run every 15 minutes (cron, GitHub Actions...). Each run
# posts what fell due since the previous run, so nothing is posted twice.
import os
from datetime import datetime, timedelta, timezone

from atproto import Client

RUN_EVERY = timedelta(minutes=15)

SCHEDULE = [
    ("2026-11-02T08:00:00+00:00", "Good morning! Today's forecast is up."),
    ("2026-11-02T18:00:00+00:00", "Evening update: clear skies tonight."),
]

now = datetime.now(timezone.utc)
due = [
    text
    for at, text in SCHEDULE
    if now - RUN_EVERY < datetime.fromisoformat(at) <= now
]

if due:
    client = Client()
    client.login(os.environ["BLUESKY_HANDLE"], os.environ["BLUESKY_APP_PASSWORD"])
    for text in due:
        client.send_post(text)
        print(f"Posted: {text}")
else:
    print("Nothing due.")

For a schedule that changes, read it from a file or a database instead of the code, and record what was posted so a late or repeated run cannot post twice.

Run it with cron

On a server you control, crontab -e and add one line (every 15 minutes):

bash
*/15 * * * * cd /home/bot/my-bot && BLUESKY_HANDLE=weather.bluesky.bot BLUESKY_APP_PASSWORD=$(cat ~/.bluesky-app-password) npx tsx scheduled.ts >> bot.log 2>&1

Run it with GitHub Actions

In your bot's repository, add .github/workflows/post.yml, and store the credentials as repository secrets (Settings → Secrets and variables → Actions):

yaml
name: Post
on:
  schedule:
    - cron: "*/15 * * * *"
  workflow_dispatch:
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx tsx scheduled.ts
        env:
          BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }}
          BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}

GitHub runs scheduled workflows on a best-effort basis: a run can start late or, under load, be skipped. Keep the run interval in the script equal to the schedule, and do not rely on the exact minute.

Stay within the limits

  • Post at a human pace. A bot that floods timelines gets muted, reported, or rate limited.
  • Sign in once per run, not once per post: sign-ins have their own, stricter limit.
  • Read Bluesky's rate limits before running a busy bot.