Guides Bluesky

Posting to Bluesky from a script

Facets are byte offsets, not character offsets, and createRecord mints its key server-side. Both go wrong at 200 OK — here is the arithmetic, and a checker for posts you already sent.

  • IntermediateLevel
  • 20 minTo set up
  • PythonYou write the code
Posting to Bluesky from a script

Two things go wrong when you post to Bluesky from your own code, and they have the same shape: the server takes the write, returns 200, and the result is wrong. One puts your links on the wrong words. The other posts twice. Here is the arithmetic behind both, and a checker you can point at posts you have already sent.

Updated 5 November 2026

What it uses

No kit, and no POCKETCORP needed.

This one is about someone else’s API: standard-library Python and a Bluesky app password are the whole toolchain.

Python and an app passwordIncludedNothing here needs a POCKETCORP, a framework or a dependency. The code is standard-library `urllib`, and the checker does not even log in.
A Bluesky account you post to from codeIncludedA bot, a cross-poster, a feed bridge — anything that builds a post body itself rather than typing it into the app.
Thirty minutes of someone else's debuggingIncludedFour public issue threads, two of them filed by people who hit exactly this and one closed with “there isn't really anything to fix here”.

Set it up

Step by step.

Step 101

A bug report that was not a bug

In December 2024 a developer called MadhuGowthaman opened the same issue twice, once against Bluesky's docs and once against the protocol repo, under the title *“Incorrect facet responses for posts containing emojis”*. The post was nine characters long — 😀 #abcdef — and the API came back saying the hashtag ran from offset 5 to offset 12. That looked wrong to them, and I understand why. #abcdef plainly starts at position 2 and ends at position 9, and the whole post is only nine characters, so an offset of 12 is past the end of a string that has already finished.

The response was correct. Here is the arithmetic, which you can run yourself:

terminal
$ python3 -c '
t = "\U0001F600 #abcdef"
print("characters    :", len(t), " bytes:", len(t.encode()))
print("tag by chars  :", t.index("#abcdef"), "->", t.index("#abcdef")+len("#abcdef"))
b = t.encode()
s = b.index(b"#abcdef"); e = s + len(b"#abcdef")
print("tag by bytes  :", s, "->", e)
print("bytes[5:12]   :", repr(b[5:12].decode()))
'
characters    : 9  bytes: 12
tag by chars  : 2 -> 9
tag by bytes  : 5 -> 12
bytes[5:12]   : '#abcdef'

The emoji is one character and four bytes. Add the space and the tag starts at byte 5, and the string that looked nine long is twelve. The post renders exactly as intended, and it still does today:

The post “😀 #abcdef” in the Bluesky web client, with #abcdef rendered as a working blue link
The post from the bug report, read this morning. The tag is a link, because byte 5 to byte 12 is where the tag is.

Two years later this is still the first thing to go wrong in a hand-written posting script. Everything below was run on 2026-11-05 against bsky.social, on Python 3.10.12, and the output is copied out of the terminal transcript.

Step 202

Where the byte offsets came from

A Bluesky post is a record with a text string and a list of facets. Facets are how links, mentions and hashtags exist at all: the text itself is flat, and a facet says “the range from here to here is a link to this URI”. The lexicon calls that range a ByteSlice, with a byteStart and a byteEnd, and the documentation is explicit about what those index.

That choice was argued out in the open in March 2023, in atproto issue #621, which is worth reading if you have ever wondered whether anyone thought about this. Bryan Newbold (bnewbold) opened it with a screenshot of mentions highlighted a couple of positions off, because the Typescript that wrote the post counted UTF-16 and the Rust that read it counted UTF-8. Daniel Holmgren (dholms) laid out the three options (UTF-8, UTF-16, graphemes) and landed where the protocol landed, with one reservation stated plainly:

That prediction held, and the next step is the receipt.

Step 303

The wreckage is still on the network

