Bluesky integration: posting, and the byte-offset hazard¶
For credential creation, test accounts, production accounts and environment-isolated execution, follow the account setup guide first. The direct-constructor examples below also work outside the managed launcher; use the launcher when you need its separation guarantees.
Created: 2026-09-01 · Status: ACTIVE
zeo_core.integrations.social.bluesky posts to Bluesky with typed,
IntegrationResult-returning calls. This tutorial targets the current
package contract (zeocore==0.7.0); credential-free paths are verified by
the documentation gate, while live publishing requires an explicit operator
credential checkpoint. The one thing this tutorial exists to stop you from getting wrong
silently: link/mention positions are UTF-8 byte offsets, not character
offsets.
Requires the bluesky extra:
Step 1 — the first error you will hit, and what it means¶
from zeo_core.integrations.social.bluesky.service import BlueskyIntegration
bsky = BlueskyIntegration()
result = bsky.initialize()
print(result.success, result.error)
Executed output on a machine with no Bluesky credentials configured (stdout; you'll also see logger warnings on stderr about the credentials path — those are noise, not a crash):
This is expected on first run, not a bug. Credentials load from a JSON
file outside the repo — resolved via platformdirs
(~/Library/Application Support/zeocore/bluesky/bluesky_credentials.json
on macOS), written with mode 0600 — so you cannot accidentally commit a
token. Use a Bluesky app password, never your account password:
Settings → App Passwords on bsky.app.
To authenticate and persist credentials for later runs:
from zeo_core.integrations.social.bluesky.auth import BlueskyAuthProvider
from zeo_core.integrations.social.bluesky.credential_paths import default_credentials_path
auth = BlueskyAuthProvider(credentials_file=default_credentials_path())
auth_result = auth.authenticate(identifier="you.bsky.social", app_password="xxxx-xxxx-xxxx-xxxx")
if auth_result.success:
auth.save_credentials()
authenticate() makes a real network call, so it is not run in this
tutorial — the operator runs this step live, once, interactively.
Step 2 — always check .success before touching .content¶
Every call — initialize(), post(), authenticate() — returns an
IntegrationResult: .success (bool), .content (payload, None on
failure), .error (str | None). Never read .content unguarded:
result = bsky.post("hello from zeocore")
if not result.success:
raise SystemExit(f"post failed: {result.error}")
print(result.content)
No post from this tutorial has been sent to Bluesky — posting is left to the operator's own session, live.
Step 3 — the byte-offset hazard (read this even if you skim everything else)¶
post() takes links/mentions as spans of text you want linked,
not offsets you compute yourself:
from zeo_core.integrations.social.bluesky.facets import LinkSpan
bsky.post(
"café ☕ https://example.com done",
links=[LinkSpan(text="https://example.com", uri="https://example.com")],
)
Internally this calls compute_facets(), which locates link.text
inside your post and converts the match to Bluesky's wire format — a
UTF-8 byte offset, because the AT Protocol richtext spec indexes
facets in bytes, not codepoints. Executed directly, no credentials
needed:
from zeo_core.integrations.social.bluesky.facets import compute_facets, LinkSpan
text = "café ☕ https://example.com done"
facets = compute_facets(text, links=[LinkSpan(text="https://example.com", uri="https://example.com")])
print(facets[0]["index"])
Real output:
But the character offset of that same substring is (7, 26) —
three lower, because é is 2 UTF-8 bytes (1 extra) and ☕ is 3 UTF-8
bytes (2 extra) ahead of it in the string. If you hand-built the facet
index yourself using str.find()/len() character math — the natural
first instinct — you would ship byteStart: 7, byteEnd: 26. Bluesky
would decode that against the byte string and highlight three bytes into
the URL: a mangled link, silently, with no error from the API. This
is the single most valuable thing to internalize from this tutorial:
never hand-compute facet offsets — always go through compute_facets()
(or post(), which calls it for you).
An all-ASCII post can't expose this bug — character and byte offsets coincide when every character is one byte. Test with non-ASCII text (accents, emoji, curly quotes) before you trust any facet-building code, your own or otherwise.
Reference¶
BlueskyIntegration.post(
text: str,
links: list[LinkSpan] | None = None,
mentions: list[MentionSpan] | None = None,
) -> IntegrationResult[dict]
LinkSpan(text, uri); MentionSpan(text, did) — note did, not a
handle; resolving a handle to a DID is a separate, external lookup this
integration does not do for you.
See also¶
notion-integration.md— sameIntegrationResultpattern, a config-file-based auth flow instead of a credentials-file one.