Every site gets a media library backed by a shared CDN. You upload an image, PDF, video, or web font once; StaticOwl stores it, hands you a permanent CDN URL, and you drop that URL into a content field or a template. Uploads are content-addressed — identical bytes de-duplicate to the same asset — and served with a long, immutable cache so the CDN does the heavy lifting.

This guide covers uploading (from a file or a URL), how an upload becomes a stored asset, image transforms, and how to reference media in content and templates.


Quick start

Two ways to get bytes into your library:

You have… Use
A local file on your computer POST /api/media/upload (multipart), or the dashboard Media → Upload
A public URL (generated image, stock photo, another site) POST /api/media/upload-from-url
An agent that produced an image URL MCP media_upload_from_url
Local files, but you're driving via an AI agent MCP create_upload_link → hand the user a browser upload page

Both upload paths return the same JSON, the important field being url — the permanent CDN address you store and render:

{
  "ok": true,
  "id": "asset:9f2a1c4b7e0d3a56",
  "url": "https://media.preview.staticowl.com/_media/site_mysite/9f2a1c4b…d3a56.jpg",
  "filename": "hero.jpg",
  "mimetype": "image/jpeg",
  "bytes": 184213,
  "width": 1600,
  "height": 900,
  "sha256": "9f2a1c4b…",
  "variants": []
}

Uploading

From a local file (multipart)

The upload field name is file:

curl -X POST https://app.staticowl.com/api/media/upload \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -F "file=@./hero.jpg"

From a URL (server-side fetch)

Pass any public http(s) URL and StaticOwl fetches the bytes on the server, then runs the exact same pipeline as a multipart upload:

curl -X POST https://app.staticowl.com/api/media/upload-from-url \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceUrl": "https://replicate.delivery/…/out.png",
    "filename": "generated-hero.png",
    "altText": "A barn owl mid-flight at dusk"
  }'

Why from-URL matters. Because the fetch happens server-side, it bypasses two things that block a browser:

This is the canonical path for agents: an AI that generates an image with its own tool (Replicate, DALL·E, Ideogram, a self-hosted diffusion model) gets back a URL, not bytes — so it passes that URL straight to upload-from-url/media_upload_from_url and stores the returned CDN URL.

upload-from-url also accepts a Google Drive share link to a single file (shared as "Anyone with the link") and pulls it in directly. Drive folder links are not supported yet — they return a gdrive_folder_unsupported 400.

Listing, fetching, deleting

# List assets (newest first). q= filters filename/url; limit caps the page (max 500).
curl "https://app.staticowl.com/api/media/?limit=30&q=hero" \
  -H "Authorization: Bearer $STATICOWL_API_KEY" -H "X-Site-Id: site:mysite"

# Delete an asset — removes the Asset node AND the S3 object. Not reversible.
curl -X DELETE "https://app.staticowl.com/api/media/asset:9f2a1c4b7e0d3a56" \
  -H "Authorization: Bearer $STATICOWL_API_KEY" -H "X-Site-Id: site:mysite"

MCP (AI agents)

Tool Required args What it does
media_upload_from_url sourceUrl Fetch a public URL (or single-file Drive link) into media. Optional filename, altText. 25 MB max.
media_list List assets, newest first. Optional search, limit (default 30, max 500), full.
media_get id One asset by id, including its variant list.
media_delete id Remove the Asset node and the S3 object. Not reversible.
media_stock_search query Search Unsplash / Pexels / Pixabay; returns URLs you can pass to media_upload_from_url. Optional provider, limit.
media_generate_alt_text imageUrl Claude-vision alt text for an image URL. Needs ANTHROPIC_API_KEY on the server.
create_upload_link Mint a one-time, single-site browser upload page for local files (~24h). Hand it to the user; MCP can't receive local file bytes.
check_upload List the most recent uploads (default 5, max 30) — use right after a create_upload_link hand-off.

There is no multipart MCP upload tool by design: an agent can't stream local file bytes over MCP. For a URL the agent has, use media_upload_from_url; for files on the user's disk, use create_upload_link and then check_upload.

Example agent flow:

media_stock_search { query: "misty pine forest at dawn", provider: "unsplash", limit: 5 }
media_upload_from_url { sourceUrl: "https://images.unsplash.com/photo-…", altText: "Fog over a pine forest" }
# → returns { id, url, … }; store url in your content field.

The media CDN & content-addressed dedup

Every upload becomes an object in S3 and is served from the shared media CDN. The moving parts:

Piece Value
CDN host media.preview.staticowl.com (env STATICOWL_MEDIA_HOST)
S3 key format _media/<sitePrefix>/<sha256><ext>
<sitePrefix> your site id with non-alphanumeric chars replaced by _ (e.g. site:mysitesite_mysite)
Public URL https://media.preview.staticowl.com/<key>
Cache-Control public, max-age=31536000, immutable (one year)

Content addressing. The upload's SHA-256 hash is the filename. Two consequences:

  1. Identical bytes de-duplicate. Upload the same image twice — same hash, same key, same asset. The second upload is effectively free and idempotent.
  2. Different bytes get a different URL. Edit an image and re-upload and you get a brand-new URL. That's why the CDN can cache immutable for a year — a given URL's bytes never change. Update the content field to point at the new URL.

The Asset record

Each upload upserts an Asset node on your site's graph, linked (:Site)-[:HAS_ASSET]->(:Asset). Its id is asset:<first 16 hex of the sha256>. Fields:

Field Meaning
id asset:<hash16>
url the CDN URL you reference
key the S3 key
sha256 full content hash
mimetype e.g. image/jpeg, application/pdf
bytes size of the primary object
width / height intrinsic pixel dimensions (images/SVG; null for video/PDF)
filename original name
altText optional, saved from the upload or media_generate_alt_text
variants JSON array of derived renditions (see below; empty unless auto-optimize is on)
uploadedBy / uploadedAt provenance

Image transforms & variants

If a site enables auto-optimize (mediaSettings.autoOptimize.enabled, off by default, toggled per-site), image uploads run through a sharp pipeline that resizes, strips EXIF (privacy + bytes), auto-rotates from the EXIF orientation flag, and produces a set of variants alongside the primary:

Variant What it is
primary Re-encoded source, resized to ≤ maxWidth (default 2400px). JPEG at jpegQuality (default 85); PNGs stay PNG to keep transparency. Replaces the canonical key.
webp Same dimensions as primary, WebP q82 — only when generateWebP is on.
thumb400 400px-wide JPEG q80, for list/nav thumbnails.
thumb800 800px-wide JPEG q80, for cards.

Variants are stored under sibling S3 keys derived from the same hash (…<hash>_t400.jpg, …_t800.jpg, …<hash>.webp) and recorded in the Asset's variants array as {name, key, url, mimetype, bytes, width, height}. Read them with media_get.

Notes:


Referencing media in content

A media reference is just its CDN URL string. Give your content type an image field and store the url you got back from the upload:

types_create {
  name: "product",
  fields: [
    { name: "title", type: "string", required: true },
    { name: "image", type: "image" }
  ]
}

content_create {
  type: "product",
  fields: {
    title: "Kenya AB",
    image: "https://media.preview.staticowl.com/_media/site_mysite/9f2a…d3a56.jpg"
  }
}

In the dashboard editor an image field shows a text input plus a Pick button that opens the media library so you don't have to paste URLs by hand.


Referencing media in templates

The stored value is a plain URL, so the simplest reference is a bare expression — autoescaped, which is safe inside an attribute:

<img src="{{ image }}" alt="{{ title }}">

Better: the built-in img filter emits a complete responsive <img> tag, so you don't hand-write attributes:

{{ image | img: alt: title, width: 1600, height: 900 }}

img accepts these named args (a bare first arg is treated as alt):

Arg Effect
alt Alt text (also title).
width / height Sets the attributes (helps layout stability).
loading lazy (the default) or eager for above-the-fold hero images.
class / style Extra attributes on the tag.

Inside a {% list %} loop the URL lives on the loop variable, same as any field:

{% list type:"product" order:"title" as p %}
  <a href="/products/{{ p.slug }}.html">
    {{ p.image | img: alt: p.title, width: 400 }}
    <h3>{{ p.title }}</h3>
  </a>
{% endlist %}

Self-hosted web fonts work the same way — upload the .woff2, then reference its CDN URL in an @font-face src. Cross-origin font loading is already handled at the CDN (see gotchas).


Formats & size limits

Max file size 25 MB — both multipart upload and upload-from-url (fetched-size cap)
Images JPEG, PNG, GIF, WebP, AVIF, SVG
Video MP4, WebM, MOV
Documents PDF
Web fonts WOFF2, WOFF, TTF, OTF (accepted by extension even with a generic mimetype)

upload-from-url additionally requires the source to be http:// or https:// (no file:// / data://), fetches with a 15-second timeout (20s for Drive), and rejects anything over the 25 MB cap with a 413.


Gotchas & tips


See also

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Deploy targets — StaticOwl Docs
64% match
Deploy targets — S3, GitHub, and CDN options
62% match
HTTP API reference — StaticOwl Docs
60% match