Skip to content

Writing arbitrary records to a Bluesky PDS in Python

Use atproto to write a record because you can

Cyrus Æ
Mar 24, 20264 min read

Mirrored from lawn.dawnfire.casa due to server maintenance/for permanence, will be updated with permalink.

---
title: "Writing arbitrary records to a Bluesky PDS in Python"
subtitle: "Short tutorial that does what it sounds like"
date: "2026-03-23"
categories: [atproto, python, tutorial]
description: "Use atproto to write a record because you can."
---

Today I am: testing writing arbitrary records to my ATProto PDS (the repo that represents all your user data on services like Bluesky). Mine's currently hosted by Bluesky but I'm considering self-hosting now that I've gotten nerdsniped by the fact that it's possible. In the meantime, I wanted to make sure I could write to Bluesky if I wanted to use that data store for arbitrary records.

This is a short how-to post because I couldn't find a straightforward example online and when I tried to generate one with Google Gemini it gave me code that didn't work. The AT Protocol is still under active development, and that means breaking changes relative to documentation and incomplete documentation.

This should be accurate currently to the 0.0.65 version of atproto.

Adding an arbitrary record

Credentials

  1. Go to https://bsky.app/settings/app-passwords (this URL isn't surfaced directly by the Settings interface, you have to find it via search engine)
  2. Name and create a new app password; there will be an option to grant access to DMs or not, other than that there aren't expiry or scope options
  3. Save the password; it follows the usual API token etc. model of not being recoverable after generation

Script

Here's the code:

from atproto import Client, models

# 1. Set up your credentials
# We're hardcoding these in because it's just a test script.
handle = 'HANDLE HERE'
password = 'APP PASSWORD HERE'
pds_url = 'https://bsky.social'

client = Client(pds_url)
client.login(handle, password)

# 2. Define your "arbitrary" data
# Note: Every record MUST have a '$type' property matching the collection
# Timestamp is optional as far as I know
record_content = {
    "$type": "com.example.myCustomType", 
    "hello": "world",
    "timestamp": "2026-03-23T00:00:00Z",
    "secret_data": 42
}

# 3. Write it to your repository
# The whole input needs to be wrapped in that "data" model--see below
response = client.com.atproto.repo.create_record(
    models.ComAtprotoRepoCreateRecord.Data(
        repo=handle,
        collection="com.example.myCustomType",
        record=record_content,
        validate_=False,
    )
)

print(f"Record created! URI: {response.uri}")

secret_data is just there as an example field; there's nothing special enforcing its secrecy as far as I understand it. hello is an equally arbitrary field.^

^[I thought I remembered a post that it would be funny to share here about the degree to which these fields are arbitrary, but it's lost.]

Apparently the models.ComAtprotoRepoCreateRecord.Data part is new enough that a) you can't generate working test code that gets it right, as opposed to having the contents as parameters, because the models import is that new; b) it's, relatedly, not documented as such.

The error making that mistake creates renders as TypeError: ComAtprotoRepoNamespace.create_record() missing 1 required positional argument: 'data', which would seem to imply "you need an argument called data" but actually means "there's only one argument, data, and you need to wrap the contents in 'the shape of data' in order to turn it into that".

Setup notes

If you're someone who is not super comfortable with Python and need the walkthrough (I have a love-hate relationship with Python that leaves me not super comfortable with it, it's me), assuming you're using uv:

  1. Make a project folder to run the script from and cd (or z with zoxide) into it
  2. uv add atproto for the library itself
  3. uv sync to create a virtual environment where the library is available
  4. uv run ./FILENAME.py to run the script

Why do that

In my case, I've nerdsniped myself into wanting to develop on the AT Protocol--specifically, writing records to my PDS when I save an academic paper in the knowledge manager I'm building (insert alcanzai link here, later), in a way that's cross-compatible with existing bookmark/reading managers on ATProto (namely, semble.so and margin.at -- more on those as I start using them properly myself).

I might play around with storing stuff there in general! Maybe it will seduce me away from Quarto, where this blog is intended to live, for more than just backups! I don't know.

In a more general case: Because you can? It's pretty funny. You can just... write random data to the pile of information that constitutes your account. You don't even actually need Python to do it, that was just the legible way to get a script to start off with. It costs basically nothing to try, especially if it still works this way by the time you read this.

That's pretty neat!

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions