Cue
How it’s built · metal to AI

Under the Hood

The tech choices, architecture decisions, and workflows behind a full-stack app developed almost entirely with AI.

8
modules
4
languages
~314K
lines of code
73
DB migrations
63
tables
8
containers
6
AI features
0
Kubernetes
01Overview

The big picture

Cue is an event scheduling app that uses AI to help groups of friends coordinate plans through natural language. Someone proposes a few times, friends reply however they want — “Wednesday works but I’d prefer Thursday” — and Gemini figures out the best option.

Behind that simple idea sits a monorepo with eight modules, written in four languages, running on personal servers connected to the world through Cloudflare Tunnels. No Kubernetes. No GitHub Actions. No cloud functions. Just Docker containers, a load balancer, and a Fish shell script.

Around the scheduling core the surface has grown: a live group chat with optional AI participants, 1:1 direct messages, standalone topic channels, group decisions the whole party settles together with AI, Trips that gather events and decisions into one timed arc, a per-user library of photos and short videos that doubles as chat attachments and event covers, AI weather reports, AI commentary, push notifications, home-screen widgets, calendar sync, and one search platform that reaches all of it from a ⌘K palette. They all share the same backend, the same SSE channel, and the same deploy pipeline.

If you read Norwegian, there’s a companion piece about the personal journey of building Cue: the Cue story.

02Monorepo

Eight modules, one repo

Everything lives in a single monorepo. This was a deliberate choice — it lets the AI agent see the full picture when working across boundaries, and keeps documentation, deploy scripts, and configuration in one place.

cue-coreKotlin

The brain. All business logic, database access, AI integration, authentication, push notifications, media transcoding, and the SSE broadcaster. Spring Boot 4 on JDK 25 with virtual threads.

cue-front-endTypeScript

The web client. Next.js 16 with App Router, React 19 Server Components, Tailwind CSS 4, and a custom SSE hook for real-time updates. View layer only — no business logic.

cue-adminTypeScript

Admin dashboard for monitoring users, events, AI usage, and costs. Built with the same stack as the frontend, plus Recharts for analytics. Google OAuth only.

cue-iosSwift

Native iOS app built with SwiftUI. Distributed through the App Store. Push notifications, calendar sync, home-screen widgets, quick actions, and a full-screen photo-and-video viewer.

cue-androidKotlin

Native Android app built with Jetpack Compose and Material 3 — dynamic color, predictive back, Glance home-screen widgets. Distributed through Google Play. A pure API client that shares zero code with the Kotlin backend.

cue-macosSwift

Native macOS app built with SwiftUI (Swift 6). Lives in the menu bar and the Dock, signed and notarized, shipped outside the App Store through a Homebrew Cask and a direct DMG with Sparkle auto-updates.

cue-cliGo

Command-line client built with Cobra and Charmbracelet for beautiful terminal UIs. Distributed via Homebrew with GoReleaser. Covers events, responses, notifications, and live SSE watching.

cue-core-api-testKotlin

Integration test suite that runs against the deployed server. Verifies the API contract after every deploy — the safety net that catches regressions.

03Polyglot

Four languages

Each module uses the language that fits its domain best. No universal compromise — just the right tool for each job.

LanguageUsed inLines
KotlinBackend, Android app, tests~144,800
SwiftiOS & macOS apps~106,200
TypeScriptWeb, admin~51,200
GoCLI~11,300

On top of that: ~1,500 lines of SQL across 73 Flyway migrations, ~120 lines of Fish for deploy automation (a dev and a prod entrypoint over a shared lib), and a pile of YAML/TOML for Docker Compose and GoReleaser configuration. The codebase has more than tripled since the first version of this page — the original scheduler is now a small fraction of the surface; chat, DMs, channels, group decisions, Trips, the media library, and the iOS, Android and macOS apps account for most of the new lines.

04Traffic

How requests flow

Every request to Cue travels the same path: from the client, through a Cloudflare Tunnel, into an nginx load balancer, and finally to one of two backend instances.

Clientweb · iOS · CLICloudflare Tunnelno open ports, no public IPcue-lbnginx · :8095cue-core-1:8080cue-core-2:8080PostgreSQL:5432Valkey:6379Cloudflare R2media · EU
Every public request enters through a Cloudflare Tunnel — no open ports, no public IPs.

The nginx load balancer distributes traffic between two cue-core instances with automatic failover. If one goes down, the other picks up immediately. SSE connections get a 24-hour read timeout so they can stay alive for as long as the user has the app open. Media reads are 302-redirected straight from cue-core to a 15-minute presigned R2 URL, so the bytes never round-trip through the application server — and large video uploads go directly to R2, bypassing the load balancer’s body limit entirely.

Browse the API referencetwelve resource groups, real JSON, and the live SSE stream
05Containers

The full service map

In total, eight containers run in Docker Compose, plus one external store for media. Here’s the complete picture:

cue-core-18080Backend instance 1
cue-core-28080Backend instance 2
cue-lb8095nginx load balancer
cue-front-end3000Next.js web app
cue-admin3016Admin dashboard
postgres5432PostgreSQL 18
valkey6379Cache, SSE relay, rate limiter
cloudflaredCloudflare Tunnel
Cloudflare R2Media storage (EU, dev + prod buckets)
06Real-time

Real-time with Server-Sent Events

Cue needs to feel instant. When someone responds to an event, everyone else should see it immediately — on web, iOS, Android, macOS, and the CLI. Instead of WebSockets or polling, Cue uses Server-Sent Events (SSE), a simple, HTTP-native protocol for one-way real-time streaming.

How it works

The client opens a long-lived HTTP connection to /api/sse/stream. The server keeps that connection open and pushes events down whenever something happens. It’s beautifully simple.

Signal-based, not payload-based

This is a key design choice. SSE events in Cue are signals, not data payloads. When the backend sends an event-updated signal, it doesn’t include the event data. Instead, the client refetches the relevant data through the normal REST API. This keeps authorization logic centralized in one place and the SSE layer paper-thin.

Multi-instance broadcasting

