Posting to Bluesky from a Microcontroller

Bluesky has seen a large increase in its number of users over the last few weeks. While Bluesky appears to be more or less a Twitter clone at first interaction, it is built on top of the AT Protocol, which is an extensible foundation for social applications. This means that the Bluesky lexicon (app.bsky.*), which defines the core resources and operations necessary for a “microblogging” platform, is just one of potentially many lexicons that could serve different use-cases.

Golioth, along with many members of the team, has been on Bluesky since its earliest days. Given that Golioth is primarily used to get data from constrained devices to destinations on the internet, we are always interested in new platforms that expose functionality via an API, even if the platform may not be a traditional repository of device data. Typically, targeting a new API via Golioth Pipelines is relatively straightforward due to the flexibility of the webhook destination. It turns out that Bluesky is no exception.

Screenshot of a post on Bluesky from a microcontroller.

Let’s learn a bit about how Bluesky and the AT Protocol work on our way to creating posts from a microcontroller!

Creating an App Password & Session

The AT Protocol uses OAuth for authentication. In order to access authenticated resources, a user must exchange their credentials for a short-lived access token, which may be refreshed. This is accomplished via the com.atproto.server.createSession method.

Note that createSession is part of the com.atproto.* lexicon, meaning that the authentication model is decoupled from Bluesky specifically.

When using third-party clients that are acting on behalf of a user, it is encouraged to use app passwords, which can easily be revoked, rather than the primary password. App passwords can be created on Bluesky under profile settings (https://bsky.app/settings/app-passwords).

Screenshot of App Passwords page on Bluesky.

Though ideally we could use longer-lived credentials (more on this later), for this example we can obtain a short-lived access token to perform operations on behalf of my @danielmangum.com account.

$ curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession -d '{"identifier": "danielmangum.com", "password": "$APP_PASSWORD"}' -H 'Content-Type: application/json'

Building a Pipeline

The com.atproto.repo.createRecord XRPC method, which once again is part of the AT Protocol rather than Bluesky specifically, is used to create a record in a user’s data repository. The $type of a record maps to a lexicon schema. In this case, we will be creating app.bsky.feed.post records.

To minimize the amount of data sent from our devices, and to remove the need for firmware to be aware that data is being delivered to a specific destination, we can do much of the request body formation via transformer steps in a pipeline. Specifically, we’ll use Concise Binary Object Representation (CBOR), a more compact format than JSON, to encode the message on the device, and we’ll only require that the device send text and createdAt fields. In the pipeline, we’ll convert the CBOR object to JSON using the cbor-to-json transformer, then transform the result using the json-patch transformer to adhere to the structure required for the createRecord method.

The payload sent by the device will look as follows (shown as JSON for readability).

{
    "text": "This was posted from a microcontroller.",
    "createdAt": "2024-11-20T04:20:00.000Z"
}

After passing through the transformation steps, the final request payload will match that expected for record creation.

{
    "repo": "danielmangum.com",
    "collection": "app.bsky.feed.post",
    "record": {
        "$type": "app.bsky.feed.post",
        "text": "This was posted from a microcontroller.",
        "createdAt": "2024-11-20T04:20:00.000Z"
    }
}

To authenticate, we’ll need to take the accessJwt from the response to the createSession request in the previous section and store it as a secret so that it can be used to authenticate a createRecord request using the webhook destination. The $BSKY_BEARER_TOKEN secret should be of the format Bearer $accessJwt.

The final pipeline enables any device in our project to post to the @danielmangum.com Bluesky account by sending CBOR data to the /post path.

Use this pipeline in your project.

filter:
  path: "/post"
  content_type: application/cbor
steps:
  - name: convert-json
    transformer:
      type: cbor-to-json
  - name: format-send-bsky
    transformer:
      type: json-patch
      version: v1
      parameters:
        patch: |
          [
            {
              "op": "add",
              "path": "/repo",
              "value": "danielmangum.com"
            },
            {
              "op": "add",
              "path": "/collection",
              "value": "app.bsky.feed.post"
            },
            {
              "op": "add",
              "path": "/record",
              "value": {"$type": "app.bsky.feed.post"}
            },
            {
              "op": "move",
              "from": "/text",
              "path": "/record/text"
            },
            {
              "op": "move",
              "from": "/createdAt",
              "path": "/record/createdAt"
            },
            {
              "op": "remove",
              "path": "/text"
            },
            {
              "op": "remove",
              "path": "/createdAt"
            }            
          ]
    destination:
      type: webhook
      version: v1
      parameters:
        url: https://bsky.social/xrpc/com.atproto.repo.createRecord
        headers:
          Authorization: $BSKY_BEARER_TOKEN

Writing the Firmware

Because the pipeline is performing most of the operations required to deliver a message to Bluesky, writing the firmware is as simple as adapting one of the stream examples, such as the Zephyr and esp-idf sample applications, from the Golioth Firmware SDK. You may want to trigger posting on press of a physical button, or perhaps in response to values from a sensor reaching a defined threshold.

After encoding the desired payload using a library such as zcbor, streaming it to Golioth for processing requires only a single function call.

int err = golioth_stream_set_sync(client,
                                      "post",
                                      GOLIOTH_CONTENT_TYPE_CBOR,
                                      buf,
                                      payload_size,
                                      SYNC_TIMEOUT_S);

What’s Next?

There are two sharp edges we encountered with this pipeline. The most obvious was the use of short-lived credentials. While this pipeline would work great for lifetime of the accessJwt, it does not provide facilities for refreshing the credential or creating a new session. While the AT Protocol may have support for API keys or similar functionality in the future, interacting with OAuth systems is not unique to this use case and would likely be a useful improvement to the webhook destination.

The second sharp edge was less apparent. While a device may want to be able to set the createdAt field itself, it is likely that the common case is to use the current time. All messages that flow through Pipelines have an associated timestamp indicating when they arrived at the Golioth platform. This metadata can be injected into a JSON object using the inject-metadata transformer. However, there is not currently native support for transforming the timestamp into the expected datetime format of the app.bsky.feed.post schema. While custom functionality can always be introduced via the webhook transformer (note difference from webhook destination), ideally it is not required.

One of the benefits of developing more and more Pipeline examples is discovering new ways that we can expand functionality to accommodate more use cases and improve developer experience. If you have any suggestions based on your own experience using Pipelines, feel free to let us know on the forum!

Talk with an Expert

Implementing an IoT project takes a team of people, and we want to help out as part of your team. If you want to troubleshoot a current problem or talk through a new project idea, we're here for you.

Start the discussion at forum.golioth.io