Bluesky (bluesky)
Bluesky is the reference client for the AT Protocol. Rustiz speaks XRPC over
plain HTTP, authenticates with a handle + app password (there is no OAuth
app and no redirect URI), and publishes app.bsky.feed.post records to the
account’s PDS. Video is uploaded to Bluesky’s separate video service and
polled asynchronously.
- Rust source:
crates/rustiz-providers/src/bluesky.rs - Upstream port:
bluesky.provider.ts - Editor: Normal
- Connection mode:
customFields - No developer app, no OAuth, no app review.
Overview
| Property | Value |
|---|---|
| Identifier | bluesky |
| Display name | Bluesky |
| Connection mode | customFields (service URL, identifier, app password) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; replies use app.bsky.feed.post with a reply ref |
| Pending / async publishing | Yes. supports_pending = true; video is started in post_pending() and polled before createRecord |
| Analytics | No |
| Post analytics | No |
| Mentions | Yes. supports_mentions = true; app.bsky.actor.searchActors |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 6 |
Character limit (max_length) | 300 characters, hard-coded |
| Tool tip | “We don’t currently support two-factor authentication. If it’s enabled on Bluesky, you’ll need to disable it.” |
| OAuth scopes | None. info.scopes = ["write:statuses", "profile", "write:media"] is cosmetic; no OAuth flow uses it. |
Server Environment Variables
required_env() returns nothing:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&[]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
BLUESKY_VIDEO_BASE_URL | No | https://video.bsky.app | Overrides the video service origin. Trailing slash trimmed. Read through Config::base_url("BLUESKY_VIDEO", …). |
DISABLE_SSRF_PROTECTION | No | unset | When true, skips the public-HTTPS check on the service URL. Needed for a PDS on a private network or http://localhost. Global switch; use only on a trusted network. |
JWT_SECRET | Yes in practice | empty | Encrypts the stored customFields blob (service, identifier, password) as customInstanceDetails |
There is no BLUESKY_BASE_URL. The PDS origin is a per-channel
customFields.service value, not a server env var. BLUESKY_VIDEO_BASE_URL
only changes the video upload host; it does not change the PDS.
rustiz providers --status reports bluesky as configured even before any
account is connected, because the credentials arrive per channel.
Step-by-Step Developer App Setup
There is no Bluesky developer portal application to create. The “app” is an app password created by the account owner.
1. Prepare the account
- Sign in at https://bsky.app.
- Open Settings → Privacy and security → App Passwords (the direct URL is https://bsky.app/settings/app-passwords).
- Choose Add App Password, name it
Rustiz, and copy the generated password. It looks likexxxx-xxxx-xxxx-xxxx. - Keep the account’s normal password out of Rustiz. An app password can be revoked individually without changing the account password.
Bluesky app passwords are intended exactly for this: third-party clients that need to create records but should not be able to change the account password or email. They still act as the account for posting, deleting and reading.
2. Know the service URL
| Account type | service value |
|---|---|
| Normal Bluesky-hosted account | https://bsky.social |
| Custom-domain handle on bsky.social | https://bsky.social (the handle is the identifier) |
| Self-hosted PDS | The PDS origin, e.g. https://pds.example.com |
| Local PDS for testing | http://localhost:2583 with DISABLE_SSRF_PROTECTION=true |
The handle alone does not identify the PDS: use the PDS origin. service
is trimmed of a trailing slash. It must be https unless SSRF protection is
disabled.
3. Redirect URI
There is none. Bluesky’s password/session flow has no browser redirect, no
redirect_uri, no client id and no client secret. connectChannelTool with
customFields completes synchronously.
4. Scopes
Bluesky app passwords have no scope selection. The provider’s info.scopes is
a Postiz compatibility field only; check_scopes() is never called. The
app password grants whatever the account can do through
com.atproto.server.createSession, except account-administration endpoints
that AT Protocol blocks for app passwords.
5. Sandbox / testing vs production
- There is no Bluesky sandbox and no app review. The same app password works against production immediately.
- Use a secondary Bluesky account, or a self-hosted PDS, for tests. A test post is public on the account’s profile.
- Video uploads require a verified email address on the Bluesky account and consume the account’s daily video quota. A brand-new test account may not be allowed to upload video.
- If the account has 2FA enabled and
createSessionanswersAuthFactorTokenRequired, the Rust port has noauthFactorTokenhandling and reports invalid credentials. The provider’s tool tip says to disable 2FA; the safer modern alternative is to rely on an app password, but the code does not implement the 2FA challenge. - Revoking the app password immediately invalidates the stored session; there is no refresh path (see below).
Connecting a Channel (CLI & MCP)
customFields keys
| Key | Type | Default | Validation regex (exact string from custom_fields()) | Notes |
|---|---|---|---|---|
service | text | https://bsky.social | See the exact regex below | PDS origin. Despite the optional scheme and http in the regex, authenticate() requires a public HTTPS URL unless SSRF protection is off. A bare bsky.social passes the regex but fails the HTTPS check. |
identifier | text | — | /^.+$/ | Handle, DID or email. A single leading @ is stripped by login_identifier(). |
password | password | — | /^.{3,}$/ | App password (xxxx-xxxx-xxxx-xxxx), not the account password. |
Exact service validation string:
/^(https?:\/\/)?((([a-zA-Z0-9\-_]{1,256}\.[a-zA-Z]{2,6})|(([0-9]{1,3}\.){3}[0-9]{1,3}))(:[0-9]{1,5})?)(\/[^\s]*)?$/
CLI
Create a JSON file:
{
"service": "https://bsky.social",
"identifier": "alice.bsky.social",
"password": "abcd-efgh-ijkl-mnop"
}
rustiz channels connect bluesky --fields ./bluesky.json
# or pipe it
printf '%s' '{"service":"https://bsky.social","identifier":"alice.bsky.social","password":"abcd-efgh-ijkl-mnop"}' \
| rustiz channels connect bluesky --fields -
A successful credential connection is immediate:
{ "connected": true, "pending": false, "name": "Alice" }
No browser opens and no callback is served. --open / --timeout do nothing
useful for this provider.
MCP connectChannelTool
{
"provider": "bluesky",
"customFields": {
"service": "https://bsky.social",
"identifier": "alice.bsky.social",
"password": "abcd-efgh-ijkl-mnop"
}
}
integrationSchema reports:
{
"type": "customFields",
"headless": true,
"requiresHuman": false
}
There is no follow-up. Call integrationList for the channel id.
Important: do not omit customFields
If connectChannelTool is called for bluesky without customFields,
connect.rs falls through to the OAuth path. Bluesky::generate_auth_url()
returns the random state string as both url and state (a legacy of the
upstream base class), so the agent receives a “login URL” that is not a URL and
no channel is created. Always send the credential object.
Likewise, an empty object {} is still an object: connect_custom() encodes
it and authenticate() fails with Invalid credentials. Send all three keys.
Provider functions (triggerTool)
None. functions() is empty. integrationSchema.functions is [].
Post Settings & Media Validation
settings_schema()
Bluesky does not override the trait default:
{ "type": "object", "properties": {}, "additionalProperties": true }
There are no provider-specific settings. __type is added by the engine. The
post text and media are the only inputs; there are no reply-gate, language,
label or quote settings.
validate_settings()
Not overridden: every settings object is accepted.
check_validity()
The provider checks exactly two rules, per post (each Vec<ValidityMedia> is
one entry in the thread):
#![allow(unused)]
fn main() {
if posts.iter().any(|post| {
post.iter().any(|media| media.path.contains("mp4")) && post.len() > 1
}) {
return Ok(Some("You can only upload one video per post.".into()));
}
if posts.iter().any(|post| post.len() > 4) {
return Ok(Some("There can be maximum 4 pictures in a post.".into()));
}
}
- One
.mp4may be the only media in a post. - An
.mp4plus any other media is rejected. - More than four media items is rejected, even if some are videos.
- The
contains("mp4")check is case-sensitive;VIDEO.MP4bypasses the rule, whilehas_extension()later treats it as a video. This is a port bug: a.MP4plus an image can reach the upload path and the video branch silently ignores the image. - There is no file-size check, no aspect-ratio check, no alt-length check and no video-duration check.
Media requirements
| Media | Bluesky limit | Rustiz behavior |
|---|---|---|
| Images | Up to 4 per post, 2 MB each (current Bluesky limit; older docs say 1 MB) | Each image is resized down until the encoded file is at most about 976 KiB, then uploaded with com.atproto.repo.uploadBlob. Aspect ratio is preserved and stored in the embed. |
| Video | One MP4 per post, up to 300 MB / 10 minutes (current limits; Rustiz does not enforce either) | Uploaded to the video service, polled every 30 s for up to 18 attempts (~9 minutes). |
| Alt text | Per image | Preserved in app.bsky.embed.images[].alt; optional. |
| Mixed | Not allowed | check_validity() rejects an .mp4 with any other media (except the case-sensitivity hole above). |
| GIF | Displayed as a static image | Rust decodes and re-encodes the first frame. |
Images are uploaded with Content-Type: application/octet-stream, not their
real image MIME type. AT Protocol’s uploadBlob records the request content
type on the blob; a PDS may therefore store the blob as
application/octet-stream and clients may not render it. If images appear as
downloads instead of pictures, this is the first thing to fix.
Facets
The provider reproduces @atproto/api’s RichText.detectFacets:
- Mentions:
(^|\s|\()@handlewhere the handle is a valid domain; resolved withcom.atproto.identity.resolveHandleand stored as aapp.bsky.richtext.facet#mentionwith adid. - Links:
http(s)://…or a bare domain with a valid TLD; trailing punctuation and unmatched)are trimmed; stored asapp.bsky.richtext.facet#link. - Hashtags:
#or full-width#at the start of a word; tag trimmed of punctuation; max 64 UTF-16 code units; stored asapp.bsky.richtext.facet#tag.
Offsets are UTF-8 byte offsets, as AT Protocol requires. Facets are omitted when the list is empty.
No link preview cards. Rustiz never creates an
app.bsky.embed.external card. A URL in the text is a clickable facet, not a
card with title/description/image.
Plugs
run_plug() implements two methods (both are @Plug-style automations, run
after the post is live):
| Method | Fields | Behavior |
|---|---|---|
autoRepostPost | likesAmount | Reads the post thread, compares likeCount >= likesAmount, waits 2 s, then creates an app.bsky.feed.repost record. |
autoPlugPost | likesAmount, post | Same threshold check, then creates a reply to the post whose text is the post field after HTML stripping (Editor::Normal). The reply uses the post as both root and parent. |
Fields arrive as JSON; plug_text() accepts strings, numbers and booleans and
stringifies them. An empty or non-numeric likesAmount becomes 0, so the plug
fires on the next run regardless of likes. run_post_plug() is not overridden
and returns Unsupported.
Implementation Notes & Gotchas
- There is no OAuth.
generate_auth_url()is a legacy stub that returns the state string as the URL. The only working connect path iscustomFields; without it the agent gets a bogus URL (see above). - Login on every publish. The stored
accessJwtis not reused for publishing:get_agent()callscom.atproto.server.createSessionwith the stored handle/app password for every post, comment, mention search and plug. This is simple but burns thecreateSessionrate limit (30 per 5 minutes, 300 per day per account) and means a revoked app password fails at publish time even if the channel was connected successfully. - No refresh.
refresh_token()returns an empty token andexpires_in: 0. The storedrefreshJwtis never used. A 4xx login (other than 429) is classified asrefresh_token, so the channel is marked for reconnect. There is nocom.atproto.server.refreshSessionimplementation. - Two-phase duplicate protection. Bluesky uses a prepare/commit handshake
in
pending_data:post_pending()starts the video job (if any) and stores{jobId, message, media}.check_post_status()polls the video job. When ready,finalize_post()is called.- The first
finalize_post()only setsattempting=true, confirmed=falseand returnsPending. - The next
check_post_status()seesattempting=trueand setsconfirmed=true; it returnsReady. - The second
finalize_post()performs the upload/createRecordand returnsCompleted. If a create attempt is uncertain, the nextcheck_post_status()seesattempting && confirmedand refuses with “Bluesky may have already published this post…”. This is deliberate:createRecordis the one irreversible step and is called withexecute(no retry, no 401 reclassification).
- Preparation failures are disarmed. If login or blob upload fails before
createRecord,prep_failure()clearsattempting/confirmedand incrementsprepFailures; after 4 failures it returns “Could not prepare the post for Bluesky, nothing was published”. This lets the workflow retry without a false duplicate warning. - Video polling.
start_video_upload()callscom.atproto.server.getServiceAuthwithaud=did:web:<PDS host>,lxm=com.atproto.repo.uploadBlob,exp=now+30m, then streams the MP4 toapp.bsky.video.uploadVideo?did=…&name=….getJobStatusis public (no auth). The job must reachblob != null;JOB_STATE_FAILEDis a hard error. The blockingupload_video()waits 18 × 30 s (~9 minutes). - Image resizing is aggressive. The loop shrinks by 10% until the encoded
file is about 976 KiB, not Bluesky’s current 2 MB. A 12 MP photo may be reduced
more than necessary. The re-encode uses
image::guess_format(JPEG is the fallback when the format cannot be guessed). - GIFs become static.
image::load_from_memorydecodes the first frame; the re-encode is a still image. Bluesky displays GIFs as static images anyway, but do not expect animation. - Only
.mp4is recognized as video.has_extension(path, "mp4")is the only video test. A.mov,.webmor.m4vis treated as an image: it is decoded with theimagecrate and uploaded as an image blob, which will fail or produce a non-video embed. Transcode non-MP4 video to MP4 before posting. - No size pre-check.
check_validity()only counts items. A 20 MB image is accepted by validation, resized locally, and then uploaded; a 400 MB video is accepted by validation and rejected by the video service or the engine’s upload limits.MAX_UPLOAD_MBand the reverse proxy limit are the practical caps. - Facet resolution can fail softly. If
resolveHandlefails, the mention facet is still emitted with an emptydid; Bluesky may reject the record or ignore the facet. There is no per-facet error surfaced to the user. - 300 characters is not the whole limit. Bluesky’s lexicon also has a
3,000-byte ceiling per post. The engine counts
chars()on the stripped text, not graphemes and not bytes. This is stricter than Bluesky for emoji sequences (a family emoji counts as severalchars) but does not protect against 300 four-byte characters exceeding 3,000 bytes. - Rate limits. Current Bluesky documented limits: 3,000 requests per 5
minutes per IP;
createSession30 per 5 minutes and 300 per day per account; record writes cost points (create 3, update 2, delete 1) with 5,000 points per hour and 35,000 per day. Video has separate daily quotas. Because Rustiz logs in for every post, a high-volume queue can exhaustcreateSessionbefore it exhausts record writes. max_concurrent_job = 6is the provider’s own concurrency cap. It does not reserve rate-limit budget.- Video requires a verified email and a profile. Bluesky rejects video uploads from accounts without a verified email (and, in some clients, a display name/avatar). Rust surfaces the video job’s failure as “Could not upload video, job failed”.
- Release URL is
https://bsky.app/profile/{did}/post/{rkey}, built from theat://URI returned bycreateRecord. The storedpost_idis the full AT URI, not just the rkey. - Threads.
comment()callsapp.bsky.feed.getPostThread?uri=…&depth=0to get the parentcidand the root ref. For a top-level parent, it uses the parent as both root and parent. For a reply, it readsrecord.reply.root. The reply’s text and media go through the same facet and embed logic. - Mentions.
mention()callsapp.bsky.actor.searchActors?q=…and mapshandletoid,displayNametolabel,avatartoimage. The engine’smention_format()prefixes@. - No analytics. Bluesky has no
analytics()/post_analytics()implementation in this port, even though the public AppView exposes counts. - SSRF check.
is_safe_public_https_url()requireshttps, rejectslocalhost, resolves the host and rejects private/blocked IPs. It runs only duringauthenticate(), not on every publish; a PDS that resolves publicly at connect time and privately later is not rechecked. - Custom PDS caveat.
service_auth()derives the video audience from the PDS host asdid:web:<host>. A self-hosted PDS that is not addressed by adid:webmatching its hostname may fail video service auth; text and image posts still work.
References
- AT Protocol / Bluesky API overview: https://docs.bsky.app/docs/get-started
- App passwords: https://bsky.app/settings/app-passwords
com.atproto.server.createSession: https://docs.bsky.app/docs/api/com-atproto-server-create-sessioncom.atproto.repo.createRecord: https://docs.bsky.app/docs/api/com-atproto-repo-create-recordcom.atproto.repo.uploadBlob: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob- Video service (
app.bsky.video.*): https://docs.bsky.app/docs/api/app-bsky-video-upload-video - Rich text facets: https://github.com/bluesky-social/atproto/tree/main/packages/api/src/rich-text
- Bluesky rate limits: https://docs.bsky.app/docs/advanced-guides/rate-limits
- Postiz Bluesky setup: https://docs.postiz.com/self-host/providers/bluesky
- Postiz Bluesky settings: https://docs.postiz.com/public-api/providers/bluesky