The Problem
As I’ve added content to my website, I’ve started posting links to various social media platforms. In the past, I experimented with Mastodon, but didn’t find it to be very useful. I’ve moved over to BlueSky recently and have started making posts about my new content there. However, I find the process of creating posts to be repetitive and somewhat tiring.
My solution to this is to expand on my previous efforts of auto-publishing to Hashnode with a custom Astro integration, and add functionality to create posts on BlueSky. To save time writing the posts, I will have an LLM generate the text of the post for me using the content of the blog post as a starting point. For the model, I’ll be using OpenAI’s GPT-4o.
Once I’ve updated my integration, this is how my website will share content with other sites (I’ve colored the BlueSky connection green to indicate it’s new):
A New Cross-Posting Algorithm
Since I previously had figured out how to cross-post to Hashnode with a custom Astro integration, I figured it would make a good starting point for this new work.
For this specific use-case, this is what the sequence of steps will look like for making the post on BlueSky:
.
The astro:build:done
arrow is the hook from the Astro framework which the integration waits for before running.
Once the update is complete, the integration will perform the following steps:
- Perform the existing Hashnode cross-post steps.
- Load the JSON data from a new API endpoint for BlueSky.
- Get the most recent posts from BlueSky.
- Check if the most recent blog post is newer than the latest BlueSky post.
- If the most recent blog post is newer, use GPT-4o to write a BlueSky post.
- Post the AI-generated content to BlueSky.
- Clean up any remaining API files from the
dist
folder.
Building on the Custom Integration
Following the pattern I developed for Hashnode, I created a new API endpoint called src/pages/api/post-for-bluesky.json.ts
:
// Imports here ...
export async function GET() {
const posts: PostForBlueSky[] = POSTS.map(post => {
const body = post.collection === 'blog' ? post.body : post.data.body;
const id = post.id;
const heroImageSrc = post.data.heroImage.src;
if (!body) throw new Error(`Body missing for post with ID ${id}`);
if (!heroImageSrc) throw new Error(`Hero image missing for post with ID ${id}`);
return {
body,
slug: post.id,
pubDate: post.data.pubDate.toISOString(), // BlueSky has its post createdAt values represented by ISO datetime strings.
title: post.data.title,
description: post.data.description,
thumbSrc: heroImageSrc
};
});
return new Response(
JSON.stringify(posts[0])
);
}
However, in contrast to the Hashnode endpoint, this endpoint does NOT return a list of all my posts. Rather, it returns only the most recent post.
After that, I updated my integration to grab the JSON data from the file system, convert it to an object, and then use that to create the BlueSky post:
// src/integrations/cross-post.ts
// ... Existing code here.
// ... The following TS code is inside of the astro:build:done hook and occurs after the Hashnode code.
const blueSkyJson = getJsonFromApiEndpoint(assets, '/api/post-for-bluesky.json', routes, logger);
if (!blueSkyJson) {
logger.error('Could not retrieve JSON for BlueSky cross-posts');
return;
}
const { fileContent, filePath } = blueSkyJson;
const password = process.env.BLUESKY_PASSWORD || "";
const username = process.env.BLUESKY_USERNAME || "";
const openAiApiKey = process.env.OPENAI_API_KEY || "";
if (!(password && username && openAiApiKey)) {
logger.error('Missing API keys for BlueSky cross-post');
return;
}
const agent = await getAgent(username, password);
const latestPost: PostForBlueSky = JSON.parse(fileContent);
const heroImgSrc = fileURLToPath(new URL(latestPost.thumbSrc.replace(/^\//, ''), dir));
logger.info(`Astro dir is ${dir}`);
logger.info(`Thumb src is ${latestPost.thumbSrc}`);
logger.info(`Hero image is ${heroImgSrc}`);
const isPublished = await makeBlueSkyPost(username, agent, latestPost, openAiApiKey, heroImgSrc);
if (isPublished) {
logger.info(`Post with slug ${latestPost.slug} posted to BlueSky`)
} else {
logger.info(`No post made to BlueSky`);
}
fs.rmSync(filePath);
if (!apiDirectory) {
apiDirectory = path.dirname(filePath);
}
if (apiDirectory) {
fs.rmSync(apiDirectory, { recursive: true });
}
// ...
Astute observers may notice the BlueSky integration isn’t exactly like the previous Hashnode code. That’s because of a few things:
- I upgraded to Astro v5, so there are some differences in the integrations API.
- I created this helper function
getJsonFromApiEndpoint
. - New environment variables
OPENAI_API_KEY
,BLUESKY_USERNAME
, andBLUESKY_PASSWORD
.
For security, these environment variables are stored in a .env
file, excluded from version control using .gitignore.
When running in production, the environment variables are stored in an encrypted form with Cloudflare as part of the
build configuration.
The getJsonFromApiEndpoint
does what the name implies; it gets the JSON string from the API file. However, one
additional clarification I will make is the PostForBlueSky
type looks like this:
// Exported from src/utils/bluesky/index.ts
type PostForBlueSky = {
body: string;
slug: string;
pubDate: string;
title: string;
description: string;
thumbSrc: string;
};
It will become clearer in the next section why this is the shape I chose for the JSON.
Adding BlueSky Support
BlueSky has good documentation about how to create a post in this article titled Creating a post. Essentially, there are three steps:
- Create an agent.
- Login.
- Create the post.
However, when I tried to follow it I got a little confused about what the right way to go about implementing rich text with an external site embed. Fortunately, I was able to find this helpful post titled Using the BlueSky API by Raymond Camden. Raymond gave a clear example of how to include both rich text and an embed in the same post.
One key detail: the login rate limit is lower than for other operations. While testing, I accidentally exceeded the rate limit and had to wait 24 hours for it to reset.
Anyway, building off of Raymond and BlueSky’s articles, this is the code I came up with:
// src/utils/bluesky/index.ts
// ... Imports and type definitions here.
const makeBlueSkyPost = async (
username: string, agent: AtpAgent, latestPost: PostForBlueSky, openaiApiKey: string, heroImageSrc: string
) => {
const response = await agent.getAuthorFeed({
actor: username
});
const feed = response.data.feed;
const postUris = feed.map(item => item.post.uri);
let posts: PostThread[] = [];
for (let i = 0; i < postUris.length; i++) {
const postThreadResponse = await agent.getPostThread({ uri: postUris[i], depth: 0 });
const postThread = postThreadResponse.data.thread.post as PostThread;
posts.push(postThread);
}
posts.sort((a, b) => {
const dateA = (new Date(a.record.createdAt)).valueOf();
const dateB = (new Date(b.record.createdAt)).valueOf();
return dateB - dateA;
});
const canLatestPostBePublished = new Date(latestPost.pubDate) > (new Date(posts[0].record.createdAt));
if (canLatestPostBePublished) {
const postUrl = SITE + "/blog/" + latestPost.slug + "/";
const text = await generateBlueSkyPostTextFromArticle(openaiApiKey, latestPost.body, postUrl);
if (text) {
const rt = new RichText({ text });
await rt.detectFacets(agent);
const file = fs.readFileSync(heroImageSrc);
const imageFileType = await fileTypeFromBuffer(image); // Comes from the file-type package.
const { data } = await agent.uploadBlob(image, { encoding: imageFileType?.mime } );
await agent.post({
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.external',
external: {
uri: postUrl,
title: latestPost.title,
description: latestPost.description,
thumb: data.blob
}
},
});
} else {
throw new Error("Could not generate text for BlueSky post");
}
}
return canLatestPostBePublished;
};
// ... Exports here.
The most useful thing I found in Raymond’s post was the correct way to make the uploadBlob
call. Passing the image
with the encoding argument is very simple and straightforward 🔥.
Generating Posts with GPT-4
The code for getting a response from OpenAI is pretty simple. OpenAI has a nice TypeScript SDK which makes making the requests fairly straightforward. The main reason I picked it is because of my familiarity with their API and SDK. Here’s what the code looks like:
// src/utils/openai.ts
import OpenAI from 'openai';
const getClient = (apiKey: string) => {
return new OpenAI({
apiKey
});
};
const generateBlueSkyPostTextFromArticle = async (apiKey: string, postContent: string, url: string) => {
const client = getClient(apiKey);
const chatCompletion = await client.chat.completions.create({
messages: [
{
role: 'developer',
content: `
You are an expert social media manager. Your task is to write a short, high-engagement BlueSky post in first-person based on the provided blog content.
Here are the requirements:
1. The post must be a single sentence that teases the blog content.
2. The link to the full article is: ${url}.
3. Include that link (and any hashtags, if desired) at the end of the same sentence.
4. Limit the entire post to a maximum of 300 characters.
5. Do not clip words or sentences; it should read naturally as one complete sentence.
6. Write in the first-person perspective.
`
},
{
role: 'user',
content: postContent
}
],
model: 'gpt-4o'
});
return chatCompletion.choices[0].message.content;
};
export {
generateBlueSkyPostTextFromArticle
}
Testing Approach
As far as testing goes, I went with a somewhat lazy approach. Since my BlueSky account is still growing, I felt comfortable testing posts directly. The approach allowed me to finish the integration quicker, but also had the downside of creating new posts with broken links on my account. Thankfully, BlueSky allows users to delete posts.
If I had an account with many followers, I would probably have opted for a test account where I could make mistakes without the fear of losing followers. Additionally, I also decided to not write any unit tests. So far, I’ve treated unit tests as technical debt for my website and have ticket in my backlog for writing some.
In summary, my testing flow was just:
- Check the API endpoint with
npm run dev
. - Create a post with
npm run build
.
Results and Reflections
If I’ve done everything correctly, this article should be the first one cross-posted to BlueSky 😃.
The integration process went smoother than expected, thanks to BlueSky’s API and Raymond Camden’s example. Reusing my existing Hashnode integration sped things up, and GPT-4o handled generating concise, engaging post text with ease.
What Worked Well:
- BlueSky’s API – Simple and well-documented.
- Astro Integration Reuse – Saved significant development time.
- GPT-4o Output – The AI-generated posts felt natural and effective.
Challenges:
- Testing – Without a test BlueSky account, I had to post live, resulting in some trial-and-error cleanup.
- Rate Limits – Managing rate limits required careful handling to avoid duplicate posts.
Despite the minor bumps, automating this process was worth it. Posting is now one less thing I have to think about.