In September 2024 a developer called mfn filed issue #2823: they were consuming the API, their mention highlighting was wrong, and when they looked the post up in the official client it was wrong there too. The post is still there. Here is what its facets actually slice, read live from the public AppView this morning:

terminal — facetcheck.py against the post in atproto#2823
text   : 'Posição: 1º podcast @jamellebouie.net  \nPosição: 2º podcast @kenwhite.bsky.social  ...'
length : 257 characters, 275 bytes
facet 1: bytes[20:37] = 'st @jamellebouie.'
         mention -> did:plc:nvfposmpmhegtyvhbs75s3pw
         CHECK: the slice is not a handle
facet 3: bytes[104:118] = ' podcast @bloo'
         mention -> did:plc:uewxgchsjy4kmtu7dcxa77us
         CHECK: the slice is not a handle
facet 6: bytes[231:257] = '\ufffdão: 6º podcast @hausofd'  BROKEN: starts or ends inside a character
         mention -> did:plc:e72cwu7fen37hzzzhwy6mkxp
         CHECK: the slice is not a handle

verdict: 1 broken range(s), 6 to look at

Every line of that post is Posição: Nº podcast @someone. The ç, the ã and the º are two bytes each and one character each, so every line is three bytes longer than it is characters, and the error accumulates: the first mention is off by three, the sixth by eighteen. By the sixth facet the start offset has drifted into the middle of the ç, which is why the slice begins with a replacement character.

The offsets are not random. They are exactly right, in the wrong unit — index the same text by character instead of byte and all six land perfectly:

terminal — the same offsets, read as character offsets
1 chars[20:37] = '@jamellebouie.net'
2 chars[60:81] = '@kenwhite.bsky.social'
3 chars[104:118] = '@bloomberg.com'
4 chars[141:161] = '@junlper.bsky.social'
5 chars[184:208] = '@chrislhayes.bsky.social'
6 chars[231:257] = '@hausofdecline.bsky.social'
text length in characters: 257 | last byteEnd: 257

Six for six. The final byteEnd is 257, which is the length of the text in characters and eighteen bytes short of its length in bytes. That is the signature of a script that called len(text) and sent the answer to a field named byteEnd.

The same post rendered on bsky.app: the blue mention highlighting starts mid-word and drifts further right on each line, and the last line shows two literal replacement characters
What the reader sees, in Bluesky's own client, today. Line one underlines “st @jamellebouie.”; by line six the client is painting two black diamonds into the middle of the word Posição, because it was handed a byte range that begins inside a character.

Nothing in the chain stopped this. The PDS stored it, the AppView served it, and the official app rendered it, because the protocol treats this as the posting client's job. Newbold, answering the issue: *“it is up to the client developer creating the posts to correctly generate facets (or use an SDK/helper which implements this for them).”* mfn's reply is the part worth keeping: *“Interesting approach to leave this up to the client and leave the possible interpretation of broken data to everyone else.”* A maintainer closed the issue a week later with “there isn't really anything to fix here (unfortunately/fortunately)”.

For contrast, a post with an emoji in it whose facets were produced properly, this one from the protocol's own account. Byte 10, not character 7, and it lands:

terminal — facetcheck.py against @atproto.com
text   : '🔴 LIVE https://stream.place/atproto.com Off Protocol LIVE'
length : 57 characters, 60 bytes
facet 1: bytes[10:42] = 'https://stream.place/atproto.com'
         link -> https://stream.place/atproto.com

verdict: OK
Step 404

Writing the offsets correctly

The fix is four lines, and the only thing to hold onto is that you encode the prefix, not the match.

facets.py — the version that is right
def facets_by_byte(text):
    raw = text.encode("utf-8")
    out = []
    for m in LINK.finditer(text):
        start = len(text[:m.start()].encode("utf-8"))
        end = start + len(m.group(0).encode("utf-8"))
        assert raw[start:end].decode("utf-8") == m.group(0)
        out.append({
            "index": {"byteStart": start, "byteEnd": end},
            "features": [{"$type": "app.bsky.richtext.facet#link", "uri": m.group(0)}],
        })
    return out

