Runs: Every hour

Share every new blog post on Bluesky automatically

When you publish a blog post or newsletter issue, a post with its title and link appears on your Bluesky account within the hour.

Sharing each new post by hand is easy to forget, and Bluesky is open enough that a short script can do it for you. This version watches your blog’s feed and posts the title and a clickable link whenever something new appears.

  1. Make a Bluesky app password

    In Bluesky, open Settings, then Privacy and security, then App passwords, and add one called Blog poster. Copy it; Bluesky only shows it once. An app password can post for you but can't change your account password.

  2. Find your blog's feed

    Most blogs and newsletters have an RSS feed. Try your site's address with /feed, /rss or /feed.xml on the end. You should see a page of XML that lists your posts.

  3. Start a new script and store the password

    Go to script.google.com, click New project and name it Blog to Bluesky.

    Click the gear icon (Project Settings), scroll to Script properties, and add a property named BSKY_APP_PASSWORD with the app password as its value. Keeping it here means it isn't written in the code.

  4. Paste the script

    Back in the editor, replace everything with this and fill in your handle and feed address:

    const HANDLE = 'you.bsky.social';
    const FEED_URL = 'https://example.com/feed';
    
    function postNewItems() {
      const props = PropertiesService.getScriptProperties();
      const seen = JSON.parse(props.getProperty('seen') || '[]');
      const channel = XmlService.parse(UrlFetchApp.fetch(FEED_URL).getContentText())
        .getRootElement().getChild('channel');
      const items = channel.getChildren('item').slice(0, 5).reverse();
    
      // First run: remember existing posts instead of sharing them all.
      if (seen.length === 0) {
        props.setProperty('seen', JSON.stringify(items.map((i) => i.getChildText('link'))));
        return;
      }
      const fresh = items.filter((i) => seen.indexOf(i.getChildText('link')) === -1);
      if (fresh.length === 0) return;
    
      const session = bsky('com.atproto.server.createSession', {
        identifier: HANDLE,
        password: props.getProperty('BSKY_APP_PASSWORD'),
      });
      for (const item of fresh) {
        const title = item.getChildText('title');
        const link = item.getChildText('link');
        const start = bytes(title + '\n\n');
        bsky('com.atproto.repo.createRecord', {
          repo: session.did,
          collection: 'app.bsky.feed.post',
          record: {
            $type: 'app.bsky.feed.post',
            text: title + '\n\n' + link,
            createdAt: new Date().toISOString(),
            facets: [{
              index: { byteStart: start, byteEnd: start + bytes(link) },
              features: [{ $type: 'app.bsky.richtext.facet#link', uri: link }],
            }],
          },
        }, session.accessJwt);
        seen.push(link);
      }
      props.setProperty('seen', JSON.stringify(seen.slice(-50)));
    }
    
    function bsky(method, body, token) {
      const res = UrlFetchApp.fetch('https://bsky.social/xrpc/' + method, {
        method: 'post',
        contentType: 'application/json',
        headers: token ? { Authorization: 'Bearer ' + token } : {},
        payload: JSON.stringify(body),
      });
      return JSON.parse(res.getContentText());
    }
    
    // Bluesky counts link positions in bytes, not characters.
    function bytes(text) {
      return Utilities.newBlob(text).getBytes().length;
    }
    
  5. Run it twice to test

    Click Run once and allow access: the first run only remembers your existing posts. To check posting works, remove the newest post's link from the seen script property in Project Settings and run it again. That post should appear on Bluesky.

  6. Check every hour

    Open Triggers, click Add trigger, choose postNewItems, Time-driven, Hour timer, Every hour, and save.

Things that trip people up

  • Links in Bluesky posts are only clickable when the post includes a link facet with byte positions. The script handles this; hand-written code often forgets.
  • If your account lives on your own server instead of bsky.social, change the address in the bsky function to your server's.
  • Posts are limited to 300 characters, so very long titles may fail to post.