Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifierbluesky
Display nameBluesky
Connection modecustomFields (service URL, identifier, app password)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; replies use app.bsky.feed.post with a reply ref
Pending / async publishingYes. supports_pending = true; video is started in post_pending() and polled before createRecord
AnalyticsNo
Post analyticsNo
MentionsYes. supports_mentions = true; app.bsky.actor.searchActors
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job6
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 scopesNone. 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.

VariableRequiredDefaultPurpose
BLUESKY_VIDEO_BASE_URLNohttps://video.bsky.appOverrides the video service origin. Trailing slash trimmed. Read through Config::base_url("BLUESKY_VIDEO", …).
DISABLE_SSRF_PROTECTIONNounsetWhen 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_SECRETYes in practiceemptyEncrypts 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

  1. Sign in at https://bsky.app.
  2. Open Settings → Privacy and security → App Passwords (the direct URL is https://bsky.app/settings/app-passwords).
  3. Choose Add App Password, name it Rustiz, and copy the generated password. It looks like xxxx-xxxx-xxxx-xxxx.
  4. 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 typeservice value
Normal Bluesky-hosted accounthttps://bsky.social
Custom-domain handle on bsky.socialhttps://bsky.social (the handle is the identifier)
Self-hosted PDSThe PDS origin, e.g. https://pds.example.com
Local PDS for testinghttp://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 createSession answers AuthFactorTokenRequired, the Rust port has no authFactorToken handling 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

KeyTypeDefaultValidation regex (exact string from custom_fields())Notes
servicetexthttps://bsky.socialSee the exact regex belowPDS 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.
identifiertext—/^.+$/Handle, DID or email. A single leading @ is stripped by login_identifier().
passwordpassword—/^.{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 .mp4 may be the only media in a post.
  • An .mp4 plus 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.MP4 bypasses the rule, while has_extension() later treats it as a video. This is a port bug: a .MP4 plus 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

MediaBluesky limitRustiz behavior
ImagesUp 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.
VideoOne 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 textPer imagePreserved in app.bsky.embed.images[].alt; optional.
MixedNot allowedcheck_validity() rejects an .mp4 with any other media (except the case-sensitivity hole above).
GIFDisplayed as a static imageRust 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|\()@handle where the handle is a valid domain; resolved with com.atproto.identity.resolveHandle and stored as a app.bsky.richtext.facet#mention with a did.
  • Links: http(s)://… or a bare domain with a valid TLD; trailing punctuation and unmatched ) are trimmed; stored as app.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 as app.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):

MethodFieldsBehavior
autoRepostPostlikesAmountReads the post thread, compares likeCount >= likesAmount, waits 2 s, then creates an app.bsky.feed.repost record.
autoPlugPostlikesAmount, postSame 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 is customFields; without it the agent gets a bogus URL (see above).
  • Login on every publish. The stored accessJwt is not reused for publishing: get_agent() calls com.atproto.server.createSession with the stored handle/app password for every post, comment, mention search and plug. This is simple but burns the createSession rate 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 and expires_in: 0. The stored refreshJwt is never used. A 4xx login (other than 429) is classified as refresh_token, so the channel is marked for reconnect. There is no com.atproto.server.refreshSession implementation.
  • Two-phase duplicate protection. Bluesky uses a prepare/commit handshake in pending_data:
    1. post_pending() starts the video job (if any) and stores {jobId, message, media}.
    2. check_post_status() polls the video job. When ready, finalize_post() is called.
    3. The first finalize_post() only sets attempting=true, confirmed=false and returns Pending.
    4. The next check_post_status() sees attempting=true and sets confirmed=true; it returns Ready.
    5. The second finalize_post() performs the upload/createRecord and returns Completed. If a create attempt is uncertain, the next check_post_status() sees attempting && confirmed and refuses with “Bluesky may have already published this post…”. This is deliberate: createRecord is the one irreversible step and is called with execute (no retry, no 401 reclassification).
  • Preparation failures are disarmed. If login or blob upload fails before createRecord, prep_failure() clears attempting/confirmed and increments prepFailures; 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() calls com.atproto.server.getServiceAuth with aud=did:web:<PDS host>, lxm=com.atproto.repo.uploadBlob, exp=now+30m, then streams the MP4 to app.bsky.video.uploadVideo?did=…&name=…. getJobStatus is public (no auth). The job must reach blob != null; JOB_STATE_FAILED is a hard error. The blocking upload_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_memory decodes the first frame; the re-encode is a still image. Bluesky displays GIFs as static images anyway, but do not expect animation.
  • Only .mp4 is recognized as video. has_extension(path, "mp4") is the only video test. A .mov, .webm or .m4v is treated as an image: it is decoded with the image crate 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_MB and the reverse proxy limit are the practical caps.
  • Facet resolution can fail softly. If resolveHandle fails, the mention facet is still emitted with an empty did; 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 several chars) 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; createSession 30 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 exhaust createSession before it exhausts record writes.
  • max_concurrent_job = 6 is 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 the at:// URI returned by createRecord. The stored post_id is the full AT URI, not just the rkey.
  • Threads. comment() calls app.bsky.feed.getPostThread?uri=…&depth=0 to get the parent cid and the root ref. For a top-level parent, it uses the parent as both root and parent. For a reply, it reads record.reply.root. The reply’s text and media go through the same facet and embed logic.
  • Mentions. mention() calls app.bsky.actor.searchActors?q=… and maps handle to id, displayName to label, avatar to image. The engine’s mention_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() requires https, rejects localhost, resolves the host and rejects private/blocked IPs. It runs only during authenticate(), 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 as did:web:<host>. A self-hosted PDS that is not addressed by a did:web matching its hostname may fail video service auth; text and image posts still work.

References