With two backend instances behind a load balancer, a signal produced by instance 1 needs to reach clients connected to instance 2. Cue solves this with a Valkey pub/sub relay. When a service publishes a signal, it goes to Valkey first, and every backend instance picks it up and broadcasts to its connected clients.

afterCommit()EventServicepublishes a signalValkey pub/subfans out to every instancecue-core-1SseBroadcastercue-core-2SseBroadcasterits clientsits clients
A signal fans out through Valkey so it reaches clients on every instance.

Transactional safety

Signals are only sent after the database transaction commits. This is enforced through Spring’s TransactionSynchronization.afterCommit(). If a transaction rolls back, no signal is ever sent. No phantom updates. No race conditions.

Heartbeats and reconnection

A heartbeat is sent every 20 seconds to keep connections alive and detect dead ones. On the client side, if the connection drops, an exponential backoff kicks in — starting at 1 second and capping at 30. A 401 response stops the retry loop entirely and redirects to login instead of hammering the server.

One channel, many signal types

Everything realtime in Cue rides this same channel. The wire format is uniform — a named SSE event plus a tiny JSON body identifying what changed — and adding a new signal type is a one-line change in the broadcaster. The signal types, grouped by domain:

Eventscreated · updated · responded · declined · rejoined · finalized · canceled · reopened · invited · invitation-removed · organizer-changed · cover-updated · proposed-time-added / removed / updated
AIresolution-updated · ai-comment-updated · weather-report-updated · brief-updated · ai-participant-changed
Decisionsdecision-updated ▸ created · updated · option-added / removed · preference-updated · context-added / removed · invited · declined · rejoined · finalized · reopened · canceled · organizer-changed · participant-removed · recommendation-updated · insight-updated · ai-comment-updated · chat-message-added / edited · chat-reaction-added / removed · chat-read-updated · chat-typing · ai-participant-changed
Chatchat-message-added / edited · chat-reaction-added / removed · chat-read-updated · chat-typing
DMsdm-message-added / edited · dm-reaction-added / removed · dm-read-updated · dm-status-changed · dm-typing
Channelschannel-message-added / edited · channel-reaction-added / removed · channel-read-updated · channel-typing · channel-member-changed · channel-settings-changed · channel-request-changed · channel-deleted
Tripstrip-updated ▸ created · updated · deleted · member-added / removed · member-role-changed · note-added / updated / removed · document-added / removed · media-added / removed · cover-updated · brief-updated · ai-comment-updated · the chat-* set
Containerslinks-changed — dual-fanned to the pillar's envelope and the container's
Mediamedia-ready · media-failed
Othernotification
07Intelligence

AI in six places

AI isn’t just a development tool for Cue — it’s embedded in the product itself. The backend talks to Gemini through Google’s Vertex AI. Where the original Cue had a single AI feature (the suggested-time resolver), the product now uses Gemini in six distinct places.

AI Suggested Time (Resolution)

Always-on for events with 2+ proposed times. Reads every response and writes a structured JSON score per slot plus a human-readable rationale in the majority language of the responses. Re-runs whenever a response changes.

AI Comments

Opt-in per event, decision or trip. The organizer picks a persona — Funny or Serious — and the AI posts a short, persona-flavored comment that re-runs as the conversation evolves. A trip takes Funny only: its serious voice is the Trip Brief.

Weather Reports

Opt-in per event. yr.no Locationforecast supplies the raw forecast for the event's coordinates and proposed times (with a 4-decimal-rounded coordinate cache and ETag-aware 304 handling); Gemini condenses it into a few practical sentences.

Live chat participants

Opt-in per chat — event chats and decision chats alike. Any invitee can add an AI participant with a free-form personality and one of three interaction modes (proactive, mention_only, event_driven). An LLM-driven “should I respond?” gate biases toward silence so they don't flood the room.

Decision recommendations & Option Insights

Cue's decisions pillar. A group proposes options and states preferences in plain text; Gemini recommends the best pick with per-option fit scores and a written rationale, re-running as options and preferences change. Option Insights adds a grounded pros-and-cons brief per option, sourced from a live Google Search.

Travel AI (the Trip Brief)

Opt-in per trip. Gemini assembles everything the group gave the trip — notes, documents, the linked events and decisions, who's coming — into one markdown brief written in the voice of wherever the trip sits on its arc: a warm planner ahead, a daily companion while it's live, a recap once it's behind. It regenerates on meaningful change, and every morning of a live trip.

Every one of them runs on Gemini 3.7 Flash — the suggested time, the AI comment, the weather read, the Trip Brief, the whole decision brain (recommendation, Option Insights, and decision commentary), the live chat participants in event and decision chats alike, and the option brainstormer that seeds a new decision. The analytical work and the conversational work stay on separate config keys, so the latency-critical half can be split onto its own model whenever that trade is worth making.

All of them share the same pattern: a service publishes a Spring ApplicationEvent after the database transaction commits, an @Async listener calls Gemini, and the result is broadcast back to every connected client over SSE. Independent entities, independent listeners, independent SSE signal types, independent rate-limit checks — one feature can never block another.

User respondswrite saved instantlyEventServicetransaction commitsApplicationEvent@Async listenerResolutionServicebuilds the promptGemini · Vertex AI3.7 FlashValkey SSE relayresolution-updatedevery clientweb · iOS · CLI
The shared pattern, resolution shown — swap in AiCommentService, WeatherReportService, or AiChatService and the rest is identical.

The user sees their write saved instantly; moments later, the AI response arrives over SSE without a page refresh. Every AI call is logged with token counts and cost. The admin dashboard tracks daily, weekly, and monthly Gemini spend against configurable caps — one user can’t torch the month’s budget.

08Conversations

Five kinds of conversation

Chat started as an organizer-toggled feature on individual events. It grew into a full messaging surface with five kinds of conversation: the live chat bolted onto an event, a 1:1 direct message, a standalone topic channel, the chat a decision grows, and a trip’s own room. The hub at /chat lists all five together, sorted by activity, with unread badges per conversation and a total badge in the top nav.

