Auto-post new blog entries from kyle.pericak.com to Mastodon.
Same pattern as the Twitter and Bluesky auto-posters: a K8s CronJob
polls /feed.xml, dedupes against a PVC-stored GUID file, and posts
any new items. ArgoCD-managed via the shared cronjob Helm chart.
Each destination runs in its own CronJob with its own PVC, so one destination failing has no effect on the others.
Title + blank line + UTM-stamped URL. Mastodon's server-side crawler
fetches the page's OpenGraph tags and generates a rich link-preview
card, so we don't upload an image ourselves — og:image on the blog
post is what produces the image in the card.
@Ralph Secure My Laptop
https://kyle.pericak.com/secure-my-laptop.html?utm_source=mastodon&utm_medium=social&utm_campaign=blog_post
Leading @ is stripped from titles because a status starting with
@handle is treated as a mention/reply and gets hidden from the
timeline of anyone not already following the mentioned account.
500-char limit (mastodon.social default) is enforced conservatively.
Every link gets
utm_source=mastodon&utm_medium=social&utm_campaign=blog_post so
GA4 attributes the traffic to "mastodon / social".
Persistent file on a PVC tracking posted item GUIDs
(/cache/posted-guids). Same rationale as the Twitter and Bluesky
jobs — timestamp filtering is brittle, GUIDs are stable.
CronJob (every 2h)
→ GET /feed.xml
→ diff against /cache/posted-guids
→ for each new item:
→ POST /api/v1/statuses with Bearer <access_token>
→ Mastodon crawler fetches OG tags → renders link card
→ append GUID to posted-guids
→ exit
infra/ai-agents/cronjobs/scripts/mastodon-rss.pyinfra/ai-agents/cronjobs/scripts/crosspost_common.py
(RSS parsing, GUID dedup, UTM injection — shared with tweet-rss.py
and bluesky-rss.py)infra/ai-agents/cronjobs/helm/templates/mastodon-rss.yamlmastodon-rss-state (10Mi)secret/ai-agents/mastodonkpericak/ai-agent-runtime (has requests; no new
dependency — script calls Mastodon's REST API directly)Mastodon uses a single Bearer access token for server-to-server posting to the app owner's own timeline. No OAuth flow is required because the token itself represents the bot account.
Create the token at {instance}/settings/applications → New
application with scope write:statuses and copy the "Your access
token" value. The client key / client secret shown on the same page
are only used for OAuth-on-behalf-of-other-users flows and are not
needed here.
On every run, after reading the token, the script calls
verify_credentials and — if bot is false — PATCHes
update_credentials with bot=true. Idempotent: after the first
successful run it's one GET and no writes. Mirrors the Bluesky
bot self-label behavior so automated accounts are disclosed as
such per fediverse convention.
Store at secret/ai-agents/mastodon:
instance_url # e.g. https://mastodon.social
access_token # from Settings → Development → your app
Write with bin/vault-cmd.sh:
vault kv put secret/ai-agents/mastodon \
instance_url=https://mastodon.social \
access_token="$MASTODON_ACCESS_TOKEN"
The helm template injects these as MASTODON_INSTANCE_URL and
MASTODON_ACCESS_TOKEN.
Every 2 hours (0 */2 * * *), matching the Twitter and Bluesky jobs.
Blog posts publish infrequently, so sub-hour latency buys nothing.
write:statuses → save → copy the access token.Mastodon Access Token (Blog Crosspost Bot), password = token,
username = instance host (e.g. mastodon.social).infra/ai-agents/bin/bw-to-exports.sh — syncs the token into
apps/blog/exports.sh as MASTODON_ACCESS_TOKEN.source apps/blog/exports.sh
infra/ai-agents/bin/vault-cmd.sh kv put secret/ai-agents/mastodon \
instance_url=https://mastodon.social \
access_token="$MASTODON_ACCESS_TOKEN"
infra/ai-agents/environments/pai-m1.yaml:
cronjobs:
mastodonRss:
enabled: true
schedule: "0 */2 * * *"
An empty mastodon-rss-state PVC means the first cron run posts
every entry currently in the RSS feed. To test with just one post
(e.g. the Ralph post), pre-populate the state file with every GUID
except the one you want posted:
# 1. ArgoCD creates the PVC (after step 6 above would normally apply,
# but you can create just the PVC ahead of enabling the cron).
# 2. Run a throwaway pod with the PVC mounted, populate it:
kubectl -n ai-agents run masto-seed --rm -it --restart=Never \
--image=kpericak/ai-agent-runtime:0.6 \
--overrides='{"spec":{"volumes":[{"name":"cache","persistentVolumeClaim":{"claimName":"mastodon-rss-state"}}],"containers":[{"name":"masto-seed","image":"kpericak/ai-agent-runtime:0.6","stdin":true,"tty":true,"volumeMounts":[{"name":"cache","mountPath":"/cache"}]}]}}' \
-- sh
# Inside the pod:
python3 -c "
import urllib.request, xml.etree.ElementTree as ET
root = ET.fromstring(urllib.request.urlopen('https://kyle.pericak.com/feed.xml').read())
guids = [i.findtext('guid','').strip() for i in root.findall('.//item')]
# Keep every GUID EXCEPT the Ralph post
guids = [g for g in guids if 'secure-my-laptop' not in g]
open('/cache/posted-guids','w').write('\\n'.join(sorted(guids)) + '\\n')
print('seeded', len(guids), 'GUIDs; ralph will be posted on next cron run')
"
Next cron tick sees only the Ralph GUID as new → one post, one image, one link preview.
source apps/blog/exports.sh
STATE_FILE=/tmp/mastodon-rss-state \
RSS_URL=https://kyle.pericak.com/feed.xml \
apps/blog/.venv/bin/python3 infra/ai-agents/cronjobs/scripts/mastodon-rss.py --dry-run
Dry-run skips the POST and the bot flag PATCH, but still parses
the feed, applies UTMs, and shows the exact status text that would
be sent.
write:statuses only).$0/month. Free Mastodon account + existing K8s infra.