len(text[:m.start()].encode("utf-8")) is the whole trick: how many bytes come before the match. The assert is not decoration. It is the thing that would have caught every broken post on this page, and it costs nothing. If the slice you are about to publish does not decode back to the text you meant, do not publish it.

Run against a line with a euro sign, an em dash and a trailing emoji, the two approaches diverge by four bytes:

terminal — the same text, built both ways
$ python3 facets.py
text: Der Preis ist 12 € — Anleitung: https://pocketcorp.agency/guides/ 🎉
      67 characters, 74 bytes

by character:
  BAD bytes[32:65] = 'ng: https://pocketcorp.agency/gui'

by byte:
  ok  bytes[36:69] = 'https://pocketcorp.agency/guides/'

The character version publishes a link whose clickable text is ng: https://pocketcorp.agency/gui. Not an error, not a warning. Just a post that looks like someone's script broke, which it did.

Step 505

A checker for posts you have already sent

facetcheck.py is a single standard-library file that takes a post URL, reads the record from the public AppView and prints what each facet actually slices. No login, no dependencies.

It separates two kinds of complaint, and that distinction took three rewrites to get right. BROKEN is arithmetic: the range splits a character, or byteEnd runs past the end of the text. Those cannot be anything but a bug, and they set the exit code. CHECK is a guess: the slice is not the thing the facet points at. That is usually a bug and is sometimes deliberate, because a facet may cover any range you like — the protocol is happy to link the words “Start From Scale: Building an Open Network for the Real World” to a URL that appears nowhere in the text, and @atproto.com does exactly that.

Three rules I wrote confidently had to be loosened after they fired on legitimate posts: clients shorten long URLs to a prefix with an ellipsis, hashtags come back in a different case than they were written, and anchor text is free-form. The calibration, from the transcript: across 133 faceted posts from five accounts belonging to people who build the protocol, the checker reports 0 broken ranges and 1 CHECK — that one being the deliberate anchor text above.

facetcheck.py
#!/usr/bin/env python3
"""facetcheck — read any Bluesky post and show what its facets actually slice.

    python3 facetcheck.py https://bsky.app/profile/<handle>/post/<rkey>

BROKEN is unambiguous: the range splits a character, or runs past the end of the
text. CHECK is a heuristic: the slice is not the thing the facet points at, which
is usually a bug and is sometimes deliberate anchor text. Exits non-zero on BROKEN.
"""
import json, re, sys, urllib.parse, urllib.request

