| type | playbook |
|---|
A scheduled task fires a Claude session with instructions to run this playbook.
Claude executes each step using MCP tools (Substack) plus the image generator
configured in config.json and helper scripts for deterministic queue work.
Trigger prompt for scheduled task:
Execute the Substack visual publish flow. Read
visual_playbook.mdand follow it exactly. Language: es (or en). Image generator: pillow_local (or canva). Abort if anything fails; do not improvise.
The playbook is generator-agnostic. Step 3 varies by adapter. See the appendix for adapter-specific choreography.
Run:
python3 visual_helper.py peek --lang esExpected response:
{
"status": "ok",
"queue_number": "5",
"title": "Note title",
"pillar": "P1",
"quote_text": "The quote text for the card.",
"context_line": "Optional caption for the Note body.",
"figure_id": "fig_03_seated.png",
"figure_rotation": true
}If status is "empty", exit cleanly. Log VISUAL_QUEUE_EMPTY to
Substack Publishing Log.md and stop. Nothing to publish this cycle.
Read config.json. Check image_generator.default. If not set, use pillow_local.
Supported adapters:
pillow_local-- pure Pillow, renders locally. No external API. See Step 3a.canva-- Canva MCP choreography. See Appendix A.nano_banana,midjourney,dalle-- see adapter stubs inimage_generators/.
Run:
python3 render_card.py \
--pillar <pillar from Step 1> \
--quote "<quote_text from Step 1>" \
--handle "@your-handle" \
--output /tmp/substack_visual.pngFor P1 with a figure:
python3 render_card.py \
--pillar P1 \
--quote "<quote_text>" \
--handle "@your-handle" \
--figure ~/your-vault/images/figures/<figure_id from Step 1> \
--output /tmp/substack_visual.pngOn success, the output PNG is at the path given by --output.
See Appendix A for the full Canva MCP choreography (clone template, replace text, replace figure, commit, export). The choreography produces a download URL. Download the PNG:
curl -o /tmp/substack_visual.png "<download_url from Canva export>"Call get_image_generator(adapter_name, config["image_generator"]) and invoke
.generate(spec). The returned image_path is the local PNG to upload.
Use the Substack MCP:
substack__upload_image
image_path: /tmp/substack_visual.png
publication: secondary (for ES) or main (for EN)
Save the returned image_url.
Use the Substack MCP:
substack__create_note_attachment
image_url: <image_url from Step 4>
publication: secondary
Save the returned attachment_id.
Use the Substack MCP:
substack__publish_note
text: <context_line from Step 1, or empty string for image-only>
publication: secondary
attachment_ids: [<attachment_id from Step 5>]
Save the returned note_id.
Run:
python3 visual_helper.py mark \
--lang es \
--queue-number <queue_number from Step 1> \
--note-id <note_id from Step 6> \
--pillar <pillar from Step 1> \
--figure-used <figure_id from Step 1>This updates the Review Queue entry with a PUBLISHED YYYY-MM-DD id:NNN visual:P1/fig_03
marker and appends a row to Substack Publishing Log.md.
At any step, if a tool call returns an error:
- Log the failure to
Substack Publishing Log.mdwith the step name and error message. - Do NOT mark the queue entry as published.
- Do NOT retry automatically. The next scheduled fire will pick up the same queue entry.
- Exit with a clear status so the user can diagnose on morning review.
If using the Canva adapter and the failure is in the commit step, call
cancel-editing-transaction before exiting so the clone is not left in a
broken state.
--lang esroutes to publicationsecondary--lang enroutes to publicationmain
For separate language templates in Canva, give them different design IDs and
store them in distinct pillar keys (e.g. P1 for EN, P1_es for ES) in
config.json under image_generator.canva.pillars.
If the trigger prompt includes --dry-run, execute Steps 1 and 2 only
(fetch queue entry and determine adapter), report what would be generated,
then EXIT without uploading or publishing. Use this to validate the pipeline
is wired correctly without consuming API credits.
This section is specific to the canva adapter. The main playbook above is
generator-agnostic; skip this appendix unless image_generator.default is
set to "canva".
Call merge-designs with:
type: "create_new_design"title: "Substack Note clone PX {timestamp}"operations:[{type: "insert_pages", source: {type: "design", design_id: <design_id from config>}}]
Save the returned design_id as clone_design_id. The master template stays
untouched.
Call start-editing-transaction with clone_design_id.
Save:
transaction_idpagesarray- The text element ID for the placeholder quote (used in A3)
Call perform-editing-operations with:
transaction_idpages- One
find_and_replace_textoperation:element_id: the quote element ID from A2find_text: the placeholder quote string baked into the templatereplace_text:quote_textfrom Step 1
Only if pillar == "P1" AND figure_rotation == true AND figure_id is set.
-
Upload the figure:
upload-asset-from-url url: file://{figure_library_path}/{figure_id}Save
asset_id. -
Replace the image element:
perform-editing-operations transaction_id: {{transaction_id}} operations: [{type: "update_fill", asset_id: {{asset_id}}}]
If the figure library is empty, skip A4. The clone keeps the template's default figure.
Call commit-editing-transaction with transaction_id.
On failure here, call cancel-editing-transaction before exiting.
Call export-design with:
design_id:clone_design_idformat:{"type": "png", "width": 1080, "height": 1080, "export_quality": "pro"}
Save the returned download_url, then continue at Step 4 of the main playbook.
The cloned design is no longer needed after export. Either delete it to keep the Canva workspace clean or leave it for weekly housekeeping. Canva auto-archives unused designs.