I’ve been working on adding multiple Standard Site publications to CodeTV, and I wanted each one to have its own custom icon.

In AT Protocol, images must be stored as blob types and referenced in the record, which requires a few extra steps to accomplish.

I wrote a small helper script in Node. I run it manually and just dump the blob info to the console for copy-pasting. You might do something much more sophisticated with your own images; I hope you tag me if you do so I can steal your ideas.

Step 1: Turn an image URL into an atproto blob

I already put all my images up on Cloudinary, so it made sense for me to grab images off a CDN instead of loading them from disk. I believe it would be relatively straightforward to change this script to read from disk if you prefer.

Blob upload script
import 'dotenv/config';
import { AtpAgent } from '@atproto/api';
if (!process.env.BLUESKY_USERNAME) {
console.error('No Bluesky username provided. Add BLUESKY_USERNAME in .env');
}
if (!process.env.BLUESKY_PASSWORD) {
console.error('No Bluesky password provided. Add BLUESKY_PASSWORD in .env');
}
if (!process.env.BLUESKY_USERNAME || !process.env.BLUESKY_PASSWORD) {
throw new Error('Missing Bluesky credentials');
}
const agent = new AtpAgent({
service: 'https://bsky.social',
});
await agent.login({
identifier: process.env.BLUESKY_USERNAME,
password: process.env.BLUESKY_PASSWORD,
});
async function imgUrlToBlob(url: string) {
const res = await fetch(url);
if (!res.ok) {
console.error(res.statusText);
throw new Error('unable to load image');
}
const arrayBuffer = await res.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const encoding = res.headers.get('content-type') ?? undefined;
const { data } = await agent.uploadBlob(uint8Array, { encoding });
return data;
}
const icon = await imgUrlToBlob(
'https://res.cloudinary.com/jlengstorf/image/upload/f_auto/q_auto/v1781037770/the-build-log-cover.jpg',
);
// dump the output for use in the new record
console.log(JSON.stringify(icon, null, 2));

Step 2: Copy the blob data from the output

After the blob is created, you get back a JSON response that looks like this:

Example output of uploading a blob
{
"blob": {
"$type": "blob",
"ref": {
"$link": "bafkreifjgtzald6j2ebhfkvuddrnle746eczxweogyrbln6aj3stv4yi2m"
},
"mimeType": "image/jpeg",
"size": 369212
}
}

Step 3: Use the object value of blob to populate the record

Grab just the object inside blob and put it in your record to use your own custom image in your records:

Example Standard Site publication using the uploaded blob
{
"$type": "site.standard.publication",
"url": "https://codetv.dev/series/build-log",
"name": "The Build Log (a CodeTV production)",
"description": "Join host Jason Lengstorf as he travels to hear real stories about real companies, told by the people who built them.",
"icon": {
"$type": "blob",
"ref": {
"$link": "bafkreifjgtzald6j2ebhfkvuddrnle746eczxweogyrbln6aj3stv4yi2m"
},
"mimeType": "image/jpeg",
"size": 369212
},
"createdAt": "2026-06-09T22:00:00.000Z",
"preferences": {
"showInDiscover": true
}
}

Don’t forget to reference the blob!

When a blob is first uploaded, it’s stored in a temporary location. It’s only after a record references the blob that it becomes publicly accessible and permanent! Unreferenced blobs will get garbage collected “after an appropriate amount of time”.

Make sure to create the records that reference the blob pretty soon after uploading to make sure you don’t run into issues.