In my last post, I discussed how Bluesky’s ATProtocol made automation with IFTTT and Zapier nearly impossible. Fortunately, at least in Zapier’s case, “almost” was the operative word. I found a couple of extremely useful JavaScript chunks on the Zapier forums that provided the bedrock for a subsequent solution. And now, viola! When I post to Mastodon, a matching post is made automatically to Bluesky… with some caveats. But let’s start with the fundamentals of this Zap.
Signing up for a new Zapier account is free, but unless you’re willing to pay twenty bucks a month, you’re limited to two-step Zaps. That made the original author’s impelemtation problematic, because I’m a miser and I spend enough on subscriptions as it stands. So if I wanted to achieve the same effect, I would need to combine both those chunks into a single script with an RSS trigger.

After a frustrating false start, I finally grafted both halves together into a unified, functioning script with all the variables appropriately named (“skeet” vs. “tweet”) and a new post length truncator. The truncation code was critical as content over 300 characters will cause the post to fail. Since Mastodon posts can be 500 characters or longer, I put in a conditional that cuts off the main post at 240 characters if needed. The remainder of the space is for a “(More)” note and the Mastodon URL at the end.
This approach does have some problems. Media won’t display across the ATProtocol/ActivityPub divide due to the complexities of posting media in one format, then displaying it in the other. As an example, Bluesky doesn’t support natively posting videos, so trying to show a native Mastodon video on a service without any video support at all is… rough.
Four parameters must be supplied when creating a Zap with this script. These are specified in the “Code by Zapier” JavaScript step. Those parameters are: username, password, skeet_text, and skeet_url.

The “username” and “password” fields are exactly what you’d expect. Just be sure to use an application password from Bluesky rather than your primary password. The next two fields are also fairly self-explanatory. Use the “skeet_text” field for the post’s content, and the “skeet_url” field for the Mastodon post’s web address.
But where can you find an RSS feed of your Mastodon account in the first place? That’s the easy bit. Every Mastodon account has one built into it! For example, if your account is…
https://weirdscience.org/@technomancer
…your RSS feed is…
https://weirdscience.org/@technomancer.rss
…and updates every time you post. Simply point Zapier’s RSS trigger at your feed, and the rest is cake.
Want to make your own crossposter? All the code you need is below!
// Stage 1: Get Authentication Token
const url = 'https://bsky.social/xrpc/com.atproto.server.createSession';
const data = {
identifier: inputData['username'], // Input from Zapier, assuming 'username' corresponds to 'BLUESKY_HANDLE'
password: inputData['password'] // Input from Zapier, corresponding to 'BLUESKY_APP_PASSWORD'
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(data), // Sending the data as a JSON string
headers: {
'Content-Type': 'application/json' // Use JSON content type
},
redirect: 'manual'
});
// Check if the response status is OK
if (!response.ok) {
console.error('Failed request', await response.text());
return null;
}
const responseBody = await response.json(); // Parse the JSON response
if (!responseBody.accessJwt) {
console.error('accessJwt not found in the response');
return null;
}
// Stage 2: Post to Bsky
// Truncate the post if it's too long.
var RawSkeetText = inputData['skeet_text'];
if (RawSkeetText.length > 240) {
var TruncatedSkeet = RawSkeetText.substring(0, 240) + '... (More)';
} else {
var TruncatedSkeet = RawSkeetText;
}
// Define the requisite constants.
const secondUrl = 'https://bsky.social/xrpc/com.atproto.repo.createRecord';
const authToken = responseBody.accessJwt;
const skeetText = TruncatedSkeet + "\r\n" + inputData['skeet_url'];
const skeetURL = inputData['skeet_url'];
// Finding the start and end byte positions of the skeetURL within skeetText
const byteStart = Buffer.from(skeetText.slice(0, skeetText.indexOf(skeetURL))).length;
const byteEnd = byteStart + Buffer.from(skeetURL).length;
const recordData = {
"$type": "app.bsky.feed.post",
"text": skeetText,
"createdAt": new Date().toISOString(),
"facets": [
{
"index": {
"byteStart": byteStart,
"byteEnd": byteEnd
},
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": skeetURL
}
]
}
]
};
const postData = {
repo: inputData['username'],
collection: 'app.bsky.feed.post',
record: recordData
};
const postResponse = await fetch(secondUrl, {
method: 'POST',
body: JSON.stringify(postData),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
}
});
if (!postResponse.ok) {
console.error('Failed request', await postResponse.text());
output = {
success: false,
message: "Failed to create record"
};
return;
}
const postResponseBody = await postResponse.json();
if (!postResponseBody.success) {
console.error('Failed to create record', postResponseBody.message);
output = {
success: false,
message: postResponseBody.message
};
return;
}
output = [{
success: true
}];

