Introduction
Is bookmarking with the AT Protocol feasible?
I'm a habitual bookmarker, but I never come back to what I bookmarked. One day, I figured I should publish my bookmarks online if I wasn't going to do anything else with them. Someone's bound to find something they haven't seen before, I thought, as I tabbed back to Hacker News.
But how would I publish my bookmarks? Social bookmarking platforms such as Pinboard and Pinterest were still doing fine, even if they're not as relevant today. Some tech-savvy folks had found another use case for Notion and other note-taking apps as a power-user-friendly bookmark manager. And some went further and made a website they could call their own.
I kept circling back on this idea as I went job searching, until I came across an article on Bluesky and Nostr. I had heard of Bluesky and its AT Protocol in passing, but this article, an overview of the trade-offs ATProto designers and Nostr designers made in creating their respective federation protocols, convinced me to look into Bluesky's work further.
The AT Protocol specification left me with a good impression of what the Bluesky team is building, so much so that I decided I would use ATProto to publish my bookmarks, and maybe get a bookmark manager out of it. I still struggle to explain my rationale for choosing ATProto, but here's what I've settled on for now: I chose to use ATProto because...
- It can syndicate my bookmarks to a global audience, beyond my social network.
- It can syndicate my bookmarks to multiple platforms/apps, even those that have nothing to do with bookmarks. Unaffiliated app developers would be free to remix each other's ATProto apps using their publicized data, in perpetuity.
- It does not (inherently) place a penalty on user experience, even though the protocol is within the realm of distributed systems.
This website is my proof of concept for managing public bookmarks using the AT Protocol. I'm tentatively calling it pinned.red. pinned.red is comprised of:
-
A revision of the
redLexicon.pinned .concept -
A mock implementation of pinned.red API methods, as described
by the
redLexicon.pinned .concept - Interactive widgets/"clients" for making calls to the mock API methods
I'd need to add a few more things, like a server backend, before claiming that I made an app. Nevertheless, this proof of concept does enough to demonstrate how I can publish my bookmarks online using ATProto, and how a social bookmarking platform could possibly work on ATProto.
A Primer on ATProto Records and Lexicon
User data on the AT Protocol, and how it can be remixed
By default, each Bluesky (app) user is given an SQLite database. When a Bluesky user makes a post, that post is written to that user's database, or repository in ATProto terms. What makes ATProto repositories special is that access to the repositories is not hidden behind a central API controlled by Bluesky. App developers don't need to pay Bluesky for access; by design users' repositories can be read and written to by any independent app (although apps cannot write data without user approval).
A user's repository stores records of the user's Bluesky posts, likes, follows, etc. But how would we tell which records are for Bluesky posts and which are for Bluesky likes? Well, let's take a look at an example record:
{
"text": "logging on for my shift at the posting factory",
"$type": "app.bsky.feed.post",
"langs": [
"en"
],
"createdAt": "2023-07-03T18:11:26.814Z"
}
a record with type
"app
(view in PDSls)
This record has been serialized into JSON. It has a
"$type" field; for this record, it's set to
"app. Can we assume
from reading its "$type" field that this record
represents a Bluesky post? Not quite. This record identifies itself as
a Bluesky post, but from the record alone, we can't tell whether it
represents a valid Bluesky post. In order to check the
record's validity, we'll need to refer to its Lexicon definition.
Lexicon is the AT Protocol's schema definition language. Lexicon is based on JSON and is mainly used to define how repository records should be structured. Schema definitions for records (and API methods which we'll discuss later) are contained in Lexicon files.
Let's look at the Lexicon definition for our record. From reading its
"$type" field, we learned that the record should map to
the
app
Lexicon definition. Currently, this Lexicon definition, and the
Lexicon file it's contained in, can be found in the
bluesky‑social/atproto
GitHub project. Below, I've copied over the Lexicon file, but omitted
less pertinent fields:
{
"lexicon": 1,
"id": "app.bsky.feed.post",
"defs": {
"main": {
"type": "record",
"description": "Record containing a Bluesky post.",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The primary post content. May be an empty string, if there are embeds."
},
"langs": {
"type": "array",
"description": "Indicates human language of post primary text content.",
"maxLength": 3,
"items": {
"type": "string",
"format": "language"
}
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
},
...
}
}
},
"replyRef": {
...
},
"entity": {
...
},
"textSlice": {
...
}
}
}
the Lexicon file with the
app
Lexicon definition
(view original in GitHub)
Here's what we're looking for to validate our record:
-
There are 4 Lexicon definitions in this file (
"main","replyRef","entity", and"textSlice"); the one we're interested in is called"main". -
Looking at the
"properties"field, each nested field is itself a schema definition for a property/field in our record. For example, the"createdAt"field within"properties"specifies that the"createdAt"field within our record should be a string that conforms to a "datetime" format standard. -
The
"required"field specifies a subset of nested fields from"properties"that our record must contain.
Well then, is our record valid according to the
app
Lexicon definition? Yes it is, our record contains the required
properties/fields and each of them conforms to their specified type
and constraints.
Now we can tell which repository records are valid Bluesky posts and
which are valid Bluesky likes. Or rather, we can have ATProto apps
identify and validate records for us. For example,
embed.bsky.app looks up a record
by URI, and if it finds a valid, "app" record, it parses the record as a Bluesky post and creates an HTML
embed.
embed.bsky.app, an app that generates code to embed a Bluesky post in a website
So far, I've talked about repositories and records as if they're intrinsic to Bluesky only; I said that each Bluesky user has a repository, and each repository stores records of Bluesky posts, likes, and so forth.
But repositories can store more than just "Bluesky" records. Repositories can store records of drawings, longform articles, and other content outside of microblogging.
PinkSea, a drawing-based message board
WhiteWind, a blogging service
The developers of the PinkSea and WhiteWind apps authored their own Lexicon definitions for records representing artwork or blog posts. Perhaps you're now thinking that "PinkSea" and "WhiteWind" records are only intended to be used by PinkSea and WhiteWind, respectively, since the apps are, for lack of a better term, official. However, any app can become a "PinkSea" or "WhiteWind" app. After all, some of us use alternative Bluesky clients like deck.blue over the official Bluesky app. Just as some apps could recreate the Bluesky experience from its Lexicon definitions, other apps can recreate the PinkSea and WhiteWind experiences from their Lexicon definitions.
Keep in mind, however, that Lexicon definitions allow records to be interoperable with any ATProto app, in every form. While reusing records in alternative clients is one way developers can remix an ATProto app using its related records, developers can be more creative and extend both the original app's feature set and its records' schemas.
Incidentally, some developers of alternative Bluesky clients have
already done just that; they
implemented pinned posts before Bluesky
by "extending" the
app
Lexicon definition with a "pinnedPost" field. Meanwhile,
WhiteWind uses Bluesky post records to
implement comment sections, and sill.social searches for trending links across Bluesky as part
of
its news aggregator service.
Hopefully this primer covered enough for you to make sense of ATProto records and Lexicon. I went over how each Bluesky user (and anyone with an ATProto account) has a repository that stores user data in the form of records, how Lexicon allows apps to validate users' records, and how by publishing Lexicon definitions for the records their apps interact with, developers enable interoperability between each other's apps and user records. New kinds of apps are being introduced to the growing ATProto ecosystem thanks to the interoperability granted by Lexicon definitions.
The
red .pinned .concept
Lexicon
Primitives for managing bookmarks
My proof of concept for bookmark management, pinned.red, has its own Lexicon definitions:
-
red.pinned .concept .bookmark -
red.pinned .concept .collection -
red.pinned .concept .collectionmember -
red.pinned .concept .rootcollection -
red.pinned .concept .createBookmark -
red.pinned .concept .createCollection -
red.pinned .concept .deleteCollectionMemberByIndex
Lexicon definitions describe records and API methods; I'll group the pinned.red Lexicon definitions into those two categories:
- Describes pinned.red records
- Describes (mock) pinned.red API methods
In this section, I'll mainly go over the Lexicon definitions for pinned.red records. I'll revisit the Lexicon definitions for pinned.red API methods in upcoming sections.
According to pinned.red, a bookmark looks like this:
{
"uri": "https://en.wikipedia.org/wiki/Bookmark_(digital)",
"$type": "red.pinned.concept.bookmark",
"title": "Bookmark (digital) - Wikipedia",
"createdAt": "1970-01-01T00:00:00Z"
}
a record with type
"red
The Lexicon definition for that "bookmark" record, and other "red" records, looks like this:
{
"type": "record",
"description": "Record declaring a web bookmark.",
"key": "tid",
"record": {
"type": "object",
"required": ["uri", "createdAt"],
"properties": {
"uri": {
"type": "string",
"description": "URI that the bookmark refers to.",
"format": "uri"
},
"title": {
"type": "string",
"description": "Title of the bookmark."
},
"createdAt": {
"type": "string",
"description": "Creation timestamp.",
"format": "datetime"
}
}
}
}
the
red
Lexicon definition
(view Lexicon file)
A pinned.red bookmark has a URI, a creation timestamp, and, optionally, a title. Other bookmarks by web browsers have those three properties and more, but for pinned.red's bookmark schema I preferred to keep it simple. It would be nice to have comments or tags in the future...
Each pinned.red bookmark is a member of a
pinned.red collection. pinned.red collections are
bookmark folders, basically. A "collection" record looks like this:
{
"head": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/red.pinned.concept.collectionmember/3jzfcijpj2z2a",
"$type": "red.pinned.concept.collection",
"title": "Collection title",
"createdAt": "1970-01-01T00:00:00Z"
}
a record with type
"red
A pinned.red collection can have a title, a creation timestamp, and... a "head"? How does a collection "store" its bookmarks then? Well, the collection's "head" is a representation of the collection's bookmarks, sort of.
I'll explain what the value of "head" means when we get
to the next Lexicon definition after collections, but for now, think
of a pinned.red collection as a folded list of bookmarks. The
first bookmark is written above the topmost fold, but to read the next
bookmark you have to unfold the list. Unfold again for each subsequent
bookmark and eventually you'll read every bookmark in the collection.
Here's the Lexicon definition for a "red" record:
{
"type": "record",
"description": "Record declaring a collection of bookmarks and (sub)collections. A collection is modeled as an ordered list of its members; copies of members are allowed.",
"key": "tid",
"record": {
"type": "object",
"required": ["createdAt"],
"properties": {
"head": {
"type": "string",
"description": "First member of the collection.",
"format": "at-uri"
},
"title": {
"type": "string",
"description": "Title of the collection."
},
"createdAt": {
"type": "string",
"description": "Creation timestamp.",
"format": "datetime"
}
}
}
}
the
red
Lexicon definition
(view Lexicon file)
Hmm, there's some details on pinned.red collections I neglected to mention:
- In addition to bookmarks, a pinned.red collection can have (sub)collections as its members. My "folded list" analogy is already inaccurate...
-
A pinned.red collection can have zero members; in this
scenario the
"head"property is omitted. - A pinned.red collection can have no title; apps can still identify the corresponding record by its record key.
As for why the
red
Lexicon definition has a "head" property instead of a
"members" property? It's due to concerns with record
size. I can't predict how many bookmarks a pinned.red user will
add to a collection, but I can imagine they could add a thousand
before they became aware. On my web browser, I don't bother changing
the default bookmark location, and now I have thousands of bookmarks
in one folder.
A "red" record would grow much larger if apps could write multiple
bookmarks and (sub)collections to it, to the point where its size
becomes a performance bottleneck. Guidelines on
size constraints for records
(and other ATProto primitives) are still evolving, but I'd rather take
caution than accidently breach some technical limitation.
Let's return to the "head" property of a
pinned.red collection.
"head": {
"type": "string",
"description": "First member of the collection.",
"format": "at-uri"
}
the "head" property as defined by
red
Instead of an array of bookmarks and collections, the
"head" property is a string — an
AT‑URI string.
AT‑URIs
(or at:// URIs) are a subset of URIs for identifying
resources on the AT Protocol, like repository records. AT‑URIs
are often used in records to reference other records, forming
relationships between record types. For a "red" record, its "head" AT‑URI is a reference to a
"red" record.
I need a better analogy to illustrate how pinned.red collections "stores" its members... but what if I told you that a pinned.red collection is fundamentaly a linked list? That should clear things up, right?
...
Okay, once I explain what "red"s are for, then this'll all make sense. Please bear with
me.
A "red" record does not represent a member of a collection, so to
speak. If pinned.red bookmarks and collections are already
viewed as collection members, then a "red" record can be viewed as the relationship between a collection
member and the collection it belongs to. A collection
membership, one could say.
Structurally, a "red" record is a node for a singly linked list. It can have a
"next" field and a "subject" field,
according to its Lexicon definition:
{
"type": "record",
"description": "Record declaring that a bookmark or (sub)collection is included once in a collection's ordered list of members. Remaining list members can be found by using the `next` property. The record key of the collectionmember record must match the record key of the subject, and that record must be in the same repository.",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "next"],
"properties": {
"subject": {
"type": "string",
"description": "The actual member — a bookmark or a (sub)collection.",
"format": "at-uri"
},
"next": {
"type": "string",
"description": "Next member in the enclosing collection, except if `isLast` is set to `true`; then `next` points to the collection itself.",
"format": "at-uri"
},
"isLast": {
"type": "boolean",
"description": "Declaration that this collection member is the last one in the enclosing collection.",
"default": false
}
}
}
}
the
red
Lexicon definition
(view Lexicon file)
Underneath those Lexicon conventions is a basic implementation
of a linked list node. "next" gives the (AT‑URI)
address of a subsequent node, unless the current node is last
in the list/collection, and "subject" represents the
actual value of the current node (a bookmark or a
(sub)collection).
Now we can piece together what the
"head" of a pinned.red collection is for. Given
that a collection's "head" is analogous to a linked
list's head, the first node in the list, we (or developers) can start
from the "head" to iterate over all of the collection's
members, just as one would iterate over a linked list from its own
head. It's how apps know what members belong to the collection, just
from reading a reference to a single member.
Suppose someone is iterating over a collection and they encounter a subcollection; if they iterate over that collection as well, then the multiple linked lists they are iterating over begin to resemble a tree. If they iterate over every subcollection and every subcollection in those subcollections and so on, they'll encounter every bookmark and collection descended from the "starting" collection.
In order for a pinned.red app to find all of a user's bookmarks
and collections, it needs to know where to begin its search; the
user's "red" record indicates the starting point.
Here's the Lexicon definition for a "red" record:
{
"type": "record",
"description": "Record declaring a reference to an actor's root collection.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["subject"],
"properties": {
"subject": {
"type": "string",
"description": "The collection.",
"format": "at-uri"
}
}
}
}
the
red
Lexicon definition
(view Lexicon file)
There's only one property: an AT‑URI to a
pinned.red collection. The "key" field is more
noteworthy: being set to
"literal:self"
indicates that a user can have only one "red" record in their repository.
I've gone over every Lexicon definition for the pinned.red records a hypothetical pinned.red user can create.
If a pinned.red app gets made, a user will obviously want to create bookmarks, but they'll also want to organize them. The file directory continues to be a proven feature for grouping items under one designation, hence collections in pinned.red. With collections alone, users can create more granular classifications by nesting collections and ranking bookmarks within collections.
Together a user's pinned.red records form a hierarchy of bookmarks, even though from the perspective of the user's repository there's no such hierarchy.
To put another way, this...
example-handle.bsky.social's user repository
├─ red.pinned.concept.rootcollection/self
├─ red.pinned.concept.collection/examplecollec
├─ red.pinned.concept.bookmark/examplebookma
├─ red.pinned.concept.collectionmember/examplebookma
├─ red.pinned.concept.collection/7estedcollect
├─ red.pinned.concept.collectionmember/7estedcollect
├─ red.pinned.concept.bookmark/7estedbookmar
├─ red.pinned.concept.collectionmember/7estedbookmar
├─ red.pinned.concept.bookmark/7estedbookma2
└─ red.pinned.concept.collectionmember/7estedbookma2
an example repository listing with several pinned.red records
represented by their $types and record keys
...can be interpreted as this...
red.pinned.concept.collection/examplecollec
├─ red.pinned.concept.bookmark/examplebookma
└─ red.pinned.concept.collection/7estedcollect
├─ red.pinned.concept.bookmark/7estedbookmar
└─ red.pinned.concept.bookmark/7estedbookma2
a hierarchy of pinned.red bookmarks and collections formed by records from the example repository
Next I'll go over how users and developers can create pinned.red records by making calls to pinned.red API methods.
Log In to pinned.red
Access your bookmarks
In addition to Lexicon definitions, I've made some interactive widgets; if you have an ATProto account, you can use the widgets on this website to create your own collections of pinned.red bookmarks.
But first, you'll need to give permission for this website to write pinned.red records on your behalf:
When you submit your Bluesky handle, you'll be redirected to another form that asks for your Bluesky password to verify your identity. After that, you'll be asked to confirm that you authorize this website to write to your repository. If you do confirm, you'll be redirected back to this website and the login form will be disabled ⸺ you're "logged in" now.
Creating Bookmarks and Collections of Bookmarks
Without writing records "by hand"
An API isn't required to create a pinned.red bookmark, but it
makes writing valid "bookmark" records more convenient.
Each pinned.red API method is "documented" by a Lexicon definition. The Lexicon definitions tell developers how calls to the API should be formatted (e.g. what parameters are required, what to include in the request body) and what responses the API can return (e.g. binary data, error messages).
We can create a pinned.red bookmark using the
red
API method; let's take a look at its Lexicon definition:
{
"type": "procedure",
"description": "Create a bookmark.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["actor", "uri"],
"properties": {
"actor": {
"type": "string",
"description": "Handle or DID of the actor (aka, current account).",
"format": "at-identifier"
},
"uri": {
"type": "string",
"description": "URI that the bookmark refers to.",
"format": "uri"
},
"title": {
"type": "string",
"description": "Title of the bookmark."
},
"collection": {
"type": "string",
"description": "Existing collection that the new bookmark should belong to.",
"format": "at-uri"
},
"index": {
"type": "integer",
"description": "The position among existing collection members where the new bookmark should be placed.",
"minimum": 0
}
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean", "default": true }
}
}
},
"errors": [{ "name": "no" }]
}
the
red
Lexicon definition
(view Lexicon file)
The "properties" field within
"input" specifies what "arguments" should be passed to
the "createBookmark" API method. Therefore, when we call the "createBookmark" API method, we should pass:
ayour Bluesky handle- a URI
- a title
- a strongRef to a pinned.red collection
- an index
Normally, we would send our pinned.red API calls to HTTP endpoints (as ATProto apps expose their APIs via HTTP), but for this proof of concept, we're going to make API calls via a wrapper library, XRPCMockup.js. The wrapper library is as close to a compliant implementation of pinned.red API methods as it can be without creating HTTP endpoints.
So instead of making an HTTP request, we'll make a function call using
the wrapper library to create a pinned.red bookmark. This form
will call the createBookmark function from the wrapper
library once it's submitted:
And this form will call the createCollection library
function, allowing us to create a pinned.red collection:
You can read the Lexicon definition for the
red
API method
here.
A Simple pinned.red Client
Not a reference implementation
Any pinned.red bookmarks you create will show up as records in your repository. You can view the records with a repository browser like PDSls, atproto-browser.vercel.app, and astrolabe.
If you'd rather use something closer to a traditional file browser, with bookmark files arranged within a hierarchy of collection folders, I've made a basic bookmark manager (please read the caveats before using it):
{{ JSON.stringify(members[stagedMemberIndex]?.value, null, 2) }}
With the bookmark manager, you can navigate to different pinned.red collections, access websites you bookmarked, and delete bookmarks and collections.
You can also copy the AT‑URI of a bookmark's or collection's underlying record by clicking the associated link button () in the Actions column of the bookmark manager. This AT‑URI can be used in the forms from the previous section.