Publishing Markdown with Images to WhiteWind from a Claude.ai Session

@joshuashew.bsky.social

Publishing Markdown with Images to WhiteWind from a Claude.ai Session

A reference for future Claude.ai sessions operating through the credential proxy at proxy.joshuashew.com.

This documents the full working pipeline for publishing a Markdown blog post to WhiteWind with inline images hosted on wisp.place, developed while publishing this post.

The credential proxy infrastructure is documented at github.com/Jython1415/claude-ai-skills.


Architecture overview

Both WhiteWind and wisp.place are ATProto applications. All writes go to the user's PDS via com.atproto.repo.createRecord / putRecord. The credential proxy holds a long-lived authenticated session and forwards these calls on Claude's behalf.

Public API (no auth needed): reading records, resolving DIDs, fetching blobs.

Requires proxy: uploadBlob, createRecord, putRecord, deleteRecord.


Proxy permissions required

The bsky service in credentials.json must have these collections in allowed_write_collections:

"allowed_write_collections": [
  "com.whtwnd.blog.entry",
  "place.wisp.fs"
]

And allow_delete_record: true for cleanup operations.

Without these entries, createRecord and putRecord return 403 — This endpoint is restricted by proxy security policy. The filter is collection-specific: the proxy parses the request body to check the collection field before deciding whether to forward.


Network allowlist required

The Claude.ai container needs these domains accessible:

  • *.host.bsky.network — PDS read/write (verify/list records, getBlob)
  • proxy.joshuashew.com — credential proxy for authenticated writes
  • public.api.bsky.app — public ATProto reads via bsky_client
  • *.wisp.place — verify image loads
  • bsky.social — session resolution, handle→DID lookup

Step 1: Look up the user's PDS

bsky.social is an entryway that redirects. Writes must go to the actual PDS host.

import requests
resp = requests.get("https://bsky.social/xrpc/com.atproto.repo.describeRepo",
    params={"repo": "joshuashew.bsky.social"})
pds = next(s["serviceEndpoint"] for s in resp.json()["didDoc"]["service"]
           if s["id"] == "#atproto_pds")
# e.g. https://earthstar.us-east.host.bsky.network

The proxy routes writes correctly once the session is active — this lookup is mainly useful for verifying records directly against the PDS.


Step 2: Host images on wisp.place

WhiteWind renders images from external URLs in Markdown. Images stored as PDS blobs can technically be referenced via com.atproto.sync.getBlob URLs, which WhiteWind rewrites to its own /api/cache proxy — but in practice this path is unreliable for newly created records that WhiteWind hasn't indexed yet. Use wisp.place instead: it serves files from its own CDN and the URL works regardless of WhiteWind's indexing state.

2a. Gzip-compress the image and upload as a blob:

import gzip, requests

raw = open("image.png", "rb").read()
compressed = gzip.compress(raw, compresslevel=9)

resp = requests.post(
    f"{PROXY}/proxy/bsky/com.atproto.repo.uploadBlob",
    data=compressed,
    headers={"X-Session-Id": SESSION, "Content-Type": "application/octet-stream"},
)
blob = resp.json()["blob"]

Binary files (PNG, JPEG) use "base64": False. Text files (HTML, CSS, JS) need "base64": True to prevent PDS content-type sniffing. Both use "encoding": "gzip".

2b. Create the place.wisp.fs record:

The rkey must match the subdomain label you've claimed (e.g. jshoes for jshoes.wisp.place). Use putRecord rather than createRecord so it's idempotent on retry.

from bsky_client import api
from datetime import datetime, timezone

api.post("com.atproto.repo.putRecord", {
    "repo": DID,
    "collection": "place.wisp.fs",
    "rkey": "jshoes",
    "record": {
        "$type": "place.wisp.fs",
        "site": "jshoes",
        "root": {
            "type": "directory",
            "entries": [{
                "name": "image.png",
                "node": {
                    "type": "file",
                    "blob": blob,
                    "encoding": "gzip",
                    "mimeType": "image/png",
                    "base64": False,
                }
            }]
        },
        "fileCount": 1,
        "createdAt": datetime.now(timezone.utc).isoformat(),
    }
})
# Image available at: https://jshoes.wisp.place/image.png

The wisp.place firehose consumer picks up the record and makes the file available at https://{subdomain}.wisp.place/{filename}. If the domain shows "Domain not mapped to a site", go to wisp.place → your site → Settings and explicitly link the domain there. The place.wisp.domain PDS record is an audit trail only — routing requires a separate action in wisp.place's backend.


Step 3: Publish the WhiteWind post

content = open("post.md").read()
img_url = "https://jshoes.wisp.place/image.png"
img_md  = f"![Alt text]({img_url})\n\n"

result = api.post("com.atproto.repo.createRecord", {
    "repo": DID,
    "collection": "com.whtwnd.blog.entry",
    "record": {
        "$type": "com.whtwnd.blog.entry",
        "theme": "github-light",    # required for Markdown to render
        "title": "Post Title",
        "content": img_md + content,
        "createdAt": datetime.now(timezone.utc).isoformat(),
        "visibility": "public",     # or "url" (unlisted) or "author" (draft)
    }
})
rkey = result["uri"].split("/")[-1]
# Post at: https://whtwnd.com/joshuashew.bsky.social/{rkey}

theme: "github-light" is required. Without it, WhiteWind displays the post as raw text rather than rendered Markdown.

Use createRecord for the initial post, not putRecord. WhiteWind's relay consumer watches the ATProto firehose for create events. Posts written with putRecord from the start may not be indexed. For updates to existing posts, putRecord with the same rkey is correct.

Test with visibility: "author" first. Only you see it when logged in. Verify rendering, then switch to "public" with a putRecord.

Do not call notifyOfNewEntry. The endpoint https://whtwnd.com/xrpc/com.whtwnd.blog.notifyOfNewEntry is a hint to jump the indexing queue, not a requirement — and whtwnd.com is not in the Claude.ai network allowlist. The firehose is sufficient.


Updating and deleting posts

Updates: putRecord with the same rkey and updated content. Preserve the original createdAt to avoid changing the post's timestamp.

Deletion: deleteRecord on com.whtwnd.blog.entry. Note that deleting a record orphans its blobs on the PDS — if you delete a post and recreate it referencing the same blobs, the PDS will return BlobNotFound. Blobs must be re-uploaded after deletion.

joshuashew.bsky.social
Joshua Shew

@joshuashew.bsky.social

If your brain isn’t tired by the end of the day, you’re doing it wrong

he/him

2026 theme: Year of Exploration

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)