{
  "$type": "com.whtwnd.blog.entry",
  "theme": "github-light",
  "title": "Publishing Markdown with Images to WhiteWind from a Claude.ai Session",
  "content": "# Publishing Markdown with Images to WhiteWind from a Claude.ai Session\n\n*A reference for future Claude.ai sessions operating through the credential proxy at `proxy.joshuashew.com`.*\n\nThis documents the full working pipeline for publishing a Markdown blog post to [WhiteWind](https://whtwnd.com) with inline images hosted on [wisp.place](https://wisp.place), developed while publishing [this post](https://whtwnd.com/joshuashew.bsky.social/3mhoqqpp2dc2h).\n\nThe credential proxy infrastructure is documented at [github.com/Jython1415/claude-ai-skills](https://github.com/Jython1415/claude-ai-skills).\n\n---\n\n## Architecture overview\n\nBoth 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.\n\n**Public API (no auth needed):** reading records, resolving DIDs, fetching blobs.\n\n**Requires proxy:** `uploadBlob`, `createRecord`, `putRecord`, `deleteRecord`.\n\n---\n\n## Proxy permissions required\n\nThe `bsky` service in `credentials.json` must have these collections in `allowed_write_collections`:\n\n```json\n\"allowed_write_collections\": [\n  \"com.whtwnd.blog.entry\",\n  \"place.wisp.fs\"\n]\n```\n\nAnd `allow_delete_record: true` for cleanup operations.\n\nWithout 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.\n\n---\n\n## Network allowlist required\n\nThe Claude.ai container needs these domains accessible:\n\n- `*.host.bsky.network` — PDS read/write (verify/list records, getBlob)\n- `proxy.joshuashew.com` — credential proxy for authenticated writes\n- `public.api.bsky.app` — public ATProto reads via bsky_client\n- `*.wisp.place` — verify image loads\n- `bsky.social` — session resolution, handle→DID lookup\n\n---\n\n## Step 1: Look up the user's PDS\n\n`bsky.social` is an entryway that redirects. Writes must go to the actual PDS host.\n\n```python\nimport requests\nresp = requests.get(\"https://bsky.social/xrpc/com.atproto.repo.describeRepo\",\n    params={\"repo\": \"joshuashew.bsky.social\"})\npds = next(s[\"serviceEndpoint\"] for s in resp.json()[\"didDoc\"][\"service\"]\n           if s[\"id\"] == \"#atproto_pds\")\n# e.g. https://earthstar.us-east.host.bsky.network\n```\n\nThe proxy routes writes correctly once the session is active — this lookup is mainly useful for verifying records directly against the PDS.\n\n---\n\n## Step 2: Host images on wisp.place\n\nWhiteWind 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.\n\n**2a. Gzip-compress the image and upload as a blob:**\n\n```python\nimport gzip, requests\n\nraw = open(\"image.png\", \"rb\").read()\ncompressed = gzip.compress(raw, compresslevel=9)\n\nresp = requests.post(\n    f\"{PROXY}/proxy/bsky/com.atproto.repo.uploadBlob\",\n    data=compressed,\n    headers={\"X-Session-Id\": SESSION, \"Content-Type\": \"application/octet-stream\"},\n)\nblob = resp.json()[\"blob\"]\n```\n\nBinary files (PNG, JPEG) use `\"base64\": False`. Text files (HTML, CSS, JS) need `\"base64\": True` to prevent PDS content-type sniffing. Both use `\"encoding\": \"gzip\"`.\n\n**2b. Create the `place.wisp.fs` record:**\n\nThe 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.\n\n```python\nfrom bsky_client import api\nfrom datetime import datetime, timezone\n\napi.post(\"com.atproto.repo.putRecord\", {\n    \"repo\": DID,\n    \"collection\": \"place.wisp.fs\",\n    \"rkey\": \"jshoes\",\n    \"record\": {\n        \"$type\": \"place.wisp.fs\",\n        \"site\": \"jshoes\",\n        \"root\": {\n            \"type\": \"directory\",\n            \"entries\": [{\n                \"name\": \"image.png\",\n                \"node\": {\n                    \"type\": \"file\",\n                    \"blob\": blob,\n                    \"encoding\": \"gzip\",\n                    \"mimeType\": \"image/png\",\n                    \"base64\": False,\n                }\n            }]\n        },\n        \"fileCount\": 1,\n        \"createdAt\": datetime.now(timezone.utc).isoformat(),\n    }\n})\n# Image available at: https://jshoes.wisp.place/image.png\n```\n\nThe 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.\n\n---\n\n## Step 3: Publish the WhiteWind post\n\n```python\ncontent = open(\"post.md\").read()\nimg_url = \"https://jshoes.wisp.place/image.png\"\nimg_md  = f\"![Alt text]({img_url})\\n\\n\"\n\nresult = api.post(\"com.atproto.repo.createRecord\", {\n    \"repo\": DID,\n    \"collection\": \"com.whtwnd.blog.entry\",\n    \"record\": {\n        \"$type\": \"com.whtwnd.blog.entry\",\n        \"theme\": \"github-light\",    # required for Markdown to render\n        \"title\": \"Post Title\",\n        \"content\": img_md + content,\n        \"createdAt\": datetime.now(timezone.utc).isoformat(),\n        \"visibility\": \"public\",     # or \"url\" (unlisted) or \"author\" (draft)\n    }\n})\nrkey = result[\"uri\"].split(\"/\")[-1]\n# Post at: https://whtwnd.com/joshuashew.bsky.social/{rkey}\n```\n\n**`theme: \"github-light\"` is required.** Without it, WhiteWind displays the post as raw text rather than rendered Markdown.\n\n**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.\n\n**Test with `visibility: \"author\"` first.** Only you see it when logged in. Verify rendering, then switch to `\"public\"` with a `putRecord`.\n\n**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.\n\n---\n\n## Updating and deleting posts\n\nUpdates: `putRecord` with the same `rkey` and updated `content`. Preserve the original `createdAt` to avoid changing the post's timestamp.\n\nDeletion: `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.\n\n",
  "createdAt": "2026-03-23T00:00:00.000Z",
  "visibility": "public"
}