The hub is two-pane — conversation rail on the left, the selected thread on the right. On mobile it collapses into list↔detail navigation. Opaque conversation IDs (event-{id}, dm-{otherUserId}, channel-{id}, decision-{id}, trip-{id}) keep routing forward-compatible — the frontend never assumes their shape beyond passing them through to the API, and a client that doesn’t recognize a kind simply drops that row.

What every conversation has

Composer

Auto-resizing textarea (1 row resting, grows to ~6 before scrolling). Enter sends, Shift+Enter inserts a newline, and Enter is ignored while IME composition is active so CJK input doesn't accidentally fire. Optimistic insert on send.

Per-message reactions

Long-press surfaces a horizontal emoji row with a frosted backdrop. Reactions are unique per (message, user, emoji); a reactions sheet shows who reacted with what.

Read receipts

Per-user avatar markers in group chats, a single “Read” line in DMs. Stored as a last-seen message ID per (conversation, user); the SSE channel pushes updates as people read.

Typing indicators

Throttled typing pings over SSE with a fade in/out. Stops on send or after a short idle. No persistence — purely ephemeral.

Message editing

Edit within 10 minutes of sending, tracked in-row with edited_at / edit_count columns. The original send time stays, and edited messages render with a subtle “(edited)” marker.

Media attachments

Attach a photo or video — pick from the user's Cue library or upload fresh. The media is attached by ID, never re-uploaded per message; the same image used twice is one row, two usages. Videos render as a poster still with a play overlay and a duration badge.

Per-user chat colors

Each user picks a display-name color; messages render with it consistently across every chat. Color changes retro-stamp to historical messages so threads stay readable when someone re-themes.

Media stacks

When someone fires off several photos or videos in a row, the client collapses them into a stack — two or three render as a tidy collage, four or more as a fanned pile with a “+N” badge. This is purely presentational: each message still carries at most one attachment, and the stack just groups consecutive media from the same sender so the thread doesn’t become a wall of thumbnails.

Channels: a room of its own

Channels are standalone group chats — #oslo, #fotball — that aren’t tied to any event. They reuse everything above (composer, reactions, read cursors, typing, edits, media) and add the machinery a public room needs: membership, roles, and visibility.

  • Three visibility tierspublic (anyone can find it in the directory and join), private (listed, but joining takes an approved request), and secret (invite-only, hidden from the directory).
  • Roles — owner, admin, member. A partial unique index enforces exactly one owner per channel, so a room can never end up adminless or staging a quiet coup.
  • Join requests & blocks — private channels gate entry behind a request an admin approves; removing someone records a block in a side table that keeps them out of the hot fan-out path entirely, rather than smearing a “was-removed” check across every query.
  • Human-only — unlike event and decision chats, channels don’t take AI participants.

AI participants (event & decision chats)

Any invitee can drop an AI into an event or decision chat (up to five per room). The user picks a name (or accepts a randomized one), a free-form personality string, and an interaction mode:

  • proactive — reads every new message and decides whether to respond.
  • mention_only — speaks only when its name is mentioned.
  • event_driven — nudges around event state changes (someone responds, organizer finalizes, etc.).

An @-mention forces the named AI to weigh in regardless of mode. AIs added by a user are removable by that user or the organizer; removed AIs disappear from the participants strip and stop responding, but their past messages stay, attributed to the now-removed AI by name so history reads correctly.

Data model & limits

Every kind runs on its own parallel family of tables, all cut to the same shape: messages, a read cursor, reactions. chat_messages, chat_read_state, chat_message_reactions for event chats; their dm_* twins plus a dm_threads row for DMs; a seven-table channel_* family for channels; and decision_* and trip_* sets for the two newest rooms. Posting is capped by an in-memory sliding window — a ConcurrentHashMap of timestamp deques per (conversation, user), pruned on a timer. (The Valkey-Lua limiter described later guards HTTP-level limits — OTPs, per-user writes, uploads, IP ceilings — not message posting.)

Message length2,000 characters
Edit window10 minutes
Post rate30 messages / minute per user
AI participants5 per event or decision
AI reply cooldown30 seconds
AI budget60 messages / 24h per room
09Consensus

Decisions: the group picks what

Scheduling settles when; the third pillar settles what — where to travel, which flat to rent, what to name the band. A decision holds a set of candidate options and one free-text preference per person (“somewhere warm, but Tromsø would be magical”), and Gemini turns that into a recommendation: a picked option, per-option fit scores, and a written rationale that re-runs whenever the options or preferences change. It’s the decision analog of the event resolver — recommendations is to a decision what resolutions is to an event.

Two AI extras opt in per decision. Option Insights gives each option a grounded pros-and-cons brief, sourced from a live Google Search — with the query set and source links kept alongside for transparency. An AI commentator — Funny or Serious — adds a running take on the deliberation. And a decision grows the same chat an event can: messages, reactions, read receipts, typing, and AI participants, reusing the event-chat machinery wholesale.

Under the hood it borrows every earlier pattern. Thirteen tables — decisions, decision_options, decision_participants, recommendations, option_insights, a decision_* chat family, plus invite links and email invites — mirror the scheduling, AI, and chat domains one-for-one; a single decision-updated SSE signal carries every change; and all of the decision AI paths meter against the same Gemini budget as everything else. An optional deadline gives the group a decide-by instant — purely informational, nothing auto-finalizes, but a nightly job nudges once as it approaches and once when it passes.

10Containers

Trips: a container for the whole arc

Events, chats and decisions each settle one question. A Trip settles none — it organizes them. It’s a container: a named, dated thing with its own roster (roles guide and traveler), its own chat, its own notes, documents and album, that gathers events and decisions into one timeline. Dates are optional and may sit in the past, so the same entity covers a trip you’re planning for next month and one you took years ago.

That arc is the whole design. Ahead of the dates the page is a countdown over a date-ordered itinerary of the linked pillars; during, it’s a “happening now” state; behind, it’s a memory timeline and a photo album. One continuous entity — nothing to archive or re-create when the trip ends. A memory-only Trip with zero linked pillars is a first-class citizen, not a degenerate case.

Links, not ownership

