I wanted to know how to use the Bluesky APIs and PowerShell for new posts (or Skeets). Bluesky has published a sample to post via code, also they have very good documentation about their API.
In the meantime, I’ve been using the API with my updated AI Blog Assistant since February.
As an example, the post was published via the API. It should be noted that Bluesky (for now) does not signal when a post is published via the API (or by a bot).
Learn how to use the Bluesky APIs with PowerShell for posting, including how to post in plain-text, website cards, mentions, and links. #Bluesky #PowerShell #Coding
— Tobias Asböck (@tasboeck.bsky.social) April 25, 2025 at 1:12 PM
[image or embed]
In my following post, I summarize information about the API and posting with PowerShell.
Keep in mind that a Bluesky post can only be 300 characters long. If you exceed this limit, the API returns an error.
Content
1) Requirements
You need a Bluesky account and an API key.
Create the key in your Bluesky account under Settings > Privacy and Security > App Passwords. If you create a new app password, the value will only be shown once.
2) Authentication
Send an authentication request via createSession API to receive a JWT token. This token is valid for two hours. You can extend it later or obtain a new token.
# Receive an authentication token and build an authentication header for Bluesky
$BSkyAuthKey = "<ReceiveYourBSkyAppPasswordFromASecurePlace>"
$BSkyAccount = "tasboeck.bsky.social" # Replace it with your Bluesky account name
$Body = @"
{
"identifier": "$BSkyAccount",
"password": "$BSkyAuthKey"
}
"@
# Get the authentication token
$BSkyAuthResponse = Invoke-RestMethod -Uri "https://bsky.social/xrpc/com.atproto.server.createSession" -Method Post -Body $Body -ContentType "application/json"
# Build the authentication header
$AuthHeader = @{
"Authorization" = "Bearer $($BSkyAuthResponse.accessJwt)"
"Content-Type" = "application/json"
}
3) Posting in plain-text
Use the createRecord API to create new posts.
Three parameters are required when posting text.
- repo, name of your Bluesky account
- collection, predefined > app.bsky.feed.post
- Record, should include your text.
Optional parameters:
- createdAt, to specify when it was posted. Bluesky does not restrict posting in the past, but they will add a note in the post about when Bluesky first saw the post.
- langs, informs Bluesky in which language(s) the post is published. This parameter is not for multilingual posts.
- and more, depending on your post.
# Prepare the body with the post content for a simple plain text post
$PostBody = @{
repo = "$BSkyAccount"
collection = "app.bsky.feed.post"
record = @{
text = "Hello world, this is just a demo post"
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
}
} | ConvertTo-Json -Depth 10
# Send the POST request to Bluesky
$APIUrl = "https://bsky.social/xrpc/com.atproto.repo.createRecord"
$BSkyResponse = Invoke-RestMethod -Uri $APIUrl -Method Post -Headers $AuthHeader -Body $TextBody
The API returns the post ID. Using the ID and the Bluesky account, you can construct the post URL.
# Build the post URL with the response
$BSkyPostUrl = $BSkyResponse.uri
$BSkyPostUrl = $BSkyPostUrl.Substring($BSkyPostUrl.LastIndexOf("/") + 1)
$BSkyPostUrl = "https://bsky.app/profile/$BSkyAccount/post/$BSkyPostUrl"
$BSkyPostUrl
If you post text on Bluesky, the API will not convert a URL into clickable text. Readers cannot click the URL. Hashtags are added as text and must be defined separately in the PostBody. See my following example for website cards.
4) Posting a website card
I published the posts as a website card (Website card embeds) in my examples.
If you use the #Microsoft365 Roadmap API in your automation, you must update the URL by 15 March. Fetch updates easily with #PowerShell.
— Tobias Asböck (@tasboeck.bsky.social) February 17, 2025 at 3:04 PM
[image or embed]
Some parameters must be predefined in the PostBody so the card is in the format. I will guide you through the necessary steps.
- Text for the post
- Hashtags for the text (optional, if you use any)
- Image for the card
- Title, URL, and description for the card
Text
There is no difference in the text compared to the plain-text example.
$BkyPostText = "Hello world, this is just a demo post with #PowerShell and #Hashtags"
$PostBody = @{
repo = $BSkyAccount
collection = "app.bsky.feed.post"
record = @{
text = $BkyPostText
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
}
} | ConvertTo-Json -Depth 10
Hashtags
For hashtags, the API needs information about the start and end positions of the hashtag characters. Bluesky describes this method as rich text facets.
I created a code block to build and prepare the text as facets. This way, it doesn’t matter how many hashtags are included or at which position. This block can be extended with other facets, like mention or link. The code block extracts them and builds the necessary facet.
$BkyPostText = "Hello world, this is just a demo post with #PowerShell and #Hashtags"
# Create an array to hold the facets
$BSkyHashtags = @()
# Regular expression to match hashtags
$regex = '#\w+'
# Find all hashtags in the string
$HashtagMatches = [regex]::Matches($BkyPostText, $regex)
# Loop through hashtag matches to create facets with start and end indices
foreach ($Match in $HashtagMatches) {
$byteStart = $BkyPostText.IndexOf($Match.Value)
$byteEnd = $byteStart + $Match.Length
$BSkyHashtags += [PSCustomObject]@{
index = @{
byteStart = $byteStart
byteEnd = $byteEnd
}
features = @(
[PSCustomObject]@{
'$type' = 'app.bsky.richtext.facet#tag'
tag = $Match.Value.TrimStart('#') # Remove the '#' symbol from the tag
}
)
}
}
It captured the start + end positions and the term.

