Labels (com.atproto.label.defs#label
) are defined with the following fields:
#[derive(serde::Serialize)]
/// Label response content.
/// All timestamps are RFC 3339 and ISO 8601 date and time strings with milliseconds (3 subsecond digits) and Z timezone.
struct Label {
/// Expiration only provided if this label automatically expires and ceases to be applied.
#[serde(skip_serializing_if = "Option::is_none")]
exp: Option<String>,
/// CID only provided if this label applies to a specific version of a record.
#[serde(skip_serializing_if = "Option::is_none")]
cid: Option<String>,
/// Created timestamp of the label, self-reported by the label source.
cts: String,
/// Negation flag. Indicates that this label "negates" an earlier label with the same "src", "uri", and "val".
#[serde(skip_serializing_if = "bool_is_false")]
neg: bool,
/// Signature of encoded label. Discarded when sent to clients.
sig: [u8; 64],
/// DID of the label service which provides a key and label definitions for this label.
src: String,
/// DID of the account, or URI of the specific record, that this label applies to.
uri: String,
/// The <=128 character string name of the value or type of this label.
val: String,
/// Version of label schema -- currently always 1.
ver: u64,
}
The label's src
is used to lookup the #atproto_label
verification method which is the public key used to validate the sig
field.
Each label's val
is a key used to reference a label definition defined at the labeling service account's app.bsky.labeler.service/self
record. Publishing this record converts an account into a labeler. The label value should exist in the labelValues
array as well as have a corresponding labelValueDefinitions
entry in a relevant locale.
{
"$type": "app.bsky.labeler.service",
"policies": {
"labelValues": [
"example"
],
"labelValueDefinitions": [
{
"blurs": "none",
"locales": [
{
"lang": "en",
"name": "Example Label",
"description": "This is an example label."
}
],
"severity": "inform",
"adultOnly": false,
"identifier": "example",
"defaultSetting": "warn"
}
]
},
"createdAt": "2025-01-01T01:01:01.000Z"
}
The AppView consumes labels from label services over a WebSocket event stream. Each label is sent as bytes, with a header concatenated to the message body.
The header determines the message type and operation:
{
"t": "#labels",
"op": 1
}
Every message over the subscribeLabels
endpoint begins with this header, adding a small amount of overhead. When serialized to bytes with DAG-CBOR and represented in hex, the header should look like a2617467236c6162656c73626f7001
. You can take this to a CBOR decoder to see these bytes expanded back into diagnostic notation. Here's what the bytes look like when mapped to the diagnostic notation:
A2 # map(2)
61 # text(1)
74 # "t"
67 # text(7)
236C6162656C73 # "#labels"
62 # text(2)
6F70 # "op"
01 # unsigned(1)
A2
in binary is 10100010
. The bits 1010
tell us that this is a map. The bits 0010
tell us that the map has two elements. \
61
in binary is 01100001
. The bits 0110
tell us that this is a text string. The bits 0001
tell us that the text string is a single byte in ASCII. \
74
is the ASCII code for t
. \
67
in binary is 01100111
. The bits 0110
tell us that this is a text string again, but this time 0111
tells us that the text string is 7 bytes long. \
236C6162656C73
is the ASCII representation of #labels
. \
62
in binary is 01100010
. The bits 0110
tell us that this is a text string again, but this time 0010
tells us that the text string is 2 bytes long. \
6F70
is the ASCII representation of op
. \
01
is our termination byte, telling us that the map is complete.
The label is encoded in the same manner, and its bytes concatenated to the header. These are served over the com.atproto.label.subscribeLabels
endpoint as a realtime subscription of all labels, with cursor playback allowed from sequence 0
.
Labels are often also served on the com.atproto.label.queryLabels
endpoint, but not consumed by the AppView over this method, and is not required for serving labels. Some label services also implement com.atproto.report.createReport
which allows the filing of reports, and also appears to be the endpoint appeals are sent to.
The rate limit for emitting labels is 5/sec, 5k/hr, and 50k/day. It's been said that the developer experience of ingesting labels is pretty bad right now and may be improved in the future, but you should keep these limits in mind for the scope of your labeler project. At this time, automated labeling at full-firehose scale across all locales can pose a challenge for third-party labelers. Bluesky's own moderation labeler does not have this limitation.
Assuming you are within rate limits, emitted labels are usually consumed from Bluesky's two vortex clients for the AppView within a minute. If you make a request against an endpoint like getProfile
with the atproto-accept-labelers
header set to the DID of the label service, the AppView will return any accepted labels in the response, which is used by the client to visually display the labels. You will not get any feedback for labels the AppView rejected, such as labels with invalid signatures. The AppView can provide labels which the Bluesky client may not consider valid, such as labels with numerals in their val
field.
The AppView does not display labels with a neg
field set to true
, only using these to invalidate previous labels. The AppView will also not display labels with an exp
field set that has passed, as these labels have expired and are no longer valid.