A pillar joins a container through a soft link in one table — container_links, with typed-nullable FKs and two CHECKs enforcing exactly one pillar (event xor decision) against exactly one container (trip xor channel). Three rules make it safe:

  • Link ≠ access — being in a trip never grants access to a linked event. A non-invitee sees an inert discoverable card and joins through the event’s own invite flow. Authorization stays per-resource.
  • Link ≠ ownership — unlinking is non-destructive, deleting a container drops its link rows and never the pillar, and zero links is just a standalone pillar.
  • Per-viewer chips — the “part of” chips on a pillar list only containers the viewer can actually see, filtered in the service layer, so a secret channel’s name never leaks through a linked event.

The API is deliberately kind-agnostic: POST /api/events/{id}/links takes a containerKind string rather than naming trips. That one decision is why channels became the second container kind — growing the power to organize pillars, spawn trips from their roster, and render the same itinerary — without reshaping a single shipped contract.

Thirteen tables, every pattern reused

trips and trip_members, the three invite paths (direct, link, email) on the same tables Events uses, a trip_* chat family on the shared chat core, trip_notes / trip_documents / trip_media riding the existing photo library, and the two AI companions in trip_briefs and trip_ai_comments. Trip chat is human-only — a trip’s AI voice lives on the trip page, not in the room.

11Media

Media: one library, photos, video & files

Every user has a single library; the same item — a photo, a short video or an uploaded document — can be attached to chat or DM messages and used as an event cover image. A photo_usages table tracks every place each item lives, so deleting it cleanly detaches it from every message and cover that referenced it. All three share one table; a media_kind column (image, video or document) is the only thing that distinguishes them at the row level. The grid endpoints return what a thumbnail can draw; a separate files surface returns the whole library, documents included.

Storage isn’t on the application server — media lives in Cloudflare R2 buckets (cue-photos-dev and cue-photos-prod). Cue already terminates all public traffic through Cloudflare, so R2 was a natural fit: zero egress fees, S3-compatible API, and the buckets stay private. Every read is a 302 redirect to a 15-minute presigned URL, minted only after cue-core verifies the requester’s session. R2 honors HTTP Range requests, so video seeking and scrubbing work natively — no separate streaming server, no HLS.

Clientcue-coreR2GET /variants/{label}.{fmt}auth check · mint 15-min URL302 · presigned URLGET presigned URLbytes · zero egress · Range OK
Auth happens at cue-core; the bytes flow straight from R2 to the client.

The image pipeline

The cue-core container ships with libvips (the vips CLI from libvips-tools plus libheif-plugin-aomenc for AVIF encode). Compared to Java’s ImageIO, libvips is 10–20× faster and uses 10× less RAM across HEIC, AVIF, and WebP. It runs as a CLI subprocess per upload — simple, isolated, and upgradeable without touching the JVM.

1
Magic-byte sniffValidate format from the bytes themselves, not Content-Type. Accept HEIC, JPEG, PNG, WebP, AVIF, GIF; reject TIFF, BMP, SVG, RAW.
2
SHA-256 dedupHash the raw upload bytes. Re-uploads return the existing photo (HTTP 200); soft-deleted hits auto-resurrect.
3
Read EXIF, keep metadataDateTimeOriginal is parsed to its own column; the EXIF/XMP block (GPS, camera, timestamps) rides through the re-encode onto every variant (keep=all). Orientation is baked into pixels and normalized.
4
Generate variantsthumb (256w), preview (1024w), display (2048w), full (≤4096w). Each variant in both AVIF (primary) and WebP (fallback) — no JPEG fallback since every supported browser decodes WebP.
5
Animated GIFs kept wholeNo transcode, no frame loss. Original GIF stored as-is; one static WebP thumbnail is generated for grids and previews.
6
BlurHash placeholderA 28-character string is computed from a 64w intermediate and stored on the photo row. Clients render it instantly as a colored placeholder while the variant fetches.
7
Upload to R2Each variant uploads to {owner_id}/{photo_id}/{label}.{format}. Failed uploads roll back the DB transaction; orphan objects are swept by a nightly cleanup job.

The video pipeline

Video can’t ride the same synchronous path as a photo — a clip is too big to push through the load balancer, and transcoding takes seconds, not milliseconds. So video uses a three-step, asynchronous flow. The client asks for an upload ticket, PUTs the raw bytes straight to R2 (sidestepping the 55 MB body cap at the nginx load balancer — and Cloudflare’s 100 MB Tunnel ceiling — to let clips up to 200 MB through), then calls finalize. That commits a processing row and fires an event; a background worker, bounded to two concurrent transcodes, does the heavy lifting with ffmpeg.

Clientcue-coreR2POST /photos/video-ticketreserve quota · presign PUTPUT raw bytes · ≤ 200 MBbypasses the LB body capPOST /finalizestatus: processingasync worker · ≤ 2 concurrentffprobe · reject > 60 sposter → image ladder + BlurHashffmpeg → 720p (+1080p) · +faststartupload renditionsSSE media-readyposter flips to player
Video uploads go directly to R2; an async worker transcodes and signals media-ready over SSE.

The poster frame is grabbed at min(1s, duration/2) (so it skips a black intro), then fed through the exact same image ladder — which means a video shows a real still plus a BlurHash placeholder before it ever plays. ffmpeg emits a baseline 720p video_720 rendition always, and a video_1080 only if the source is at least 1080p (never upscaled). Both are H.264/AAC MP4 with the moov atom moved to the front (+faststart) for instant playback, and source metadata copied through (-map_metadata 0), matching the EXIF retention on the photo path. While a clip is transcoding the client shows its BlurHash and a spinner, then flips to the player on the media-ready signal (with a poll as fallback).

Photos vs. video at a glance

PhotosVideo
Uploadmultipart to cue-coreticket → direct R2 PUT → finalize
Processingsynchronousasync worker (≤2 at a time)
Limit50 MB200 MB · ≤ 60 s
Renditionsthumb / preview / display / full × AVIF + WebPposter ladder + video_720 / video_1080 MP4
On failuresoft-delete (30-day recovery)hard-delete (no recovery)

