heart.photos — the photo library
One library of photographs, used by he.art (where photos are items you edit), avif.he.art (an AVIF converter that files what it converts), and this gallery. Upload a picture through any of them — or through the API or MCP below — and every one of them sees it. This page is the honest account: what it does, how to drive it, and where its edges are.
What it does
- Converts and keeps. An upload (JPEG, PNG, WebP, AVIF — 25 MB, 64 megapixels max) is converted to AVIF on the server. The untouched source is kept, so quality choices are never final and the original can always be downloaded back.
- Resolutions. Each photo can carry
original,thumb,mobile,1080pand4krenditions, made only when they would actually be smaller. Public URLs live oni.heart.photos/<id>[/<kind>]; the bare URL picks a size for the requesting device. - Deduplicates. Bytes are content-addressed by SHA-256. Uploading a picture you already hold returns the existing photo instead of a copy, and identical bytes are stored once even when several accounts hold them.
- Reference-counted deletion. Deleting removes your photo. The bytes are removed only when no reference from anyone remains — so two accounts holding the same picture can each delete freely without breaking the other.
- Visibility.
private(owner only),unlisted(anyone with the link),public(also in this gallery). Enforced where the bytes are served. One caveat: once a photo has been public, already-shared links keep resolving after it is unlisted — links you handed out are not broken retroactively. - Pairs, tags, titles. A wide photo and its tall rendition can be joined as one picture; photos carry tags shared with he.art's vocabulary; He.art can write an AI title and description for gallery photos.
- Identity that cannot rot. Accounts are keyed by the Google
sub— a permanent account number that never changes and is never reused — not by email, which can change hands. Sign in with the same Google account anywhere and it is the same library.
Where its edges are
Weaknesses, stated plainly — judge for yourself whether they matter for your use.
- Bytes live in Google Drive. Cheap and durable, but a cold read takes a second or two before the CDN has it cached, and Drive rate limits are real. This is a deliberate cost choice; the storage layer is swappable and has moved twice already.
- One region, one machine, one maintainer. Served from a single Fly.io machine in
iadthat sleeps when idle (first request wakes it). No SLA. Not the place for anything irreplaceable without your own backup. - AVIF only. Renditions are AVIF; if a consumer cannot decode AVIF, use
/sourcefor the original format. - The changes feed is at-least-once. Consumers must treat events idempotently.
- Tokens are bearer secrets. Shown once at minting, revocable at he.art (avatar → API tokens). A token can read, upload, edit and delete the account's photos — treat it like a password.
- No browser CORS yet. The API is for servers, scripts and agents; browser apps on other origins cannot call it directly today.
Getting a token
Two ways, both producing a heart_… bearer token:
- By hand: sign in at he.art → avatar → API tokens → New. The secret is shown exactly once.
- By exchange (for products): verify your user with Google Sign-In, then post the ID
token here. The library verifies it itself and answers with a token for that account.
One token per (account, product) — exchanging again replaces it.
curl -s https://heart.photos/api/v1/token \ -d '{"google_id_token":"eyJ…","product":"my-app"}' → {"token":"heart_…","sub":"1044…","email":"…"}
The API (v1)
All under https://heart.photos/api/v1, JSON in and out,
Authorization: Bearer heart_…. The v1 prefix is a promise: these paths
and shapes stay.
| Route | What it does |
|---|---|
GET /me | Who the token is. The cheap way to test one. |
GET /assets | Your photos, newest first, with URLs, variants, pairing, visibility. |
POST /assets | Multipart upload: file, optional name, quality (1–100, default 60). Duplicate bytes return the existing photo with duplicate_of. |
GET /assets/{id} | One photo. |
PATCH /assets/{id} | Any of name, description, rotation, visibility. |
DELETE /assets/{id} | Drop your reference; bytes go only when nothing else points at them. |
POST /assets/{id}/pair | Form field with=<id> joins wide+tall; empty splits. |
POST /assets/{id}/tags | {"tags":[…]} — the photo carries exactly these words afterwards. |
GET /blobs/{sha256} | Do you already hold these bytes? 200 with the photo, or 404 — the way to skip a 25 MB upload. Scoped to you: it never reveals other accounts' holdings. |
POST /assets/{id}/candidates | Re-render at another quality/speed; a candidate is kept until one is chosen. |
POST /assets/{id}/choose/{candidate} | Serve this rendering from now on. |
POST /assets/{id}/variants/{kind} | Make mobile, 1080p or 4k. |
GET /assets/{id}/raw | The served AVIF bytes, owner-authenticated - works for private photos. ?variant=source for the untouched original, ?candidate=<id> for one named rendering rather than whichever is being served - which is what lets two renderings of the same photograph be compared against each other. A named rendering is immutable, so it is cached outright. |
GET /tags | Your tag vocabulary. |
GET /changes?since=N | Your events after cursor N, oldest first: created deleted renamed described rotated visibility paired unpaired tagged. Keep next and hand it back. |
Upload example
curl -s https://heart.photos/api/v1/assets \ -H "Authorization: Bearer $TOKEN" \ -F [email protected] -F name="Sunset at the pier" -F quality=60
The MCP
The library speaks the Model Context Protocol, so
an AI agent handed a token can work with your photographs like any other product. Streamable
HTTP, stateless, at https://heart.photos/mcp; every request carries the same
Authorization header.
Tools: whoami, list_photos, get_photo,
upload_photo (fetches a public http(s) image URL server-side),
delete_photo, set_visibility, rename_photo,
describe_photo, pair_photos, set_tags,
check_hash. They are the API above wearing names an agent can pick from a list — the
two cannot drift apart, because the tools call the API.
Connecting (Claude Code / any MCP client)
{
"mcpServers": {
"heart-photos": {
"type": "http",
"url": "https://heart.photos/mcp",
"headers": { "Authorization": "Bearer heart_…" }
}
}
}
Or: claude mcp add --transport http heart-photos https://heart.photos/mcp
--header "Authorization: Bearer heart_…"
For product builders
The intended shape: your product signs users in with Google, exchanges the ID token for a
library token once per sign-in, and then talks v1 on the user's behalf. Identity is verified by
the library itself on every exchange — your product is never trusted about who someone is. Use
by-hash before uploading, consume /changes instead of re-listing, and
never key anything on email.