Publish a recommendation

A dev.idiolect.recommendation is a community-published opinion that a particular lens path is appropriate under specific applicability conditions. It is the unit consumers query before choosing a translation: a lens that nobody recommends is just code; a lens that a community recommends under conditions a consumer satisfies is a routing decision.

The shape is:

issuingCommunity : at-uri        # the community publishing
conditions       : [Condition]   # postfix applicability tree
preconditions    : [Condition]   # additional assumptions
lensPath         : [at-uri]      # one or more chained lenses
caveats          : [Caveat]      # structured failure modes
requiredVerifications : [LensProperty]
occurredAt       : datetime

conditions and preconditions are postfix-operator trees over the combinator set defined inside dev.idiolect.recommendation.

Author the record

idiolect-records = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0" }
reqwest          = { version = "0.12", features = ["json"] }
serde            = { version = "1", features = ["derive"] }
tokio            = { version = "1", features = ["full"] }
#![allow(unused)]
fn main() {
use idiolect_records::generated::dev::idiolect::defs::{LensRef, SchemaRef};
use idiolect_records::generated::dev::idiolect::recommendation::{
    ConditionSourceIs, Recommendation, RecommendationConditions,
};
use idiolect_records::{Datetime, Record};

const LENS_URI: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text";
const SRC_SCHEMA_URI: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.schema/tutorial-post-body-v1";

fn build_recommendation(community_did: &str) -> anyhow::Result<Recommendation> {
    let community = format!("at://{community_did}/dev.idiolect.community/canonical");
    Ok(Recommendation {
        issuing_community: community.parse()?,
        conditions: vec![RecommendationConditions::ConditionSourceIs(
            ConditionSourceIs {
                schema: SchemaRef {
                    cid: None,
                    language: None,
                    uri: Some(SRC_SCHEMA_URI.parse()?),
                },
            },
        )],
        preconditions: None,
        lens_path: vec![LensRef {
            uri: Some(LENS_URI.parse()?),
            cid: None,
            direction: None,
        }],
        caveats: None,
        caveats_text: None,
        annotations: Some(
            "Endorses the rename-sort tutorial lens for source \
             records matching the v1 tutorial post-body schema."
                .to_owned(),
        ),
        required_verifications: None,
        basis: None,
        occurred_at: Datetime::parse("2026-05-12T00:00:00.000Z")?,
        supersedes: None,
    })
}
}

Every required field is type-checked at construction. Optional fields are Option<...>. The open-enum slugs in Condition* variants round-trip through their *Vocab siblings as covered in Open enums and vocabularies.

Get an authenticated session

The simplest auth path is an app password (legacy Bearer mode, not OAuth + DPoP). Generate one at https://bsky.app/settings/app-passwords for the account you want to publish under, then load it via env vars:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct CreateSessionRequest<'a> {
    identifier: &'a str,
    password: &'a str,
}

#[derive(Deserialize)]
struct Session {
    did: String,
    #[serde(rename = "accessJwt")]
    access_jwt: String,
}

async fn create_session(
    http: &reqwest::Client,
    pds: &str,
    handle: &str,
    password: &str,
) -> anyhow::Result<Session> {
    let resp = http
        .post(format!("{pds}/xrpc/com.atproto.server.createSession"))
        .json(&CreateSessionRequest { identifier: handle, password })
        .send().await?;
    if !resp.status().is_success() {
        anyhow::bail!("createSession {}: {}", resp.status(), resp.text().await?);
    }
    Ok(resp.json().await?)
}
}

OAuth + DPoP is the recommended path for production. The machinery lives in idiolect-lens under the dpop-p256 feature plus idiolect-oauth's session stores. For tutorial purposes the Bearer path is enough.

Sign and publish

The PDS accepts a typed record body plus a $type discriminator on a com.atproto.repo.createRecord call:

#[derive(Serialize)]
struct CreateRecordRequest<'a> {
    repo: &'a str,
    collection: &'a str,
    rkey: &'a str,
    record: serde_json::Value,
}

async fn publish(
    http: &reqwest::Client,
    pds: &str,
    bearer: &str,
    repo: &str,
    rec: &Recommendation,
    rkey: &str,
) -> anyhow::Result<()> {
    let mut value = serde_json::to_value(rec)?;
    if let serde_json::Value::Object(ref mut map) = value {
        map.insert(
            "$type".to_owned(),
            serde_json::Value::String(Recommendation::NSID.to_owned()),
        );
    }
    let resp = http
        .post(format!("{pds}/xrpc/com.atproto.repo.createRecord"))
        .bearer_auth(bearer)
        .json(&CreateRecordRequest {
            repo,
            collection: Recommendation::NSID,
            rkey,
            record: value,
        })
        .send().await?;
    if !resp.status().is_success() {
        anyhow::bail!("createRecord {}: {}", resp.status(), resp.text().await?);
    }
    Ok(())
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let pds      = std::env::var("PDS_URL")?;
    let handle   = std::env::var("ATPROTO_HANDLE")?;
    let password = std::env::var("ATPROTO_PASSWORD")?;

    let http = reqwest::Client::new();
    let sess = create_session(&http, &pds, &handle, &password).await?;
    let rec  = build_recommendation(&sess.did)?;
    publish(&http, &pds, &sess.access_jwt, &sess.did, &rec,
            "tutorial-rename-sort").await?;
    println!("published at://{}/{}/tutorial-rename-sort",
             sess.did, Recommendation::NSID);
    Ok(())
}

Run with credentials in the env:

PDS_URL=https://bsky.social \
ATPROTO_HANDLE=yourhandle.bsky.social \
ATPROTO_PASSWORD='xxxx-xxxx-xxxx-xxxx' \
cargo run

The project DID has already published this exact record:

idiolect fetch \
  at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.idiolect.recommendation/tutorial-rename-sort

If the record decodes and the issuingCommunity resolves to a community you control, the loop is closed:

flowchart LR
    A[author] -->|publishes| R[recommendation]
    R -->|points at| L[lens path]
    L -->|verified by| V[verification records]
    R -->|consumed by| C[consumer]
    C -->|decides| I[invoke or not]

The community has expressed an opinion. The lens has machine-checkable verifications attached (chapter 4 produced Holds for the roundtrip-test property). A consumer querying the orchestrator can fetch both, evaluate the conditions, and decide whether to invoke.

From the CLI

The CLI ships both pieces of this flow:

# 1. Authenticate (app password; legacy Bearer mode).
idiolect oauth login --handle yourhandle.bsky.social
# password from --app-password or env

# 2. Publish a record from a JSON file.
idiolect publish recommendation --record ./tutorial-rename-sort.json

idiolect oauth login exchanges credentials via com.atproto.server.createSession and stores the resulting session as a JSON file. idiolect publish <kind> validates the JSON against the typed Record impl, splices in $type, and POSTs com.atproto.repo.createRecord under the stored session.

ATProto is moving off app passwords toward OAuth + DPoP; the next-iteration CLI wraps atrium-oauth's browser-handoff dance and persists the resulting DPoP-bound session through the same OAuthTokenStore. The publish path is unchanged.

That is the full loop. Where to go next: