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 writespublic.api.bsky.app— public ATProto reads via bsky_client*.wisp.place— verify image loadsbsky.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"\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.