WorkU1PHOTO

Photo Portfolio Platform

A Telegram bot that ships websites.

Status: Live, by invitation · v1

Role: Solo build · 2026

The home page of scalusic.photography, a photographer’s site running on the platform: a full-width photo of posts casting striped shadows, a Croatian headline and an EN/HR language switchThe portfolio page of scalusic.photography, a photographer’s site running on the platform, with category filters above a grid of photos
scalusic.photography on a phone, a photographer’s site running on the platform, showing its home page with the language switch and menu
On screen: scalusic.photography, a photographer’s site running on the platform

Key specifications

U1 · PHOTO · Rev 2026.10

Role
Solo build
Year
2026
Status
Live, by invitation · v1
Live site
Demo on request
Repo
Private during v1
Capabilities
  • Back-end & data
  • Front-end
  • AI and LLM integration
  • Infra and delivery
Stack
  • Claude Haiku
  • agentic tool use
  • FastAPI
  • Psycopg 3
  • Alembic
  • Next.js 14
  • TypeScript
  • PostgreSQL 16
  • JSONB i18n
  • Cloudflare R2
  • Caddy + on-demand TLS
  • python-telegram-bot
  • Docker Compose
  • EN / HR

Signal path · 5 stages

How it works

Photo Portfolio Platform, from input to output

  1. Photographer: in Telegram
  2. Bot: Claude Haiku · tool use
  3. API: FastAPI · tenant-⁠scoped SQL · R2
  4. Website: Next.js
  5. Own domain: Caddy on-⁠demand TLS

What it does

Each photographer gets their own domain and a full website, and manages all of it by chatting with a Telegram bot: uploads, captions, layout, copy, even DNS, all in plain language. I built it to find out what changes when an LLM is the only write path to a real product.

A photographer adds the bot in Telegram and has a working website at a subdomain of their choice within minutes. They send photos one at a time or in batches; the bot uploads them, generates captions, picks the right gallery and updates the site. They can ask it to “swap the homepage hero”, “make the about page warmer”, “translate everything to Croatian” or “use my own domain studiomariana.hr”, all in the same chat.

There is no admin panel, by design. Every operation a photographer might ever want exists as a tool the model can call: uploading photos, setting captions, reordering galleries, changing the theme colour, binding a custom domain, adding new sections. The chat is the product, not a layer on top of one: the bot is the CMS.

How it’s built

Three services, one boundary

There are three services and one boundary worth pointing at: the bot writes, the website reads, and they never share a process. The bot is the only write path; the website is read-only output.

The bot runs the agentic loop with Claude Haiku and calls tools that hit the FastAPI service. FastAPI owns the database and R2. The Next.js site reads from FastAPI and is served by Caddy with on-demand TLS, which is what lets each photographer point any domain they own at the same IP and have a certificate provisioned for it within seconds.

The components

  • Bot (bot.py, on python-telegram-bot): takes text and photos from the photographer’s Telegram client and runs the Claude Haiku agentic loop, which writes alt text, categorises photos and translates copy.
  • API: a FastAPI service on Psycopg 3 and raw SQL, with pytest tests and Alembic migrations.
  • Database: PostgreSQL 16, with photographer_id on everything, and translations (English and Croatian) and theme settings in JSONB.
  • Storage: Cloudflare R2 (S3-compatible), with UUID keys and original, web and thumbnail versions of each photo.
  • Website: Next.js 14 and TypeScript on the App Router, built as standalone output and themed with CSS variables.
  • Edge: Caddy, issuing Let’s Encrypt certificates on demand and serving the site to visitors on any custom domain.

The hard parts

Tenant isolation in every query

Every photographer’s data is scoped behind a tenant-id filter on every read and every write. There’s no ORM, so the filter is visible at every call site and a tenant leak shows up in code review rather than at runtime. A tiny helper wraps the cursor to keep the ergonomics close to an ORM, without the “we’ll inject the filter for you, trust us” magic.

A leak in a multi-tenant query is a privacy incident, not a bug. The cure is to make the filter loud, not invisible.

Bring your own domain

Photographers don’t know what an A record is, and they don’t need to. The bot walks them through it in plain Croatian, and the flow goes like this:

  1. The photographer says “I bought studiomariana.hr, can we use it?”
  2. The bot replies “Great. Go into your registrar’s dashboard, find the record that looks like this, and paste this number.”
  3. The bot polls the domain every few seconds.
  4. The moment propagation completes, the bot says “Ready, I just turned it on.”

TLS issued on first request, gated

Caddy issues a Let’s Encrypt certificate the first time it sees traffic for a new domain, but only if the back end confirms, through a registration-check endpoint (/photographers/exists), that the domain belongs to a registered photographer. That gate is what stops random domains pointed at the IP from burning through Let’s Encrypt rate limits. Cheap, boring, very effective.

Status

A small group of photographers use it for their working portfolios. The website side is read-only HTML served through Caddy, with no JavaScript for content; for now, Next.js is mostly a build-time templating layer. At this scale, the cost per tenant is dominated by R2 storage and a few cents a month of Haiku tokens, well below what any hosted-CMS subscription would charge. The bot runs on real tokens, and demo access is by email.

On the roadmap:

  • a screenshot test suite that diffs the rendered site against the last known good version after every tool call, so the bot can roll itself back when it accidentally turns the whole homepage pink
  • an eval harness for the agentic loop itself

In numbers

admin panels, by design
0
from adding the bot to a working site
Minutes
to TLS on a photographer’s own domain
Seconds
of Haiku tokens per tenant a month
A few cents