Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,12 @@ against `main` again on 2026-08-07, after #18: five are zero-commit snapshots, `
test this repo has in English *plus* the logging assertions), and `jcllobet`'s reader work went in
with #18. Only `jefflee1990710` still holds anything, and it is one coherent area rather than a list:
**account and publication settings** — `PUT /api/v1/publication` (`name`, `hero_text`, `copyright`,
`email_from_name`, `logo_url`), `PUT substack.com/api/v1/user/profile` (`name`, `bio`, `photo_url`),
and the `POST substack.com/api/v1/image` upload both depend on, since per the fork an external url is
stored but does not render. That upload is also the only route to an image *inside* a post, which
nothing here can do. All three are unverified writes from the fork that invented `share_automatically`,
so none of it ships without a live check first; they are open by choice, not by oversight.
`email_from_name`, `logo_url`) and `PUT substack.com/api/v1/user/profile` (`name`, `bio`, `photo_url`).
Per the fork these store an external `logo_url`/`photo_url` that does not render, so they likely need a
Substack-hosted asset first — which `upload_image` now produces (`POST /api/v1/image`, verified and
shipped; see the image contract below). Both settings writes are still unverified writes from the fork
that invented `share_automatically`, so neither ships without a live check first; they are open by
choice, not by oversight.

**Writes log their intent at `info` *before* the request**, not only their outcome —
`publish_draft.publishing`, `comment_on_post.posting`, `restack_item.restacking`, with the full text
Expand Down Expand Up @@ -328,12 +329,31 @@ nothing and closed the last route that accepted a body unchecked. Six measured f
`plaintext` the plain-text value, and an unrecognised name renders as Plain Text with no error, so
omitting the attr beats guessing.

**Images can be referenced but not uploaded**, which is the contract's sharpest limit: `captionedImage`
is in 60 of 60 sampled posts and `image2.src` must already point at a Substack-hosted asset.
`POST /api/v1/image` was tried on the publication host as JSON, as form-urlencoded and as multipart —
**all three hang**, the network log showing the request pending past a minute with no response, and the
cross-origin `substack.com` attempt never settled either. So neither `python-substack`'s signature nor
the fork's is confirmed. **Do not implement an upload from either.**
**Images can be uploaded after all, and `upload_image` is how** — this once read "cannot be uploaded,
`POST /api/v1/image` hangs in all three encodings, do not implement." That record was wrong. Re-measured
live 2026-08-08 on `implementing.substack.com` from the authenticated dashboard: the endpoint answers
**200 in ~300ms**. The three earlier attempts (JSON, form-urlencoded, multipart) failed because they sent
the wrong *thing*, not for a header detail or a Cloudflare wall — the body is JSON `{image:
"data:<mime>;base64,…"}`, a **data URI**, built in the editor from `canvas.toDataURL()`. The response is
`{id, url, contentType, bytes, imageWidth, imageHeight}`, `url` on `substack-post-media.s3.amazonaws.com`
— the host every `image2.src` uses — and it renders through Substack's CDN (proven end to end on a real
draft: upload → `captionedImage` → PUT → the editor shows a `substackcdn.com/image/fetch/…` render).
Two measured facts shape the tool:
- **Substack server-fetches only its own S3 bucket.** An external URL passed as `image` answers
`400 "Failed to fetch image"`, so `upload_image` downloads the URL itself and re-encodes it. That
download is the one place this server fetches a caller-chosen host, so it is guarded: `http(s)` only,
an SSRF block on private/loopback/link-local addresses *after* DNS resolution and re-checked on every
redirect hop, an `image/*` content-type check (HEIC refused early, as the dashboard does), and a 10 MB
cap that is **ours, not Substack's** — the bundle's `MAX_FILE_SIZE` could not be read from the minified
source.
- **The data URI is elided in the logger, not just kept out of the tool's own lines.** `SubstackApi` logs
every request body at info, so a real upload would put hundreds of KB of base64 on one line;
`src/logger.js` truncates a `data:…;base64,` value to its prefix and omitted length. A post body, being
prose, is still logged in full — the two are different in kind.

**Still unverified:** every live check used the browser session cookie, not `SUBSTACK_SESSION_TOKEN` in a
header. Equivalent in principle, unconfirmed through `SubstackApi` — the first thing to check if the tool
misbehaves against the real API.

**`set_post_body` returns a node tally, not `'OK'`**, because validation cannot report what was never
sent: a document with no paywall is exactly as valid as one with a paywall. This was measured — a model
Expand Down Expand Up @@ -487,10 +507,11 @@ literals (`{a: 1}`, not `{ a: 1 }`).
SDK, and `registerTool` throws `inputSchema must be a Zod schema or raw shape` for anything
else — the SDK's validation *is* zod. npm auto-installs it even if it leaves `package.json`,
so removing the direct dependency buys nothing and unpins the version.
- **`ZodError` details live on `.issues`, not `.errors`** (zod 4 renamed it). The only reader in
`src/` is the `create_draft_post.args.invalid` log — the SDK formats the message it sends to
the client — and `.errors` silently yields `undefined` rather than failing, so a handler that
inspects them logs nothing and reports no error.
- **`ZodError` details live on `.issues`, not `.errors`** (zod 4 renamed it). The readers in
`src/` are the `*.args.invalid` logs (`create_draft_post`, `upload_image`, and the other tools
that parse in a try/catch) — the SDK formats the message it sends to the client — and `.errors`
silently yields `undefined` rather than failing, so a handler that inspects them logs nothing and
reports no error.

## Verifying the server actually works

Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,34 @@ asked for a paywall can confirm there is one. Validation cannot report a node th

Three things worth knowing:
- **An image must already be hosted by Substack.** `image2.src` pointing at an external URL is
stored but does not render, and this server cannot upload one.
stored but does not render. Use `upload_image` to re-host one and get a `src` that works.
- **A document may contain at most one `paywall`.** Substack accepts two and renders both, leaving
it undefined which one cuts the post, so this tool refuses the second.
- **`ordered_list` numbers from `attrs.order`, not `attrs.start`.** A list given only `start`
renders from 1 with no error.
</details>

<details>
<summary><strong>upload_image</strong> - Re-host an external image on Substack</summary>

Substack's editor uploads images as base64 data URIs to `POST /api/v1/image`, which answers with a
Substack-hosted URL. `image2.src` in `set_post_body` only renders such a URL, so this tool is the
bridge: give it an http(s) image URL, it downloads the image, re-encodes it, uploads it, and returns
the hosted URL. Substack itself only re-fetches URLs already in its own storage, so the download
happens here rather than being handed off.

**Inputs**:
- `url` (string): the http(s) URL of an image to upload
- `post_id` (number, optional): the post the image belongs to; its effect is unconfirmed

**Returns**: `{id, url, content_type, bytes, width, height}` — put `url` into an `image2.src` when
calling `set_post_body`.

The download is guarded: only `http`/`https`, private and loopback hosts are refused after DNS
resolution (redirects are re-checked at every hop), the content type must be an image, HEIC is
rejected with a note to convert it, and the image may not exceed 10 MB.
</details>

<details>
<summary><strong>update_draft</strong> - Change a draft's title, subtitle or audience</summary>

Expand Down
Loading