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: 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: 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):
*/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>&1Run 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):
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.