Publishing technical posts on sanctumos.org used to mean a manual step we were not proud of: generate a screenshot locally, SCP it to multihost, guess the path under public/uploads/, then hand-paste a URL into markdown. It worked until it did not β wrong folder, stale file, no media row, hero dimensions inconsistent with Open Graph.
Commit e3f95ce (June 8, 2026) added POST /api/upload-image.php β authenticated image upload for the SanctumOS CMS, modeled on DSC's upload-blog-image.php. This post documents the contract so Otto (and you) never SCP blog figures again.
Problem statement
SanctumOS stores blog assets on disk under public/uploads/ β served directly, not tracked in git (unlike DSC, where we commit WebP under public/uploads/blog/ and cron-deploy). That split is intentional: SanctumOS is a living CMS; binary churn does not belong in the repo.
What was missing was an API-first path from automation β bytes on disk β media table row β ready-to-paste markdown/HTML. SMCP Phase 2b posts in early June shipped with SCP because the endpoint did not exist yet. This closes the gap.
Endpoint contract
URL: POST https://sanctumos.org/api/upload-image.php
Auth: X-API-Key (same keys as create-page.php / update-page.php)
Rate limit: 60 requests / 60 seconds per key (configurable via api_rate_limit_or_exit)
Multipart (preferred for local files)
| Field | Meaning |
|---|---|
file or image |
Image bytes |
topic |
Folder under /uploads/ (default blog); slug-sanitized |
role |
featured / hero β 1200Γ630 card; inline / evidence β aspect preserved |
filename |
Optional base name (no extension) |
author_username |
Media attribution (default otto) |
JSON (base64)
{
"image_base64": "<base64 or data: URI>",
"topic": "blog",
"role": "inline",
"filename": "smcp-phase-2b-screenshot",
"content_type": "image/png"
}
{ "image_base64": "
### Response (success)
json
{
"success": true,
"topic": "blog",
"role": "inline",
"path": "uploads/blog/smcp-phase-2b-screenshot.webp",
"url": "https://sanctumos.org/uploads/blog/smcp-phase-2b-screenshot.webp",
"bytes": 48231,
"mime_type": "image/webp",
"reencoded_webp": true,
"markdown": "
",
"figure_html": "<figure class=\"post-inline-figure\">...</figure>"
}
Response (success)
{
"image_base64": "<base64 or data: URI>",
"topic": "blog",
"role": "inline",
"filename": "smcp-phase-2b-screenshot",
"content_type": "image/png"
}
{ "success": true, "topic": "blog", "role": "inline", "path": "uploads/blog/smcp-phase-2b-screenshot.webp", "url": "https://sanctumos.org/uploads/blog/smcp-phase-2b-screenshot.webp", "bytes": 48231, "mime_type": "image/webp", "reencoded_webp": true, "markdown": "", "figure_html": "
### Response (success)json { "success": true, "topic": "blog", "role": "inline", "path": "uploads/blog/smcp-phase-2b-screenshot.webp", "url": "https://sanctumos.org/uploads/blog/smcp-phase-2b-screenshot.webp", "bytes": 48231, "mime_type": "image/webp", "reencoded_webp": true, "markdown": "", "figure_html": "<figure class=\"post-inline-figure\">...</figure>" }
The API returns paste-ready markdown and figure_html β same ergonomics as DSC's blog image upload.
Image processing (includes/image-helpers.php)
Shared helpers mirror DSC behavior:
| Role | Processing |
|---|---|
featured / hero |
Resample to 1200Γ630 WebP card (Open Graph friendly) |
inline / evidence |
Preserve aspect ratio; clamp width to 1400px |
| GD absent | Write raw bytes with extension from MIME (dev/Termux degrade path) |
Functions: imageSanitizeToken(), imageGdWebpAvailable(), imageWriteProcessed(), upsertMediaByPath().
Idempotency: upsertMediaByPath keys on file_path β re-uploading the same path updates the media row instead of orphaning duplicates.
Media table integration
Every successful upload records (or updates) a row in the CMS media table: filename, file_path, size, MIME, uploader. Admin and future gallery features can enumerate uploads without scraping disk.
Featured images on blog pages use the featured_image column on page β set via update-page.php to the path or public URL returned from upload.
Workspace client
Otto ships a thin CLI wrapper (workspace copy):
python3 ~/docs/blogging/research-packets/drafts-202606/upload_sanctumos_image.py \
screenshot.png blog inline my-figure-name
python3 ~/docs/blogging/research-packets/drafts-202606/upload_sanctumos_image.py \ screenshot.png blog inline my-figure-name
### Response (success)
json
{
"success": true,
"topic": "blog",
"role": "inline",
"path": "uploads/blog/smcp-phase-2b-screenshot.webp",
"url": "https://sanctumos.org/uploads/blog/smcp-phase-2b-screenshot.webp",
"bytes": 48231,
"mime_type": "image/webp",
"reencoded_webp": true,
"markdown": "
",
"figure_html": "<figure class=\"post-inline-figure\">...</figure>"
}
Loads ~/.sanctumos-org-otto-api-key, POSTs multipart to prod, prints JSON. Use topic=blog for post figures; other topics (smcp, letta, β¦) match historical upload folder conventions.
Comparison to DSC blog images
DSC upload-blog-image.php |
SanctumOS upload-image.php |
|
|---|---|---|
| Auth | API key | API key |
| Storage | public/uploads/blog/ in git |
public/uploads/<topic>/ on server only |
| Roles | featured / inline | featured / hero / inline / evidence |
| WebP + card crop | Yes (GD) | Yes (GD) |
| Media registry | Post featured_image field |
media table + page field |
| Typical deploy | git add -f WebP + cron |
API upload after create-page |
Same shape, different persistence model β choose git-deployed assets (DSC marketing repo) vs CMS-native uploads (SanctumOS).
Operational notes
- Max size: UPLOAD_MAX_SIZE
from CMS config (same guard as other uploads). - Directories: Created on demand under UPLOAD_PATH/
/ . - Prod dependency: PHP GD for WebP re-encode on multihost (present on LEMP stack); without GD, bytes pass through unchanged.
- No nginx overlay: App-level PHP only (web-design.mdc
pattern).
What this enables going forward
- Blog automation β Playwright capture β upload API β update-page.php
withfigure_htmlembedded β no SSH. - Agent-published docs β SMCP or Otto can attach evidence images without human SCP.
- Consistent heroes β every featured upload gets the same 1200Γ630 treatment for social cards.
Example: wire a featured image after creating a post
python3 ~/docs/blogging/research-packets/drafts-202606/upload_sanctumos_image.py \
screenshot.png blog inline my-figure-name
1. Upload hero
python3 upload_sanctumos_image.py hero.png blog featured my-post-slug
2. Point the page at the returned path (curl + jq or update-page.php)
featured_image: "uploads/blog/my-post-slug.webp"
### Response (success)json { "success": true, "topic": "blog", "role": "inline", "path": "uploads/blog/smcp-phase-2b-screenshot.webp", "url": "https://sanctumos.org/uploads/blog/smcp-phase-2b-screenshot.webp", "bytes": 48231, "mime_type": "image/webp", "reencoded_webp": true, "markdown": "", "figure_html": "<figure class=\"post-inline-figure\">...</figure>" }
Related reading
- DSC open-source utility launch: QR Code Studio (uses SMCP + API patterns we reuse everywhere)
- Porter partner-bridge (another API-first integration story): Broca and SMCP
- SanctumOS API quick reference:
/public/docs/api-quick-reference.mdin repo
The SCP era is over for blog figures. Use the API.