Where-used tracking

Every place a media item appears writes a photo_usages row when the link is created and deletes it when the link is removed. The library page reads this table to render chips on each item (“Event chat: Saturday hike”, “DM: Anna”, “Cover: BBQ at the lake”, “Trip album: Lisbon”). It’s also the source of truth for delete UX: if an item has live usages, soft-delete detaches them server-side and the detached messages render as “(image removed)” client-side.

13Identity

Authentication

Cue supports three sign-in methods: Google, Apple, and email OTP. The frontend handles OAuth flows via NextAuth v5, while the backend validates JWT tokens from all three providers on every request.

Google

Offline mode with refresh tokens. Sessions auto-renew silently every 10 minutes and whenever the tab regains focus, so the access token is fresh before a request needs it.

Apple

Apple identity tokens expire in about ten minutes and can't be silently re-issued on iOS, so the app trades one in for a Cue session immediately after sign-in and never asks for Face ID again just to stay signed in.

Email

6-digit OTP sent via Brevo SMTP. Rate-limited to 3 codes per 10 minutes per address. Backend issues a 30-day JWT after verification.

Users can link multiple providers to one account through verified email matching — no account fragmentation, no “which provider did I use?” moments.

Cue’s own sessions

On top of the three providers sits a first-party session: a 1-hour access token plus a 90-day opaque refresh token. There is deliberately one mint endpoint rather than one per provider — POST /api/auth/session takes whichever bearer the caller already holds and lets the existing decoder verify it, so nothing about Google or Apple is re-implemented and a future provider is covered for free.

Each refresh rotates: the presented token is revoked and a fresh pair issued. Every rotation descended from one sign-in shares a family ID, so if a stolen token is ever replayed after it was consumed, the whole chain dies in a single update. Only a SHA-256 digest of the refresh token is stored — there is no low-entropy secret to brute-force. A one-minute grace window covers the honest case where a client rotated but never received the response.

14Allowance

User levels: trust, quietly

Two things about Cue cost real money as it grows: stored bytes and the fan-out of created entities — every event, decision and trip drags invites, notifications and AI calls behind it. Rate limits protect the service from bursts; they say nothing about a patient uploader who fills a bucket one photo at a time. So every user carries an effective level that gives them absolute room on those two dimensions.

Room grows with tenure automatically. A level is computed, never stored: catalog rows carry a threshold in days since the account was created, and the earned level is simply the highest rung the account’s age clears. No promotion job, no drift, nothing to get out of sync. Admins can also pin a user to a level, which wins even when it sits lower than earned — so generosity and abuse control are the same lever pointed in two directions.

LevelReached byStorageCreations / 30d
newday one256 MiB25
regular30 days1 GiB50
established365 days3 GiB100
Cue Plusgranted10 GiB150
Cue Progranted25 GiB300
Cue Maxgranted50 GiB600

The ladder is data, not a Kotlin enum — a deliberate exception to the wire-enum rule everywhere else, because enums are for closed sets and this one is open by design. Adding a tier or retuning a number is an UPDATE, never a contract change shipped clients have to be checked against. Levels cross the wire as an opaque slug plus server-provided copy, and the allowances ride as an array, so a third dimension would light up as another meter on apps already in the App Store.

Storage: the number, not the mechanism

The photo upload path already took a row lock for its quota check. The level system supplies the number and leaves the lock, the comparison and the 413 response exactly as they were — and the per-user cache is rewritten inside that same lock, which is why a tenure promotion needs no job at all.

Creations: an append-only ledger

Events, decisions and trips share one rolling 30-day budget, counted from a ledger rather than from live rows — counting live rows is trivially gamed by create, fan out, delete, repeat. Deleting doesn't return the slot; the window rolling does. Over budget is a 409 carrying numbers and the date room reopens, so every client composes the same sentence once, at its API seam.

AI, bounded sideways

There is no per-user AI budget and no per-user AI plumbing. Cue's AI hangs off entities, so capping how many entities you can create caps the AI you can fan out. The live chat rooms carry their own per-entity daily budgets on top.

Fail open, never dark

If the resolver can't answer — a missing catalog row, an empty table — the request proceeds at the most generous earnable rung and the failure is logged loudly. A quota bug must degrade to generosity, not to someone locked out of their own photos.

The whole thing is quiet. A user sees their level and room in exactly two places — their own profile, and the admin dashboard. Never in a public profile, a roster, a member list, or any payload another person can read; the level endpoint is self-scoped, which makes leaking structurally hard rather than merely discouraged. Hitting a limit is private too: the acting user gets a warm explanation with numbers and a date, and everyone else sees nothing. And shrinking room never destroys data — the allowance is floored at what a user has already stored, so a downgrade means zero room, never negative.

15Schema

Database design

The database is PostgreSQL 18 managed exclusively through Flyway SQL migrations. Hibernate runs in validate mode — it checks that entities match the schema but never touches the DDL. Schema changes go through migration files, reviewed and intentional.

Some notable choices: enum-shaped fields use VARCHAR with CHECK constraints and a custom JPA AttributeConverter on the Kotlin side (no @Enumerated(EnumType.STRING)) so the wire-format and DB-format share one fixed pattern and never drift. AI resolution scores, weather forecasts, and AI-comment metadata are stored as JSONB for flexible structure. Profile pictures live in a separate table to keep the users table lean. Message edits are tracked in-row with edited_at / edit_count columns rather than a history table. Search adds no tables of its own: every searchable row carries a generated tsvector column plus a pg_trgm index, so the index can never drift from the row it describes.

The schema has grown to 73 Flyway migrations across 63 tables, which group cleanly by domain:

