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.
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 thecom.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).
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!
Start the discussion at forum.golioth.io