I add the facets to the PostBody. This is required for the API to know which hashtags are at which positions.
$PostBody = @{
repo = "tasboeck.bsky.social"
collection = "app.bsky.feed.post"
record = @{
text = $BlogPostItem.SocialMediaText
createdAt = (Get-Date).ToString("o") # $PostPublishDate
langs = @("en-US")
facets = $BSkyHashtags
}
} | ConvertTo-Json -Depth 10
Image
Bluesky wants the ID of a blob file for the image.
You must upload the image to Bluesky via the uploadBlob API. The API returns the blob ID. Bluesky uses this method to cache the image – see the documentation.
Each post contains up to four images, and each image can have its own alt text and aspect ratio. Individual images are limited to 1,000,000 bytes in size. Image files are referenced by posts, but are not actually included in the post (eg, using bytes with base64 encoding). The image files are first uploaded as “blobs” using com.atproto.repo.uploadBlob, which returns a blob metadata object, which is then embedded in the post record itself.
# Uploading an image to the Bluesky blob storage
$ImagePath = "PathToYourImage.png"
$APIUrl = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob"
$ImageBytes = [System.IO.File]::ReadAllBytes($ImagePath)
$BSkyImageResponse = Invoke-RestMethod -Uri $APIUrl -Method Post -Headers $AuthHeader -Body $ImageBytes -ContentType "application/octet-stream"
Bluesky returns the Blob ID. You must use the ID in a post; otherwise, it will expire, and you must re-upload the image.

I extend the PostBody with the blob ID.
$PostBody = @{
repo = "tasboeck.bsky.social"
collection = "app.bsky.feed.post"
record = @{
text = $BlogPostItem.SocialMediaText
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
facets = $BSkyHashtags
embed = @{
'$type' = "app.bsky.embed.external"
external = @{
thumb = @{
'$type' = "blob"
ref = @{
'$link' = $BSkyImageResponse.blob.ref.'$link'
}
mimeType = "image/png"
size = $BSkyImageResponse.blob.size
}
}
}
}
} | ConvertTo-Json -Depth 10
Title, URL, and description
Finally, I am adding a title, a link to where the card should point, and a description.
$PostBody = @{
repo = "tasboeck.bsky.social"
collection = "app.bsky.feed.post"
record = @{
text = $BkyPostText
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
facets = $BSkyHashtags
embed = @{
'$type' = "app.bsky.embed.external"
external = @{
uri = "https://blog-en.topedia.com"
title = "Topedia Blog"
description = "This is a demo"
thumb = @{
'$type' = "blob"
ref = @{
'$link' = $BSkyImageResponse.blob.ref.'$link'
}
mimeType = "image/png"
size = $BSkyImageResponse.blob.size
}
}
}
}
} | ConvertTo-Json -Depth 10
The PostBody for the website card is ready and can be sent to the API.
$BSkyResponse = Invoke-RestMethod -Uri "https://bsky.social/xrpc/com.atproto.repo.createRecord" -Method Post -Headers $AuthHeader -Body $PostBody
# Get the post URL
$BSkyPostUrl = $BSkyResponse.uri
$BSkyPostUrl = $BSkyPostUrl.Substring($BSkyPostUrl.LastIndexOf("/") + 1)
$BSkyPostUrl = "https://bsky.app/profile/$BSkyAccount/post/$BSkyPostUrl"
$BSkyPostUrl
My post was published as a website card.