Identityusers · linked_accounts · profile_pictures · device_tokens · auth_sessions
Schedulingevents · proposed_times · invitations · invite_links · pending_email_invites · notifications
AIresolutions · ai_comments · weather_reports · ai_participants · ai_usage_logs · ai_usage_limits
Event chatchat_messages · chat_read_state · chat_message_reactions
DMsdm_threads · dm_messages · dm_read_state · dm_message_reactions
Channelschannels · channel_members · channel_messages · channel_read_state · channel_message_reactions · channel_join_requests · channel_blocks
Mediaphotos · photo_variants · photo_usages
Decisionsdecisions · decision_options · decision_participants · recommendations · option_insights · decision_context_items · decision_ai_comments · decision_ai_participants · decision_messages · decision_message_reactions · decision_chat_read_state · decision_invite_links · decision_pending_email_invites
Tripstrips · trip_members · trip_invite_links · trip_pending_email_invites · trip_notes · trip_documents · trip_media · trip_messages · trip_message_reactions · trip_chat_read_state · trip_briefs · trip_ai_comments · container_links
User levelsuser_levels · user_level_grants · user_creations
Take the visual tourwatch the 63 tables assemble themselves, domain by domain
16Iron

Hosting: personal servers, no cloud

Cue deliberately avoids the big cloud providers. No AWS, no GCP compute, no Azure. Instead, it runs on physical and rented servers — a conscious choice to keep things simple, affordable, and European.

DevelopmentHomelab

An Ubuntu Server sitting under my desk at home. Accessible via SSH through a Cloudflare Zero Trust tunnel. The database runs directly on the host. It’s fast, it’s free, and I can hear it humming when a deploy is running.

ProductionHetzner

A Hetzner CAX ARM64 instance in Europe. Affordable, fast, and the data stays on European soil. PostgreSQL runs in Docker here, and deploys are zero-downtime with rolling container updates.

No Kubernetes, by choice

Kubernetes is incredible technology, but it’s designed for problems Cue doesn’t have. Two backend instances behind nginx, managed by Docker Compose, is all the orchestration needed. The entire infrastructure fits in a single compose file. If traffic ever outgrows this setup, scaling up is a matter of adding another instance and a line in the nginx config.

Cloudflare Tunnels

Both environments are exposed to the internet through Cloudflare Zero Trust tunnels. No open ports, no public IPs, no traditional reverse proxy chain. The cloudflared daemon runs as a Docker container and connects outbound to Cloudflare’s edge, which then routes traffic to the local services. It’s elegant and secure.

17Ship it

Deploy: from commit to running in production

Cue doesn’t use GitHub Actions or any CI/CD platform. Instead, the entire build and deploy pipeline is a Fish shell script called omni-deploy. It’s faster, simpler, and gives complete control.

The deploy pipeline:
1
Git syncStage all changes, commit with timestamp, push to GitHub
2
SSH to serverPull the latest code on the target machine
3
Build imagesBuild cue-core, cue-front-end, and cue-admin Docker images in parallel
4
Sync configCopy nginx.conf to the server (prod also syncs the compose file)
5
Restart containersBring up the services on the new images, then reload nginx so it re-resolves the recreated backends
6
Health checkPoll /actuator/health until the backend reports healthy
7
Verify/cue-deploy-verify runs the API test suite against the freshly deployed server

Steps 1–6 are the omni-deploy script itself; /cue-deploy-verify wraps it and adds the seventh, exercising the live API contract before the deploy is called done.

Zero-downtime in production

The production deploy script goes further. Instead of restarting everything at once, it updates containers one by one — waiting for each to pass its health check before moving to the next. Nginx is reloaded after each backend instance updates, so there’s always at least one healthy instance serving traffic. Users never see downtime.

Why not GitHub Actions?

Building locally on the server skips the overhead of cloning, caching, and transferring artifacts. The Fish script SSHes directly into the server, pulls the latest code (which is already mostly there from the previous deploy), and builds. The whole pipeline is a single readable script.

A few honest reasons, beyond the raw speed:

  • Faster on my own boxes — the code already lives on the server, so a build skips the clone, cache-warm, and artifact shuffle a fresh GitHub runner pays for every time.
  • More uptime — GitHub Actions has gone down on me more often than my own setup ever has; a deploy should never wait on someone else’s status page.
  • Nicer to write — I got tired of wrangling GitHub’s YAML, and I genuinely enjoy Fish more.
  • Fewer ties to one vendor — I’d rather not bake a hard dependency on GitHub into the deploy path; the pipeline runs anywhere I can SSH.
18Images

Docker build pipeline

Each module has a multi-stage Dockerfile optimized for fast rebuilds and minimal runtime images.

cue-core
eclipse-temurin:25-jdk-nobleeclipse-temurin:25-jre-noble + libvips + ffmpeg

Gradle compiles the Kotlin source into a fat JAR. The runtime image adds the vips CLI (libvips-tools) plus libheif-plugin-aomenc for AVIF encode, and ffmpeg/ffprobe for video transcode and poster extraction. No build tools, no source code, no shell tooling beyond what the media pipeline needs.

cue-front-end
node:24-alpinedistroless/nodejs24

npm ci installs deps, Next.js builds the standalone output, and the final image is Google's distroless — no shell, no package manager, minimal attack surface.

cue-admin
node:24-alpinedistroless/nodejs24

Same pattern as the frontend. Identical build pipeline, different app.

All three images build in parallel during deploys. Docker layer caching means that if only the backend code changed, the frontend images rebuild in seconds (nothing to do).

19Native apps

iOS, Android & macOS

The iOS app is a native SwiftUI application distributed through the App Store (currently version 1.30). This has a profound impact on the backend architecture: since users can’t be force-updated, every API endpoint the iOS app touches is treated as a stable public API.

Adding an optional response field? Safe. Removing a field or renaming an endpoint? Forbidden without a migration plan. This discipline keeps the system honest.

The iOS surface matches the web for everything that ships: events, group decisions with their AI recommendation and Option Insights, Trips with their itinerary, brief and album, the chat hub — reactions, typing indicators, read receipts, message edits, AI participants — 1:1 DMs, standalone channels, the media library, and event cover images. The Codable models in cue-ios/Cue/Models/ mirror the cue-core wire format; every new field lands as optional so older shipped versions decode without error — which is exactly why video shipped as plain optional fields (mediaKind, durationMs, videoUrl) rather than new required ones.

The media viewer