API = "https://public.api.bsky.app/xrpc"
HANDLE = re.compile(r"^@[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}quot;)


def resolve(handle):
    if handle.startswith("did:"):
        return handle
    url = f"{API}/com.atproto.identity.resolveHandle?handle={urllib.parse.quote(handle)}"
    with urllib.request.urlopen(url, timeout=20) as r:
        return json.load(r)["did"]


def get_record(did, rkey):
    q = urllib.parse.urlencode(
        {"repo": did, "collection": "app.bsky.feed.post", "rkey": rkey})
    with urllib.request.urlopen(f"{API}/com.atproto.repo.getRecord?{q}", timeout=20) as r:
        return json.load(r)["value"]


def links_to(shown, uri):
    """Clients shorten long URLs, so the slice is a prefix of the target rather than
    all of it. Compare without the scheme and allow a trailing ellipsis."""
    strip = lambda s: re.sub(r"^[a-z][a-z0-9+.-]*://", "", s).rstrip("/")
    return strip(uri).startswith(strip(shown).rstrip(".…"))


def check(post):
    text = post["text"]
    raw = text.encode("utf-8")
    print(f"text   : {text!r}")
    print(f"length : {len(text)} characters, {len(raw)} bytes")
    broken = look = 0
    for n, facet in enumerate(post.get("facets") or [], 1):
        start, end = facet["index"]["byteStart"], facet["index"]["byteEnd"]
        try:
            shown, note = raw[start:end].decode("utf-8"), ""
        except UnicodeDecodeError:
            shown = raw[start:end].decode("utf-8", "replace")
            note = "  BROKEN: starts or ends inside a character"
            broken += 1
        print(f"facet {n}: bytes[{start}:{end}] = {shown!r}{note}")
        if end > len(raw):
            print(f"         BROKEN: byteEnd {end} is past {len(raw)} bytes of text")
            broken += 1
        for feature in facet["features"]:
            kind = feature["$type"].rsplit("#", 1)[-1]
            target = feature.get("uri") or feature.get("did") or feature.get("tag")
            print(f"         {kind} -> {target}")
            if "uri" in feature and not links_to(shown, target):
                print("         CHECK: the slice is not the link text")
                look += 1
            elif "tag" in feature and shown.lstrip("#").lower() != target.lower():
                print("         CHECK: the slice is not the tag")
                look += 1
            elif "did" in feature and not HANDLE.match(shown):
                print("         CHECK: the slice is not a handle")
                look += 1
    print()
    parts = []
    if broken:
        parts.append(f"{broken} broken range(s)")
    if look:
        parts.append(f"{look} to look at")
    print("verdict: " + (", ".join(parts) if parts else "OK"))
    return broken


if __name__ == "__main__":
    parts = urllib.parse.urlparse(sys.argv[1]).path.strip("/").split("/")
    sys.exit(1 if check(get_record(resolve(parts[1]), parts[3])) else 0)

Point it at your own account's last fifty posts once. The output is boring when everything is fine, which is the point.

Step 606

The second trap: the key is minted by the server

This one costs more than a mislinked word, and it is in the shape of the write call rather than the text. You create a post with com.atproto.repo.createRecord. Its lexicon requires three fields (repo, collection, record) and rkey, the record key, is optional. Omit it and the PDS generates one for you, which is what every posting example does.

That means your write has no client-chosen identity, so the server cannot tell a retry from a new post. If your request succeeds and the response is lost — a timeout, a dropped connection, a 502 from a load balancer that has already passed the write through — then the record exists and your code does not know it. The obvious reflex is to retry. The reflex is wrong.

I ran this against the real PDS, in a scratch collection rather than app.bsky.feed.post, so nothing landed in anybody's feed:

terminal — same body twice, no rkey
attempt 1: 200 uri=at://did:plc:mcx6.../agency.pocketcorp.scratch.note/3mzkcldgbl426
attempt 2: 200 uri=at://did:plc:mcx6.../agency.pocketcorp.scratch.note/3mzkcldtc5b24
distinct records created: 2

Byte-identical bodies, two records, two keys, no complaint. On app.bsky.feed.post that is a double post. There are two ways to make the write safe, and they behave differently enough to be worth knowing apart.

**Supply your own rkey.** The second write then collides with the first instead of creating a sibling. It does prevent the duplicate, but here is what the collision actually returns:

terminal — same body twice, rkey supplied by the client
attempt 1: 200 at://did:plc:mcx6.../agency.pocketcorp.scratch.note/retryprobe001
attempt 2: 500 {'error': 'InternalServerError', 'message': 'Internal Server Error'}

**Guard the write with swapCommit.** This is the mechanism that fits the lost-response problem. A repo is a chain of commits, and swapCommit is a compare-and-swap: the write applies only if the repo's head is still the commit you name. Read the head, send the write with swapCommit set to it, and the first successful write moves the head — so a replay of the identical request cannot apply.

terminal — a lost response, replayed with and without the guard
repo head before the write: bafyreig36p6p5iwpcpsfed4jhfxahlbfdr7eghn3ny2zrymmnt2llefvfi
write lands: 200 uri=at://did:plc:mcx6.../agency.pocketcorp.scratch.note/3mzkclfeq5p2n
repo head is now:           bafyreia5asqoatvrzldbsusqg2hi6gppcqgkvdxitkxlgvspjoihnz7e2u
replay of the same guarded request: 400 InvalidSwap
  message: Commit was at bafyreia5asqoatvrzldbsusqg2hi6gppcqgkvdxitkxlgvspjoihnz7e2u
replay of the same UNguarded request: 200 uri=at://did:plc:mcx6.../3mzkclg4y4d22

That is the whole behaviour in one screen. The guarded replay is refused by name, with the current head in the message; the unguarded replay of the very same body posts again.

It is reasonable to ask why createRecord has no idempotency key, given that every payments API grew one. It came up: a 2023 pull request titled Idempotent creates proposed returning the original URI when a duplicate backlink — a second like of the same post, say — was created. It applied to likes and follows rather than posts, and it was closed in April 2025 without being merged.

Step 707

What I could not verify

  • **Whether the 500 on an rkey collision is intended.** I measured it twice against bsky.social; I did not read the PDS source to find out why, and I have not filed anything. On a self-hosted PDS the behaviour may differ.
  • Whether anything in the pipeline would reject a bad facet range today. My evidence that nothing does is a record from 2024 that is still served and still rendered wrong. I did not try to publish a deliberately broken facet to a real feed, because the only way to test that is to post it.
  • The SDKs. The docs recommend them and I have no reason to doubt that, but I tested the arithmetic, not any library's implementation of it.
  • The scratch records were written to our own account's repo in a custom collection, invisible in any client, and deleted at the end of the run — verified, the collection lists zero records afterwards. Repo commits are public, so a firehose archive may still hold them.
Step 808

What changes when the agent is on the box

One thing worth adding, because it is the part our own setup does differently. Both traps share a property: the write succeeds and the result is wrong. No exception is raised, no status code is unusual, and if your script's definition of success is “the POST returned 200”, it will report success in both cases. The only way to find out is to read the record back from the network afterwards and compare it to what you meant to send — which is exactly what the checker does, and which is a strange thing to run by hand.

Our publishing runs on a server where an agent has a shell, so that check is not a separate chore: after a write the agent fetches the record from the public AppView, re-derives the facets from the text it intended, and compares. It is the same loop you would write as a cron job, with the difference that when the comparison fails the agent can read the transcript, work out which of the two traps it hit, and say so.

Then just ask

Your setup, at work.

Send these to your agent in the chat on your POCKETCORP page, or from Telegram or WhatsApp. Change the details to yours.

Audit what you have already posted
Fetch the last 100 posts from my Bluesky account, run the facetcheck logic over each one, and list every post with a broken byte range or a slice that is not the link, mention or tag it points at. Give me the post URLs.
Add the guard to my posting script
Read my Bluesky posting script. Rewrite the facet builder so byteStart and byteEnd are UTF-8 byte offsets computed from the encoded prefix, and add an assert that the slice decodes back to the matched text before the record is sent.
Make a retry safe
Make my createRecord call idempotent: read the repo head with com.atproto.sync.getLatestCommit first, pass it as swapCommit, and on an InvalidSwap treat it as 'the head moved' — list my recent records and check before retrying rather than assuming the write landed.

Take it further

Make it yours.

The same setup, doing more: ask for these once it runs.

Check every post before it goes out
Before any post is sent from this machine, run the facet check against the body I am about to send, and refuse the send if any range does not decode to the text it claims. Log the refusals so I can see what was caught.
Read the record back after every write
After each Bluesky post, wait ten seconds, fetch the record from the public AppView, and compare its text and facets with what I sent. Tell me about any difference, including a post that exists twice with the same body.
Watch the accounts you syndicate to
Every Monday, run the facet check over the last week of posts from each account I publish to, and message me only if something is broken. Include the exact byte ranges so I can find the bug in the script that wrote it.

The result

Links that land on the words you meant, in any language that is not plain ASCII, and a posting script whose retry cannot quietly post twice — both checked against the record the network actually holds rather than against a 200.

Your agent is ready when you are.

Posting to Bluesky from a script runs on a POCKETCORP: a computer of its own for your AI agent. Base is free for 14 days.