5) Posting with @mention
You can add an @mention to your post. Mentions are also handled as facets. With the API, you specify the position where the mention should be added.
You need the recipient’s account name and Bluesky ID (DID) to be mentioned.
You can retrieve the DID of a Bluesky account by using the resolveHandle API. In my example, I am querying my account and receiving the DID did:plc:3u3hwwaccglh3duhw6wg2tkn.
# Get the DID for a Bluesky account
$BSkyRecipient = "tasboeck.bsky.social"
$BSkyRecipientHandle = "@$BSkyRecipient"
$BSkyRecipientID = Invoke-RestMethod -Uri "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=$BSkyRecipient" -Method Get -Headers $AuthHeader
$BSkyRecipientID = $BSkyRecipientID.did
Now, you can build the mention facet. You specify when the @mention character starts and ends (similar to Hashtags). I combined my example with hashtags.
$BkyPostText = "Hey $BSkyRecipientHandle, this is just a demo post with #PowerShell and #Hashtags"
# Find the position of the mention in the text
$byteStart = $BkyPostText.IndexOf($BSkyRecipientHandle)
$byteEnd = $byteStart + $BSkyRecipientHandle.Length
# Create an array to hold the facets (each containing the type and index range)
$BSkyFacets = @()
# Create the mention facet
$BSkyFacets += [PSCustomObject]@{
index = @{
byteStart = $byteStart
byteEnd = $byteEnd
}
features = @(
[PSCustomObject]@{
'$type' = 'app.bsky.richtext.facet#mention'
did = $BSkyRecipientID
}
)
}
# Regular expression to match hashtags
$regex = '#\w+'
# Find all hashtags in the string
$HashtagMatches = [regex]::Matches($BkyPostText, $regex)
# Loop through matches to add hashtag facets with start and end indices
foreach ($Match in $HashtagMatches) {
$byteStart = $BkyPostText.IndexOf($Match.Value)
$byteEnd = $byteStart + $Match.Length
$BSkyFacets += [PSCustomObject]@{
index = @{
byteStart = $byteStart
byteEnd = $byteEnd
}
features = @(
[PSCustomObject]@{
'$type' = 'app.bsky.richtext.facet#tag'
tag = $Match.Value.TrimStart('#') # Remove the '#' symbol from the tag
}
)
}
}
Combined with Hashtags, it’s the following block. Check the different facet types.

I add it to the PostBody (without the website card) and post it.
# Posting a Bluesky post with @mention and hashtags
$PostBody = @{
repo = $BSkyAccount
collection = "app.bsky.feed.post"
record = @{
text = $BkyPostText
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
facets = $BSkyFacets
}
} | ConvertTo-Json -Depth 10
$BSkyResponse = Invoke-RestMethod -Uri "https://bsky.social/xrpc/com.atproto.repo.createRecord" -Method Post -Headers $AuthHeader -Body $PostBody
$BSkyPostUrl = $BSkyResponse.uri
$BSkyPostUrl = $BSkyPostUrl.Substring($BSkyPostUrl.LastIndexOf("/") + 1)
$BSkyPostUrl = "https://bsky.app/profile/tasboeck.bsky.social/post/$BSkyPostUrl"
Write-Host "BSky Post URL: $BSkyPostUrl"
The result is as expected.
Mention correctly placed and linked, including proper hashtags.

6) Posting a link
As the third example mentions, Bluesky does not convert a URL into clickable text if you post via the API. To make it a clickable link, you must create a facet again (similar to hashtags and @mention). Link is the third rich text facet type described by Bluesky.
You specify a link’s start and end position (as with all facets), the URL, and the text. The text can be identical to the URL.
I want to use a format like this, which is similar to HTML. The same works with the Bluesky API.
<a href="https://blog-en.topedia.com">Topedia Blog</a>
I prepare a link type facet, add a link text, and specify the URL.
# Posting a Bluesky post with a link facet
# Prepare the text and link/url
$linkText = "Topedia Blog"
$linkUrl = "https://blog-en.topedia.com"
$BkyPostText = "This is a demo post for Topedia Blog"
$byteStartLink = $BkyPostText.IndexOf($linkText)
$byteEndLink = $byteStartLink + $linkText.Length
# Create an array to hold the facets (each containing the hashtag, mentions and/or link type facets)
$BSkyFacets = @()
# Link facet
$BSkyFacets += [PSCustomObject]@{
index = @{
byteStart = $byteStartLink
byteEnd = $byteEndLink
}
features = @(
[PSCustomObject]@{
'$type' = 'app.bsky.richtext.facet#link'
uri = $linkUrl
}
)
}
# Prepare the post body
$PostBody = @{
repo = $BSkyAccount
collection = "app.bsky.feed.post"
record = @{
text = $BkyPostText
createdAt = (Get-Date).ToString("o")
langs = @("en-US")
facets = $BSkyFacets
}
} | ConvertTo-Json -Depth 10
$BSkyResponse = Invoke-RestMethod -Uri "https://bsky.social/xrpc/com.atproto.repo.createRecord" -Method Post -Headers $AuthHeader -Body $PostBody
$BSkyPostUrl = $BSkyResponse.uri
$BSkyPostUrl = $BSkyPostUrl.Substring($BSkyPostUrl.LastIndexOf("/") + 1)
$BSkyPostUrl = "https://bsky.app/profile/tasboeck.bsky.social/post/$BSkyPostUrl"
Write-Host "BSky Post URL: $BSkyPostUrl"
The result is as expected. My link was posted with a clickable text.