The most polished corner of the app is the full-screen media viewer. Photos pinch-to-zoom and pan, and pull-down-to-dismiss fades the backdrop as you drag. Videos play inline with AVKit, handed the same presigned R2 URL the web uses (the app reads the 302 target and cancels the body fetch so no bearer token ever crosses to R2). A floating glass chrome — close button and counter — toggles on tap, and adjacent items are prefetched so paging is instant. In chat, runs of media collapse into stacks with play overlays and duration badges; tapping one opens the pager at the right position.

The app supports push notifications through Apple Push Notification service (APNs), calendar sync for finalized events, home-screen widgets in three sizes, and quick actions from the home screen. Device tokens are registered on login and stale tokens are automatically cleaned up when APNs reports them as invalid. There’s a separate CueWidget target for the home-screen widgets, version-pinned to the main app so they ship together.

Universal Links tie the web and the app together: tapping a /events/<id> link on iOS opens the event directly in the native app if installed, and falls back to the web view otherwise. This is powered by an apple-app-site-association file served by the Next.js frontend with separate app IDs for dev and prod builds.

The macOS app

The Mac has its own SwiftUI app (Swift 6), built for macOS Tahoe. It lives in the menu bar and the Dock, so the next get-together is always a click away. Unlike iOS, it ships outside the App Store — signed, notarized, and delivered through a Homebrew Cask and a direct DMG download, with Sparkle handling silent in-app updates. It speaks the same cue-core wire format and SSE stream as every other client, so chat, channels, DMs, and the media library all worked the day it shipped. A dedicated /deploy-macos skill builds, signs, notarizes, and publishes a release — DMG, GitHub Release, Homebrew cask, and Sparkle appcast — in one pass.

The Android app

The newest native client is cue-android: Kotlin and Jetpack Compose, Material 3 with dynamic color, shipped through Google Play. Like iOS it’s a store client, so it consumes the same frozen-additive API contract — and it’s a pure API client, sharing zero code with the Kotlin backend; every model is decoded defensively from the wire. Push arrives through FCM (the backend routes each device token to APNs or FCM by platform), home-screen widgets are drawn with Glance, and the parity-locked domain logic — the trip arc engine, chat grouping, read-receipt pinning — is a verbatim port held to the same golden tests the web and iOS versions answer to. A /release-android skill builds the signed bundle and publishes it to a Google Play track through the Play Developer API.

20Terminal

The Go CLI

The CLI was the last module added, and it’s a great example of how AI accelerates development. With the full project context already established — API contracts, auth flows, SSE protocol — Claude Code generated the entire CLI from a single prompt: “Build a CLI for the app in Go, look at the other modules for functionality.”

It covers events, responses, notifications, and user profile from the terminal, plus a cue watch command that streams live SSE updates. It’s built with Cobra for command structure and the Charmbracelet suite for beautiful terminal UIs, and emits clean JSON when piped. Authentication tokens are stored securely in the system keyring. Distribution happens through GoReleaser, which builds binaries for macOS and Linux (both AMD64 and ARM64), generates shell completions, creates a GitHub release, and updates the Homebrew formula — all from a single make release.

21Workflow

Built with Claude Code

Perhaps the most interesting part of Cue’s architecture is how it was built. The entire project was developed using Claude Code — Anthropic’s CLI tool for agentic software development. Claude doesn’t just write code; it SSHes into servers, runs deploys, verifies health checks, and runs test suites.

CLAUDE.md: the project brain

At the root of the repo sits a CLAUDE.md file that acts as persistent instructions for Claude. It carries the non-negotiables — the iOS API stability contract, Kotlin-only backend, Flyway-only schema changes, the wire-format enum pattern — alongside a navigation map that points the agent to the right deeper doc for whichever component or area is in play. Every session starts by reading this file. Thin AGENTS.md and GEMINI.md pointer files at the same level mean gemini-cli and Antigravity sessions land on the same instructions.

doc/: the deeper map

Beyond CLAUDE.md, the doc/ directory holds the long-form material. doc/components/ has one file per component (cue-core, cue-front-end, cue-admin, cue-cli, cue-ios, cue-android, cue-macos, cue-core-api-test, cue-static-users) covering its layout, build commands, and the rules specific to it. doc/architecture/ covers cross-cutting designs — the API surface, SSE, the database schema, infrastructure, each chat kind, decisions, containers, the advanced event features, the media system, search, and user levels. CLAUDE.md routes to whichever doc is relevant rather than restating their contents.

CLAUDE.md was originally a flat brain-dump. It’s now structured as a navigation hub: short non-negotiables at the top, then a “working on X? read Y” routing table that points the agent to the right deeper doc. Less context per session, more accuracy per task.

Skills: complex workflows as commands

The real power comes from custom skills — parameterized workflows defined in .claude/skills/. Each skill is a markdown file that describes a multi-step operation Claude can execute as a slash command. Twenty-six of them now cover the whole lifecycle:

/omni-deployFast local-build dev deploy: rsync, build, restart, health check
/cue-deploy-verifyCommit, push, deploy to dev, run API tests, verify container health
/deploy-prodZero-downtime production deploy with rolling updates
/api-testRun the integration test suite against the deployed server
/deploy-iosBuild and install the debug app to a connected iPhone
/ios-releaseClose release notes (What's New + Promotional Text) and bump the App Store version
/deploy-androidBuild and install the Android app to a connected device or the headless emulator
/release-androidBuild the signed release bundle and publish it to a Google Play track via the Play Developer API
/deploy-cliBuild, test, and release the Go CLI via GoReleaser + Homebrew
/deploy-macosBuild, sign, notarize, and publish a macOS release — DMG, GitHub Release, Homebrew cask, and Sparkle appcast
/dep-upgrade-coreAudit and safely upgrade Gradle/Kotlin/Spring deps — verifies compile, tests, and boot
/dep-upgrade-webAudit and safely upgrade npm deps in both web apps — verifies builds and dev-server boot
/dep-upgrade-cliAudit and safely upgrade Go module deps in the CLI — verifies build, vet, smoke test, and cross-compile
/dep-upgrade-iosAudit and safely upgrade the Swift Package Manager deps in the iOS app — verifies simulator build and unit tests
/sync-public-docsVerify the public /architecture, /database, and /api pages against the source and correct any drift
/refresh-docsVerify the internal doc/ design docs against the source — fix stale claims, cut phase/roadmap narration, tighten prose
/refresh-api-testRefresh the live-API test suite — close coverage gaps, strengthen weak asserts, cut redundancy, then verify green
/reset-dev-dataSeed the dev database with realistic test data covering every Cue feature
/clear-dev-dataWipe all user data from the dev database for a clean slate
/static-usersCheck or reset the static dev sign-in accounts with fixed login codes
/tagCreate and push a semver git tag
/verify-webExercise a web change end-to-end on the deployed dev site in headless Chromium — real login, layout measurements, pixel checks
/jinshanLight-touch in-place polish on the current session's diff — fix what's plainly wrong, nothing more
/wufengEnd-of-session review-and-fix pass — confirm delivery, check against CLAUDE.md, exercise the change, fix flaws in place
/fuziStage, commit, and push all pending work in one pass — handles untracked files and modifications
/lantuDraft the one-shot bootstrap prompt a later session runs to scaffold a whole phased project

A typical development session might look like: write a feature, run /cue-deploy-verify to deploy and test it, iterate on feedback, then /deploy-prod when it’s ready. The entire cycle — from code change to verified production deploy — happens without leaving the terminal.

Memory: learning across sessions

Claude Code has a persistent memory system that carries knowledge between conversations. Feedback about preferred approaches, project context, and workflow preferences are stored and recalled automatically. The agent gets better at working with this specific project over time.

22Decisions

Interesting tech choices

Virtual threads (JDK 25)

Spring Boot 4 with virtual threads means each request gets its own lightweight thread. No reactive programming complexity, no callback hell — just straightforward blocking code that scales.

Valkey for pub/sub and rate limiting

Valkey handles three jobs: the SSE pub/sub relay between backend instances, atomic sliding-window rate limiting for HTTP requests (via a small Lua script over a sorted set), and general caching. Fast, in-memory, and a few dozen lines of Kotlin.

Cloudflare R2 for media

Cue already terminates traffic at Cloudflare; R2 means zero egress, S3-compatible API, and private buckets gated by 15-minute presigned URLs. No third cloud account, no separate billing surface, and bytes never round-trip through cue-core. Range requests make video seeking work without a streaming server.

Video without a media server

ffmpeg transcodes uploads to H.264/AAC MP4 (720p, plus 1080p when the source allows) with +faststart, while the poster frame rides the same image ladder as a photo. A background worker bounded to two concurrent jobs does the work; clips download progressively via R2 Range requests — no HLS, no transcoding service.

libvips over ImageIO

libvips (the vips CLI) decodes and encodes HEIC, AVIF, and WebP 10–20× faster than Java's ImageIO with 10× less memory. Shells out per upload — isolated, killable, and upgradeable without touching the JVM.

AVIF primary, WebP fallback

Every photo variant is encoded twice. AVIF for browsers that decode it (Chrome, Safari 16+, Firefox), WebP for everywhere else. No JPEG fallback — every supported browser handles WebP, and variant sizes drop 30–50% vs. JPEG at the same quality.

BlurHash placeholders

A 28-character string per item encodes a low-frequency preview. Clients render it instantly as colored gradient blocks while the real variant fetches over the network — even videos show their poster's BlurHash before they play. No more empty grey rectangles.

One model, two config keys

Gemini 3.7 Flash powers everything — resolution, AI comments, weather, the decision brain, and the live chat participants. The analytical and conversational paths still read separate model strings, so either can move without touching the other. Same Vertex AI client throughout.

Search in Postgres, not a search engine

Generated tsvector columns plus pg_trgm word similarity cover word matching and typos in the database Cue already runs. No Elasticsearch to operate, and no denormalized index to keep in sync — a generated column cannot drift from its own row. One request is one SQL statement, each kind a UNION ALL arm carrying its own membership predicate.

Distroless containers

The Next.js frontends run in Google's distroless images — no shell, no package manager, nothing except Node.js and the app. Smaller images, smaller attack surface.

Flyway over Hibernate DDL

Hibernate can generate schemas, but in production you want explicit, reviewed SQL migrations. Hibernate validates that entities match the schema — it never modifies it.

Signal-based SSE

SSE events carry signal types, not data. Clients refetch through REST, keeping authorization logic in one place. The SSE layer stays trivially simple, even with chat, DMs, reactions, typing, and media events all riding the same channel.

Rate limiting in two tiers

HTTP-level limits — OTPs, per-user writes, uploads, IP ceilings — run as a single atomic Lua script over a Valkey sorted set. Chat and DM posting use a separate in-memory ConcurrentHashMap window, pruned on a timer, because it's per-conversation and doesn't need to survive a restart.

Wire-format enums with one fixed pattern

Every enum in the public HTTP/JSON contract follows one pattern: @get:JsonValue val wire, case-insensitive @JsonCreator fromWire, and a custom AttributeConverter for DB persistence. Wires are lowercase / snake_case and never get renamed once shipped. iOS Codable types can be typed enums without fear of decode failures.

Transactional events for async work

AI resolution, AI comments, weather reports, chat AI nudges, video transcode, photo cleanup — all fire via Spring ApplicationEvents after the database transaction commits. Background work only runs once the write is durable — no phantom jobs triggered by rolled-back transactions.

No GitHub Actions

Building directly on the server is faster than any CI/CD platform. No artifact transfer, no cache warming, no YAML debugging. A Fish shell script does everything.

Fish shell for automation

Fish has cleaner syntax than Bash, better error handling, and readable scripts. The entire dev and prod deploy pipelines fit in ~120 lines of Fish between them, over a shared lib.

The philosophy

Use the simplest thing that works. Build on personal servers, not cloud abstractions. Let the AI agent handle the toil. Write code in the language that fits best, not the one you’re most comfortable with. And when eight Docker containers behind nginx can do the job — you don’t need Kubernetes.

Built by one person and a very capable AI.