Automated Public Science Communication

Building a Bot for Real-Time Official Statistics Visualisation with

Damien Dupré

DCU Business School, Dublin City University

uRos 2026, Paris · 19 November 2026

The Problem

Statistics stay where they are published

National statistical offices release thousands of tables a year. Most are read by people who already know where to look.

Citizens are in their feeds

People find information on social media. Posting there by hand, for every release, does not scale.

Question: can R turn every new official release into a readable chart in the public’s feed, with no one in the loop?

Where It Started

A May 2023 post by pumpsIE_bot on Twitter: a table of the previous day's average diesel and petrol prices by Irish county from pumps.ie, with the cheapest station in each county.

pumpsie_bot, 2023

  • Yesterday’s fuel prices from pumps.ie, by county, posted on Twitter
  • The cheapest station in each county, and the change since the day before
  • The same recipe: get the data, draw it, post it, repeat
  • Twitter’s API changes pushed the next bot to an open platform

Meet csoie_bot

Check

Which CSO tables have a new release?

csodata

Fetch

Download it from the PxStat API

csodata

Plot

Choose and draw a chart automatically

ggplot2

Post

Publish image, text and alt text

bskyr

One R script, open source, run on a schedule by GitHub Actions. No server, no manual step.

Why Bluesky?

An open protocol

Bluesky runs on the AT Protocol. Posting from code is free and documented.

App passwords

The bot logs in with a revocable app password, never the account password.

Already in R

The bskyr package wraps authentication, image upload and alt text in one call.

Architecture

Problem Architecture Challenges Results

Scheduled, Not Hosted

# .github/workflows/cso_bot.yml (excerpt)
on:
  schedule:
    # every 30 min, 10:00 to 18:30 UTC
    - cron: '*/30 10-18 * * *'
concurrency: cso-bluesky-bot     # one run at a time

jobs:
  check-and-post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: r-lib/actions/setup-r@v2
      - uses: r-lib/actions/setup-r-dependencies@v2
        with:                        # cached binaries
          packages: any::bskyr any::csodata any::ggplot2
      - run: Rscript bot_cso_bluesky.R
        env:
          BLUESKY_CSOIE_APP_USER: ${{ secrets.BLUESKY_CSOIE_APP_USER }}
          BLUESKY_CSOIE_APP_PASS: ${{ secrets.BLUESKY_CSOIE_APP_PASS }}
      - if: ${{ !cancelled() }}      # even if the bot failed
        run: |
          git add data/
          git commit -m "Update posted tables" || exit 0
          git pull --rebase && git push
  • A fresh R session on every run: no server to maintain
  • Packages come as cached binaries: a run takes about two minutes
  • The bot commits its own memory: a log of every post and every failure
  • Free for a public repository

What Is New Today?

# tables the CSO updated in the last week
toc <- cso_get_toc(from_date = as.character(Sys.Date() - 7))

# when each table was last posted, from the bot's own log
last_posted <- posted |>
  mutate(last_posted = as.Date(posted_date)) |>
  slice_max(last_posted, n = 1, by = id, with_ties = FALSE) |>
  select(id, last_posted)

# due: released since yesterday and not posted since that release,
# minus releases that have already failed three times
due <- toc |>
  mutate(release_date = as.Date(LastModified)) |>
  filter(release_date >= Sys.Date() - 1) |>
  left_join(last_posted, by = "id") |>
  filter(is.na(last_posted) | release_date > last_posted) |>
  anti_join(given_up, by = c("id", "release_date")) |>
  arrange(desc(LastModified))   # newest first

df <- cso_get_data(due$id[1], pivot_format = "tall")
  • A table is due when the CSO releases it after its last post
  • Newest first, at most 3 per run, 30 s apart, so the feed and the rate limit cope
  • Data comes back tall: one row per value, whatever the table

Challenges

Problem Architecture Challenges Results

Challenge 1: One Rule, Every Table Shape

The table has… The bot draws…
A time dimension and categories Coloured lines, at most 6 groups, extra categories as facets (at most 12)
A time dimension only A single line
Monthly data over several years Year-over-year lines: months on the x axis, the latest year in blue over grey past years
Categories only A horizontal bar chart of the top 12 values
Nothing it can plot Skips the table, logs the reason

Totals (Total, All, Both, State) are dropped from lines and bars. Every dimension not on the chart is held at its total, never averaged.

The same idea, as a package: ggauto by Nicola Rennie chooses a ggplot2 chart type from whether the variables are continuous, categorical or dates.

Challenge 2: Secrets in a Public Repository

The risk

The code is open so others can reuse it. The bot still needs a password to post.

The response

Credentials live in GitHub Secrets, reach R as environment variables, and are an app password that can be revoked in one click.

# log in once per run; save_auth = FALSE keeps the session token off disk
auth <- bs_auth(
  user      = Sys.getenv("BLUESKY_CSOIE_APP_USER"),
  pass      = Sys.getenv("BLUESKY_CSOIE_APP_PASS"),
  save_auth = FALSE
)
bs_post(text, images = chart_file, images_alt = alt_text, auth = auth)

Every post carries alt text, so the charts reach screen-reader users too.

Challenge 3: Failing Quietly, Retrying Later

API down

The CSO catalogue can’t be reached

Exit cleanly

tryCatch() returns NULL; the next run tries again

Odd table

No numeric values, or no shape the rules can plot

Skip and log

The reason goes in skipped_tables.csv; after 3 failures that release is given up

Post rejected

Bluesky refuses the post

Stop and retry

The run stops; the next run tries the same table again

Crash mid-run

R dies after some posts are out

Nothing lost

Each post is logged as it is made, and the log is committed even when the run fails

Results

Problem Architecture Challenges Results

Eight Months Without Intervention

1,484

charts posted

159

days with posts

41

posts on the busiest day

Bar chart of the number of CSO tables posted to Bluesky each week since March 2026.

What I Got Wrong

A memory of IDs

The log only recorded which tables had been posted, so each table was posted once. Eight months of updates never reached the feed.

A memory of releases

A table is due whenever the CSO releases it after its last post.

A schedule taken on trust

The cron asks for a run every 30 minutes. Over the last two months, GitHub started it about twice a day.

Plan for the runs you get

Newest releases first, so the few runs go to today’s figures.

Neither showed in the feed, where every post looked fine. Both were in the records: the bot’s own log and GitHub’s run history.

What Worked

Rules beat perfection

Not every chart is beautiful. A simple, predictable chart for every table is better than a perfect chart for a few.

Metadata is the bottleneck

Good charts need clean time and category columns. Consistent table structure helps machines as much as people.

Reusable by design

Swap csodata for another office’s API and the rest of the pipeline stays the same.

For statistical offices: an automated feed is a cheap, transparent complement to press releases, not a replacement.

Beyond the CSO: data.gov.ie runs on CKAN and harvests the CSO’s tables every night. With ckanr, package_search() sorted by metadata_modified does what cso_get_toc() does here, on any CKAN portal.

Thank You

Questions?