KotlinPostgresValkeySwiftUINext.jsGemini
How it’s built · metal to AI

Under the Hood

The stack, the architecture decisions, and the workflows behind a full-stack app built almost entirely with AI.

10
modules
4
languages
~493K
lines of code
99
DB migrations
83
tables
8
containers
9
AI features
0
Kubernetes
01Overview

The big picture

Cue is a lightweight app for getting a group of friends to agree. Events settle when: someone proposes a few times, friends reply however they like — “Wednesday works, but I’d prefer Thursday” — and Gemini reads the replies and keeps proposing the best time. Decisions settle what: where to travel, what to name the band. Chats carry the conversation in between.

Behind that simple idea sits a monorepo of ten modules in four languages, running on personal servers that reach the internet through Cloudflare Tunnels. No Kubernetes. No GitHub Actions. No cloud functions. Just Docker containers, a load balancer, and a Fish script.

Around the three pillars sits everything a group needs: Trips that gather events and decisions into one timed arc, Cue Notes for markdown the group writes together live, standalone channels and 1:1 direct messages whose links open into cards, a per-user library of photos, short videos and files that doubles as chat attachments and event covers, AI weather reports and commentary, push notifications, home-screen widgets, calendar sync, and one search engine that reaches all of it from a ⌘K palette. Every piece shares one backend and one deploy pipeline, and everything but the live typing in a note rides one SSE channel.

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

02Monorepo

Ten modules, one repo

Everything lives in one monorepo. That was deliberate: the AI agent sees the whole system when a change crosses a boundary, and the documentation, deploy scripts, and configuration sit next to the code they describe.

Fig. 1 / One contract
HTTP/JSON · SSE · WebSocketwebNext.jsadminNext.jsiOSSwiftUINotesSwiftUImacOSSwiftUIAndroidComposeNotesComposeCLIGo · Cobraapi-testafter each deploycue-coreKotlin · all the business logicPostgreSQL · Valkey · R2 · GeminiKotlinSwiftTypeScriptGo
Eight clients and a test suite speak one stable contract; everything behind it belongs to cue-core.
cue-coreKotlin

The brain. All business logic, data access, AI integration, authentication, push notifications, media transcoding, the SSE broadcaster, and the WebSocket Cue Notes are written over. Spring Boot 4 on JDK 25 with virtual threads.

cue-front-endTypeScript

The web client. Next.js 16 with the App Router, React 19 Server Components, Tailwind CSS 4, one shared SSE connection for live updates, and a CodeMirror editor for Cue Notes. View layer only — no business logic.

cue-adminTypeScript

The admin dashboard: people and levels, every pillar from events to notes, a review queue for every upload, AI usage and costs, the database’s health and nightly backups, and the live system across both replicas. The same stack as the web client, plus Recharts for analytics. Google sign-in only.

cue-iosSwift

Native SwiftUI app on the App Store: push notifications, calendar sync, home-screen widgets, a Live Activity during trips, a share extension, and a full-screen photo-and-video viewer.

cue-notes-iosSwift

Cue Notes for iPhone, its own App Store app: SwiftUI and TextKit 2 over automerge-swift, offline-first with every note’s document on the device, plus a share extension and a pinned-note widget. It shares no code with any other Cue client.

cue-androidKotlin

Native Jetpack Compose app on Google Play, with Material 3 dynamic color, predictive back, and Glance home-screen widgets. A pure API client: it shares no code with the Kotlin backend.

cue-notes-androidKotlin

Cue Notes for Android, its own Google Play app: Jetpack Compose and the platform text editor over automerge-java, offline-first with every note’s document on the device, and built for phones and foldables alike, plus a share target, a pinned-note widget, and a Quick Settings tile.

cue-macosSwift

Native SwiftUI app (Swift 6) that lives in the menu bar and the Dock. Signed, notarized, and shipped outside the App Store as a Homebrew cask and a direct DMG, with Sparkle for updates, and Cue Notes built in as a section of its own.

cue-cliGo

A terminal client built with Cobra and Charmbracelet, covering events, decisions, trips, channels, chat, photos, search, and Cue Notes, with live updates from cue watch. Ships through Homebrew.

cue-core-api-testKotlin

An integration suite that runs against the deployed server and checks the API contract after every deploy — the safety net that catches a regression before a user does.

Alongside them sits one piece of tooling, cue-static-users: a small Kotlin CLI that keeps the fixed-code test accounts that agents and scripts sign in with.

03Polyglot

Four languages

Each module is written in the language that fits its domain. No universal compromise — the right tool for each job.

~493K linesfirst-party source, by language
  • Kotlin~228,800
    backend, Android apps, tests · 46%
  • Swift~170,500
    iOS, macOS & Cue Notes apps · 35%
  • TypeScript~76,100
    web, admin · 15%
  • Go~17,700
    CLI · 4%

On top of that: about 2,200 lines of SQL across 99 Flyway migrations, about 200 lines of Fish and 900 of bash for deploys, and a handful of YAML and TOML files for Docker Compose and GoReleaser. The original scheduler is now a small fraction of the codebase; chat, direct messages, channels, decisions, Trips, Cue Notes, the media library, and the five native apps account for most of it.

04Traffic

How requests flow

Every request comes in through one of two doors. Browsers load getcue.net, served by the Next.js front-end, which proxies its /api/* calls onward; the native apps and the CLI call api.getcue.net directly. Both hostnames arrive through the same Cloudflare Tunnel and meet at an nginx load balancer in front of two backend replicas.

Fig. 2 / Request flow
ONE HOST · COMPOSE/api/*/api/*Browsersweb app · adminApps · CLIiOS · Android · macOS · GoCloudflare edgeTLS · real client IPcloudflaredoutbound-only tunnelcue-front-endgetcue.net · Next.jscue-admindashboard · Next.jscue-lbapi.getcue.net · nginxcue-core-1Spring Boot 4 · JDK 25cue-core-2Spring Boot 4 · JDK 25postgresPostgreSQL 18valkeypub/sub · limitsR2Cloudflare · media
Every public request enters through a Cloudflare Tunnel: no open ports, no public IPs. Media bytes skip the host entirely.

nginx round-robins the two cue-core replicas and retries the other one when a request fails, so either can restart without anyone noticing. SSE connections get a 24-hour read timeout, so a stream stays open for as long as the app does, and the notes WebSocket gets a location of its own that carries the upgrade. Media never passes through the application server: reads are 302-redirected to a 15-minute presigned R2 URL, and large video uploads go directly to R2, bypassing the load balancer’s body limit entirely.

Browse the API reference fourteen resource groups, real JSON, the live SSE stream and the notes socket
05Containers

The full service map

Eight containers run under Docker Compose, plus one external store for media. Only the load balancer and the two web apps publish ports, and those bind to localhost for the tunnel alone — there is no other way in.

ContainerPort
cloudflared
Cloudflare Tunnel, outbound only
—
cue-lb
Load balancer
8080 published
cue-core-1
Backend replica
8080
cue-core-2
Backend replica
8080
cue-front-end
Next.js web app
3000 published
cue-admin
Admin dashboard
3016 published
postgres
PostgreSQL 18
5432
valkey
SSE and notes relays, rate limits, sign-in codes, widget push pacing
6379
Cloudflare R2
Media storage, EU, one bucket per environment
—
06Real-time

Real-time with Server-Sent Events

Cue has to feel instant. When someone responds to an event, everyone else should see it immediately — on the web, on iOS, Android, and macOS, and in the CLI. Instead of polling, Cue uses Server-Sent Events: a simple, HTTP-native protocol for one-way streaming. The one thing that needs traffic both ways — typing into a shared note — gets a WebSocket of its own, and leaves this channel untouched.

How it works

The client opens one long-lived HTTP connection to /api/sse/stream, and the server pushes an event down it whenever something changes. That’s all there is to it.

Signal-based, not payload-based

This is the key design choice. An SSE event in Cue is a signal, not a payload: it names what changed and nothing more. The client then refetches through the normal REST API, so authorization lives in exactly one place — the REST controllers — and the SSE layer stays paper-thin.

Fig. 3 / Signal, then fetch
clientcue-coreSSE · the signalevent: event-updateddata: {"eventId":42, "changeType":"responded"}GET /api/events/42authorization runs here, once200 · as you may see it
The stream says what changed; the REST call, with its authorization, says what it is now.

Multi-instance broadcasting

With two replicas behind the load balancer, a signal produced on one has to reach clients connected to the other. So every signal goes through a Valkey pub/sub channel: each replica subscribes, and each pushes what it hears to its own connected clients.

Fig. 4 / SSE fan-out
afterCommit()EventServicea write commitsvalkeypub/sub · sse:broadcastcue-core-1SseBroadcastercue-core-2SseBroadcasterwebiOSAndroidCLI
One publish reaches every replica, and every replica reaches its own clients.

Transactional safety

Signals go out only after the database transaction commits, through Spring’s TransactionSynchronization.afterCommit(). If a transaction rolls back, no signal is ever sent: no phantom updates, and no clients refetching data that doesn’t exist.

Heartbeats and reconnection

The server sends a heartbeat every 20 seconds, which keeps connections alive through the tunnel and flushes out dead ones. When a connection drops, the client reconnects with exponential backoff. The stream carries no history, so after a reconnect the client simply refetches what it’s showing. A 401 stops the retry loop instead of hammering the server: the client refreshes its session and resubscribes, or, if the session is truly gone, asks the user to sign in again.

Reconnect delaysdoubling from 1 s, capped at 30 s
1s
2s
4s
8s
16s
30s
30s

One channel, many signal types

Everything real-time in Cue but note editing rides this one channel. The wire format is uniform — a named SSE event plus a tiny JSON body identifying what changed — so adding a signal type needs no change to the broadcaster: the service names it when it publishes. The signal types, grouped by domain:

Eventscreated · updated · deleted · 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 · deleted · 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 · image-session-updated
Othernotification
07Intelligence

AI in nine places

AI isn’t only a development tool for Cue — it’s part of the product. The backend talks to Gemini through Google’s Vertex AI in nine distinct places.

AI Suggested Time (Resolution)

Always on for events with two or more proposed times. Reads every response, the event chat and who hasn't answered, 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 or someone writes in the chat.

AI Comments

Opt-in per event, decision, or trip. The organizer picks a persona — Funny or Serious — and the AI posts a short comment in that voice, re-running as the conversation evolves and keeping its last word when nothing new has come up. Trips take Funny only: their serious voice is the Trip Brief, and the comment bows out once a trip is a memory.

Weather Reports

Opt-in per event. yr.no's Locationforecast supplies the raw forecast for the event's coordinates and proposed times, behind a cache keyed on coordinates rounded to four decimals that revalidates with If-Modified-Since, so an unchanged forecast costs a 304. Gemini condenses it into a few practical sentences.

Live chat participants

Opt-in per chat, in event 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). One director call per message lets at most one of them speak up, and none by default right after an AI, so the AIs don't flood the room.

Decision recommendations & Option Insights

The brain of the 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, preferences and the chat change. A shared file is read once into a digest, so later runs carry its facts, not its bytes. 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 gathers everything the group has given the trip — notes, documents (each read once into a digest), the trip chat, the linked events and decisions, who's coming, and on a live trip the forecast at a destination picked as a place — into one markdown brief, written for wherever the trip is on its arc: a warm planner before, a daily companion during, a recap after. It regenerates on meaningful changes, and every morning of a live trip.

Clean up (Cue Notes)

One button turns a brain-dump into a clean note: spelling and grammar fixed, the structure the text implies added, code fenced and tagged — in the author's own voice and language. A structural guard checks that every code block, link and URL survived byte for byte, and the proposal lands as an inline diff where any change can be kept or undone.

Photo descriptions

Every image uploaded to the library is described once, in a sentence or two: what it shows, legible text quoted, no guesses about who anyone is. The description is alt text where there is no caption and a search field ranked below caption and file name, never visible text. It runs behind a server kill switch and each uploader's own opt-out, which also deletes the descriptions already written.

Cover images

Behind a server switch, an organizer or guide can ask Cue to paint an event's or a trip's cover. An art director reads what the plan already says — place, season, the weather, the chat, how many are going, never who — asks at most one quick question when that isn't enough, and writes the picture: never a real person, never a word on it. A painter paints it while the studio shows the idea and its palette, and the take someone keeps becomes an ordinary library photo. A profile's Your look sets the style, mood and painter for every image Cue makes for them.

In production every text call runs on Gemini 3.8 Flash — the suggested time, the AI comments, the weather read, the Trip Brief, the whole decision brain (recommendation, Option Insights, and commentary), the live chat participants, the option brainstormer that seeds a new decision, Clean up, the photo descriptions, the digests that read a shared file once, and the cover’s art director. The text work splits into two profiles, each its own model, thinking level and time limit: the analytical profile thinks at a medium level, while the chat profile, where someone is waiting on the answer, thinks at a low one and gives up after twelve seconds. A third, the image profile, names no model of its own: the person’s painter picks it — Nano Banana 2.1 by default, or Nano Banana Pro for the finest detail. Every call goes through one gateway that routes it, checks the answer — a feature that wants structure hands the model a schema to fill — and meters it. Each event, decision or trip has at most one call per feature in flight: a burst of edits collapses into one trailing run, and unchanged inputs cost nothing. The dev server runs the faster Gemini 3.5 Flash-Lite behind the same two text profiles and Gemini 3.1 Flash-Lite Image behind both painters, so its test suite stays quick while production keeps the stronger models.

All but one share one shape: a service publishes a Spring ApplicationEvent once its transaction commits, an @Async listener calls Gemini, and the result reaches every connected client over SSE. Independent entities, listeners, signal types, and rate-limit checks — one feature can never block another. Clean up is the exception: a person asks, and the proposal comes back in the response, private to them until they accept it. A photo’s description starts the same way but sends no signal: it waits on its row for the next read. A cover is a session of steps — read, direct, paint — each run once the request that asked for it commits, and signalled to its requester alone.

Fig. 5 / AI, off the request
REQUESTASYNCCLIENTSrespondsanswer savedcommitafterCommit()200 OKno waitinglistenerbuilds promptGemini3.8 FlashsaveresolutionsSSE signalall refetch
The resolution path shown. Swap in AiCommentService, WeatherReportService, or AiChatService and the shape is identical.

The user sees their write saved instantly; moments later the AI’s answer arrives over SSE, with no refresh. Every AI call is logged with its token counts, latency and cost, priced from a dated catalog of each model’s rates, and the admin dashboard tracks daily, weekly, and monthly Gemini spend against configurable caps — no single user can burn through the month’s budget.

08Conversations

Five kinds of conversation

Chat is a full messaging surface with five kinds of conversation: the live chat on an event, a 1:1 direct message, a standalone channel, the chat a decision grows, and a trip’s own room. The hub at /chat lists all five together, sorted by activity, with an unread badge per conversation and a total in the top navigation.

The hub is two-pane — the conversation rail on the left, the selected thread on the right — and collapses to list-and-detail navigation on mobile. Conversation IDs are opaque (event-{id}, dm-{otherUserId}, channel-{id}, decision-{id}, trip-{id}), which keeps routing forward-compatible: the web client never assumes their shape beyond passing them to the API, and a client that doesn’t recognize a kind simply drops that row.

What every conversation has

Composer

An auto-resizing textarea: one row at rest, growing to about six before it scrolls. Enter sends, Shift+Enter adds a newline, and Enter is ignored during IME composition so CJK input doesn't fire early. Sends insert optimistically.

Per-message reactions

A long-press brings up a row of emoji on a frosted backdrop. Reactions are unique per (message, user, emoji), and a sheet shows who reacted with what.

Read receipts

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

Typing indicators

Throttled typing pings over SSE that fade in and out, stopping on send or after a short idle. Nothing is stored — they're purely ephemeral.

Message editing

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

Media attachments

Attach a photo or video from your Cue library, or upload one fresh. Media is attached by ID, never re-uploaded per message: the same image used twice is one row with 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, used consistently across every chat. A change applies to past messages too, so threads stay readable when someone re-themes.

Media stacks

Several photos or videos sent in a row collapse into a stack — a tidy collage for two or three, a fanned pile with a “+N” badge for more. Purely presentational: each message still carries at most one attachment.

Channels: a room of its own

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

  • Three visibility tiers — public (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 ownerless, or stage 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, which keeps the check out of the hot fan-out path instead of smearing a “was removed” condition across every query.
  • Human-only — unlike event and decision chats, channels don’t take AI participants.

AI participants in event & decision chats

Any invitee can add an AI to an event or decision chat (up to five per room), choosing a name — or accepting a random one — a free-form personality, and an interaction mode:

  • proactive — reads every new message and decides whether to respond.
  • mention_only — speaks only when it’s named.
  • event_driven — reacts to state changes: a new response, the organizer finalizing, and so on.

An @-mention makes the named AI weigh in, whatever its mode. An AI can be removed by the person who added it or by the organizer; it then leaves the participant strip and stops responding, but its past messages stay, attributed to it by name, so the history still reads correctly.

Data model & limits

Each kind runs on its own family of tables, all cut to the same shape: messages, a read cursor, and reactions.

messagesread cursorreactions
chat_
messagesread_statereactions
dm_
messagesread_statereactions
channel_
messagesread_statereactions
decision_
messagesread_statereactions
trip_
messagesread_statereactions

Plus the rows only some kinds need: a dm_threads row per DM pair, and channels, channel_members, channel_join_requests, and channel_blocks for 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 the 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 person per conversation
AI participants5 per event or decision
AI reply cooldown30 seconds
AI budget60 messages / 24 h per room
10Consensus

Decisions: the group picks what

Events settle when; Decisions settle 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 them into a recommendation: a pick, a fit score per option, and a written rationale that re-runs whenever the options or preferences change. It’s the decision twin of the event resolver: recommendations is to a decision what resolutions is to an event.

Recommendationillustrative
Where should we go in May?
  • Lisbonthe pickfit 86
  • Tromsøfit 71
  • Splitfit 58

“Lisbon fits the most preferences: warm in May, walkable, and one direct flight for everyone. Tromsø would be magical, but only one person put it first.”

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 queries 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 envelope carries every change; and every decision AI path meters against the same Gemini budget as everything else. An optional deadline gives the group a decide-by date. It’s purely informational — nothing auto-finalizes — but a job that wakes every five minutes nudges once as it approaches and once when it passes.

11Containers

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, notes, documents, and album, that gathers events and decisions into one timeline. Dates are optional and may lie 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. Before the dates, the page is a countdown over a date-ordered itinerary of the linked pillars; during, it’s a “happening now” page; afterwards, 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 no linked pillars is a first-class citizen, not a degenerate case.

Fig. 6 / A trip's arc
AHEADLIVEBEHINDa countdown over the itinerarythe brief: a warm plannerhappening nowthe brief: a daily companionmemories and the albumthe brief: a recapnoweventdecisionphoto
The same trip, three pages: the Trip Brief changes voice as the arc moves.

Links, not ownership

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

Fig. 7 / The link model
tripa dated arcchannela standing roomcontainer_linksone soft link per roweventsettles whendecisionsettles whatCHECK trip xor channelCHECK event xor decision
One row per link. Containers organize pillars; they never own them.
  • A link isn’t access — being in a trip never grants access to a linked event. A non-invitee sees an inert card and joins through the event’s own invite flow; authorization stays per resource.
  • A link isn’t ownership — unlinking is non-destructive, deleting a container drops its link rows and never the pillar, and zero links is simply a standalone pillar.
  • Per-viewer chips — the “part of” chips on a pillar list only the 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 could become the second container kind — organizing pillars, spawning trips from their roster, and rendering the same itinerary — without reshaping a single shipped contract.

Thirteen tables, every pattern reused

trips and trip_members; the three invite paths (direct, link, and email), shaped like the ones Events uses; a trip_* chat family on the shared chat core; trip_notes, trip_documents, and 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.

12Writing

Cue Notes: markdown, written together

Cue Notes is shared markdown a group writes live: collaborators see each other’s carets and words as they type, anyone can go a week offline and merge on return, and an AI Clean up turns a brain-dump into a tidy note. A note is neither a pillar nor a container — it settles nothing and organizes nothing; it holds writing, and reaches the rest of Cue only through links it renders as chips. On the web it lives at /notes, on the iPhone and on Android in apps of their own, on the Mac as a section of the Cue app, and in the terminal as cue notes; its API is part of the same frozen, additive-only contract as everything the store apps read.

One CRDT, with the server as its authority

A note’s body is a single Automerge text object holding plain markdown — no conflict dialogs, no locks, no last-write-wins. Automerge because it alone has official bindings on every runtime Cue runs: JavaScript on the web, Java on the server and Android, Swift on Apple platforms. Cue runs them as released, and a shared conformance suite — text-position vectors across emoji, CJK and combining marks, and sync transcripts replayed across eight runners — is how it knows they agree. cue-core is a peer in every note and its authority: it reads each incoming change’s bytes itself and admits only plain text splices, written under the sender’s own actor and within the note’s size, before anything reaches the document.

A socket of its own

Keystrokes and carets are high-frequency and two-way, which is exactly what the signal-only SSE stream isn’t for. So Notes has its own transport: one WebSocket per client at /api/notes/live, multiplexed by note, beside an SSE layer it never touches. A client mints a single-use ticket over REST and opens the socket with it. Any replica serves any note, with Valkey relaying changes, presence and roster moves between replicas on channels of their own, and every open document is confined to one single-threaded lane, because the native binding admits one caller at a time. On the web, the editor is CodeMirror 6 on Cue’s own binding to Automerge, rendering formatting in place as you type; it and the Automerge wasm load only on /notes pages, so no other page grows.

Never lose an acknowledged keystroke

The server writes every change to an append-only log and commits before it acknowledges it, and folds the log into a snapshot from the store as it grows. That promise is tested by breaking things: a drill kills a replica mid-typing and checks every acknowledged state against the store, and a randomized fuzz — simulated peers, emoji and CJK splices, reordered delivery, dropped connections — runs with the backend’s tests. On the web, unacknowledged changes wait in IndexedDB, so a tab closed offline loses nothing.

History

A version is cut when an editing burst ends, before every restore and before every Clean up. Scrub back, diff any version against now, and restore — a restore is itself a forward edit, so anyone typing meanwhile keeps their words, and it can be undone.

Edits without a CRDT

A plain markdown save — from a script, the CLI, an accepted Clean up or a restore — names the heads it was edited from, and the server merges it three-way from there. Nobody's concurrent words are lost to someone else's save.

Roles on the wire

Owner, editors and viewers, enforced on the socket as well as on every route: a viewer's change is refused before it touches the document, and removing someone cuts every live session they have at once. A note you aren't on answers 404, never 403.

Quiet to everyone else

A caller that doesn't ask sees nothing of Notes: no notification rows, no chat-rail entries, no SSE signals, and search serves notes only when a client names the kind. Every response the shipped apps read stays exactly as it was.

Note size64 KB of markdown
Members per note100
Socket keepalivea ping every 30 seconds
Clean up30 per person per hour
13Media

Media: one library for photos, video & files

Every user has a single library, and the same item — a photo, a short video, or an uploaded document — can be attached to chat and DM messages or used as an event cover. A photo_usages table tracks every place each item appears, so deleting it cleanly detaches it from every message and cover that referenced it. All three kinds share one table; a media_kind column (image, video, or document) is the only row-level difference. The grid endpoints return what a thumbnail can draw; a separate files surface returns the whole library, documents included. A cover Cue paints joins the library through the same upload path the moment someone keeps it, its origin marked generated.

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 at Cloudflare, so R2 was a natural fit: zero egress fees, an S3-compatible API, and buckets that stay private. Every read is a 302 redirect to a 15-minute presigned URL, minted only after cue-core has checked the requester’s session. R2 honors HTTP Range requests, so video seeking and scrubbing work natively — no streaming server, no HLS.

Fig. 8 / Media read
clientcue-coreR2GET …/thumb.avifsession check302 · signed URLGET signed URLbytes · Rangeno egress fees
Authorization 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 encoding and libheif-plugin-libde265 for HEIC decoding. Compared to Java’s ImageIO, libvips is 10–20× faster and uses a tenth of the memory across HEIC, AVIF, and WebP. It runs as a subprocess per upload: simple, isolated, and upgradeable without touching the JVM.

  1. 1
    Magic-byte sniff

    Validate the format from the bytes themselves, not the Content-Type header. HEIC, JPEG, PNG, WebP, AVIF, and GIF are accepted; TIFF, BMP, SVG, and RAW are not.

  2. 2
    SHA-256 dedup

    Hash the raw upload. A re-upload returns the existing photo (HTTP 200); a soft-deleted match comes back to life, unless an admin took it down — that row stays behind as a tombstone that refuses the same bytes.

  3. 3
    Read EXIF, keep metadata

    DateTimeOriginal is parsed into its own column; the EXIF/XMP block (GPS, camera, timestamps) rides through the re-encode onto every variant. Orientation is baked into the pixels.

  4. 4
    Generate variants

    thumb (256w), preview (1024w), display (2048w), and full (up to 4096w), each in AVIF (primary) and WebP (fallback). No JPEG fallback: every supported browser decodes WebP.

  5. 5
    Animated GIFs kept whole

    No transcode, no dropped frames. The original GIF is stored as-is, with one static WebP thumbnail for grids and previews.

  6. 6
    BlurHash placeholder

    A 28-character string computed from a 64w intermediate and stored on the photo row. Clients paint it instantly as a soft placeholder while the variant loads.

  7. 7
    Upload to R2

    Each variant lands at {owner_id}/{photo_id}/{label}.{format}. A failed upload rolls back the database transaction; orphaned objects are swept by a nightly job.

Fig. 9 / The variant ladder
full4096wdisplay2048wpreview1024wthumb256wAVIFprimaryWebPfallbackBlurHash · 28 chars
Drawn to scale: the thumbnail really is a sixteenth of the full image's width.

The video pipeline

Video can’t take the photo’s synchronous path: 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 nginx and Cloudflare’s 100 MB tunnel ceiling, so clips up to 200 MB get through — then calls finalize. That commits a processing row and fires an event; a background worker, limited to two concurrent transcodes, does the heavy lifting with ffmpeg.

Fig. 10 / Video upload
clientcue-coreR2POST video-ticketpresign the PUTPUT · max 200 MBskips the tunnelPOST finalizeprocessingtwo at a timeffprobe · 60 s maxposter + BlurHash720p (+1080p) MP4renditionsmedia-readyposter to player
Video goes straight to R2; an async worker transcodes it and signals media-ready over SSE.

The poster frame is grabbed at min(1s, duration/2), which skips a black intro, and fed through the exact same image ladder — so a video shows a real still and a BlurHash placeholder before it ever plays. ffmpeg always emits a 720p video_720 rendition, and a video_1080 only when the source is at least 1080p (it never upscales). 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 transcodes, the client shows its BlurHash and a spinner, then flips to the player on the media-ready signal (with a poll as a fallback).

Photos vs. video at a glance

AspectPhotosVideo
Uploadmultipart to cue-coreticket → direct R2 PUT → finalize
Processingsynchronousasync worker (two at a time)
Limit50 MB200 MB · 60 s
Renditionsthumb / preview / display / full × AVIF + WebPposter ladder + video_720 / video_1080 MP4
On failurerolled back; orphans swept nightlyhard-deleted, quota refunded

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 goes. The library reads this table to draw 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 deletion: if an item has live usages, the soft-delete detaches them server-side, and the detached messages render as “(image removed)”.

15Identity

Authentication

Cue supports three sign-in methods: Google, Apple, and email one-time codes. The web client runs the OAuth flows through NextAuth v5; the backend verifies credentials from all three on every request.

Google

Offline mode with refresh tokens. Sessions 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 for a Cue session right after sign-in and never needs Face ID again just to stay signed in.

Email

A six-digit code sent via Brevo SMTP, limited to three codes per ten minutes per address. After verification, the backend issues a 30-day JWT.

Users can link several providers to one account through verified email matching — no fragmented accounts, 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 sign-in credential 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. The one bearer it refuses is a Cue access token, so a session can never re-mint itself.

Every refresh rotates: the presented token is spent and a fresh pair issued. Every token descended from one sign-in shares a family ID, so if a stolen token is replayed after its successor was spent too, the whole family dies in a single update. Only a SHA-256 digest of each refresh token is stored, so a database read never yields a usable credential. A replay whose successor is still unused is the honest case — a client that rotated but never received the response, a second or a week ago — and it simply gets a fresh successor.

Fig. 12 / Token rotation
mintrefreshrefreshsign-inGoogle, Apple or emailtoken 1live · family Arotated awayrevokedtoken 2not issued yetlive · family Arotated awayrevokedtoken 3not issued yetlive · family Arevokedstolen copytoken 1, replayedreuse detectedfamily revoked at once
Each refresh spends its token. Replay one whose successor was spent too, and every token in its family is revoked.
16Allowance

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 — and on a third, the AI images Cue paints for them on demand, each costing about as much as dozens of text calls.

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 below the earned one — so generosity and abuse control are the same lever, pointed in two directions.

LevelStorageCreations / 30 dAI images / 30 d
newday one256 MiB255
regular30 days1 GiB5015
established365 days3 GiB10030
Cue Plusgranted10 GiB15060
Cue Progranted25 GiB300120
Cue Maxgranted50 GiB600240

The ladder is data, not a Kotlin enum — a deliberate exception to the wire-enum rule used 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 that 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, which is how the third, AI images, shows as another meter on apps already in the stores.

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 live rows — counting live rows is trivially gamed by creating, fanning out, deleting, and repeating. 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.

AI, bounded sideways

There is no per-user budget for text AI. Cue's AI hangs off entities, so capping how many entities you can create caps the AI you can fan out. The exceptions run on demand: Clean up over any note, attributed to the person who asked and rate-limited per user, and cover images, where every painted take spends one of the level's AI images for 30 days, at most 20 an hour. The live chat rooms carry their own 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, never to someone locked out of their own photos.

The whole thing is quiet. Users see their level and room in exactly two places: their own profile, and the admin dashboard. Never on 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.

17Schema

Database design

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

A few notable choices. Enum-shaped fields use VARCHAR with CHECK constraints and a custom JPA AttributeConverter on the Kotlin side (never @Enumerated(EnumType.STRING)), so the wire format and the database format share one fixed pattern and never drift. Per-slot resolution scores, recommendation fit scores, Option Insights’ cited sources, and cover crops are stored as JSONB for flexible structure. Profile pictures live in their own table to keep users lean. Message edits are tracked in-row with edited_at and edit_count rather than in 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 spans 83 tables, built up over 99 Flyway migrations, and groups cleanly by domain:

Identityusers · linked_accounts · auth_sessions · device_tokens · widget_credentials · profile_pictures
Schedulingevents · proposed_times · invitations · invite_links · pending_email_invites · notifications
Intelligenceresolutions · ai_comments · weather_reports · ai_participants · ai_usage_logs · ai_usage_limits · ai_model_prices · ai_grounding_prices · image_sessions · image_takes · image_looks
Event chatchat_messages · chat_read_state · chat_message_reactions
Direct messagesdm_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 · media_ai_texts
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 · container_links · trip_notes · trip_documents · trip_media · trip_briefs · trip_ai_comments · trip_messages · trip_message_reactions · trip_chat_read_state · trip_invite_links · trip_pending_email_invites
User levelsuser_levels · user_level_grants · user_creations · image_take_ledger
Cue Notesnotes · note_members · note_docs · note_log · note_actors · note_versions · note_clean_up_runs · note_images · note_invite_links · note_pending_email_invites
Link previewslink_snapshots
Operationsdatabase_backups
Take the schema tour all 83 tables, drawn domain by domain as entity-relationship diagrams
18Iron

Hosting: personal servers, no cloud

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

DevelopmentHomelab

An Ubuntu server under my desk at home, reached over SSH through a Cloudflare Zero Trust tunnel. Postgres and the tunnel come from the homelab’s shared Docker stack. 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. Here PostgreSQL runs inside the Cue stack itself, and deploys roll one replica at a time.

No Kubernetes, by choice

Kubernetes is incredible technology, but it’s built for problems Cue doesn’t have. Two backend replicas behind nginx, managed by Docker Compose, is all the orchestration needed, and the whole production stack fits in a single compose file. If traffic ever outgrows it, scaling up means adding another replica and a line in the nginx config.

Cloudflare Tunnels

Both environments reach the internet through Cloudflare Zero Trust tunnels: no open ports, no public IPs, no reverse-proxy chain. The cloudflared daemon connects outbound to Cloudflare’s edge, which routes each hostname back to its local service. Every published port binds to localhost, so the tunnel is the only way in — which is also what makes the client IP Cloudflare reports trustworthy enough to rate-limit on.

19Ship it

Deploy: from commit to production

Cue doesn’t use GitHub Actions or any other CI/CD platform. The whole build-and-deploy pipeline is a Fish entry point, omni-deploy, that commits and pushes, then pipes a bash script to the server over SSH. It’s faster, simpler, and entirely under my control.

  1. 1
    Git sync

    Commit anything pending and push main to GitHub.

  2. 2
    Remote run

    Pipe the deploy script to the server over SSH. It runs detached from the connection, one deploy at a time, and fast-forwards the checkout to exactly the commit pushed.

  3. 3
    Validate

    Test the new nginx.conf (production also the compose file) before anything is built.

  4. 4
    Build images

    cue-core, cue-front-end, and cue-admin build in parallel. A failed build stops before any container is touched.

  5. 5
    Restart

    Recreate only what changed, then reload nginx so it re-resolves the backends.

  6. 6
    Health check

    Wait until both backend replicas answer healthy through nginx, and check every container runs the revision just built.

  7. 7
    Prune

    Clear dangling images and build cache older than a week.

  8. 8
    Verify

    /cue-deploy-verify runs the API test suite against the freshly deployed server, then checks nothing crashed under it.

Steps 1–7 are omni-deploy itself; /cue-deploy-verify wraps it and adds the eighth, exercising the live API contract before a deploy counts as done.

Zero-downtime in production

The production deploy goes further. Instead of restarting everything at once, it recreates the two backend replicas one at a time, waits until each answers healthy through nginx, and reloads nginx before moving on — so a healthy replica is always serving. If the new build won’t come up, the roll stops and both replicas go back to the previous image. The two web apps are single containers, restarted only when they changed, at the cost of a few seconds each.

Fig. 13 / Rolling deploy
cue-lbnginx · round robincue-core-1v41 · servingrecreatingv42 · healthycue-core-2v41 · servingrecreatingv42 · healthy123451 · build all three images in parallel2 · recreate core-1, await its health check3 · reload nginx onto the new container4 · recreate core-2, await its health check5 · reload nginx againdone · a healthy replica served throughout
While one replica is recreated, nginx sends everything to the other.

Why not GitHub Actions?

Building on the server skips the cloning, caching, and artifact transfers a CI runner pays for on every run. The script SSHes into the server, pulls the latest code (most of which is already there from the previous deploy), and builds. The whole pipeline is a handful of readable scripts.

A few honest reasons beyond raw speed:

  • More uptime — GitHub Actions has gone down on me more often than my own setup ever has, and 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.
20Images

Docker build pipeline

Each service image comes from a multi-stage Dockerfile, tuned for fast rebuilds and minimal runtime images.

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

Gradle compiles the Kotlin source into a fat JAR. The runtime image adds the vips CLI (libvips-tools) with libheif's aomenc plugin for AVIF encoding and libde265 plugin for HEIC decoding, and ffmpeg/ffprobe for video transcoding and posters. No build tools, no source code, and no shell tooling beyond what the media pipeline needs.

cue-front-end
node:24-alpine→distroless/nodejs24

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

cue-admin
node:24-alpine→distroless/nodejs24

The same pattern as the web client: an identical build pipeline for a different app.

All three images build in parallel during a deploy. Thanks to Docker’s layer cache, when only the backend changed, the web images rebuild in seconds — there’s nothing for them to do.

21Native apps

iOS, Android, macOS & Cue Notes

The iOS app is a native SwiftUI application distributed through the App Store. That has a profound effect on the backend: users can’t be force-updated, so every endpoint the 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. That discipline keeps the whole system honest.

The iOS app matches the web for everything that ships: events, decisions with their AI recommendation and Option Insights, Trips with their itinerary, brief, and album, the chat hub — reactions, typing indicators, read receipts, edits, AI participants — 1:1 DMs, channels, the media library, and event cover images. The Codable models in cue-ios/Cue/Models/ mirror the cue-core wire format, and every new field lands as optional so older versions still decode — 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 pulling 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 reaches R2). A floating glass chrome — a close button and a counter — toggles on tap, and neighboring 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 the Apple Push Notification service (APNs), calendar sync for finalized events, home-screen widgets — upcoming events, a trip countdown, and a Chats inbox that puts whoever is waiting on you first, on the Lock Screen too — a Live Activity while a trip is underway, a share extension, and home-screen quick actions. Device tokens are registered at sign-in, live exactly as long as that sign-in, and are cleaned up automatically when APNs reports them invalid. The widgets live in a separate CueWidget target, version-pinned to the main app so they ship together.

The Chats widget keeps itself current while the app is closed. Whenever someone’s Chats rail moves — a message, their own read on another device — cue-core sends a WidgetKit push, coalesced per user in Valkey so a busy group can’t drain the widget’s daily reload budget, and the widget fetches its own rail. That fetch is the push’s receipt: APNs accepting a push doesn’t mean the phone got it, so a push no fetch answers is sent again, up to three times, from seconds to minutes apart. The widget fetches with a credential of its own: the app’s refresh tokens rotate with reuse detection, so a third process spending one could sign the app out. The widget’s credential never rotates, works on its own endpoints and nowhere else, and dies with the sign-in that minted it.

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

The macOS app

The Mac gets 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 as a Homebrew cask and a direct DMG download, with Sparkle handling silent in-app updates. It speaks the same 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 /release-macos skill builds, signs, notarizes, and publishes a release — DMG, GitHub Release, Homebrew cask, and Sparkle appcast — in one pass.

Cue Notes lives inside it as a section rather than a separate app: live carets, notes in windows of their own, Spotlight and Handoff, on a local Swift package, CueNotesKit, that shares no code with the iPhone app and is held to the same conformance suite.

The Android app

cue-android is 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 additive-only API contract — and it’s a pure API client, sharing no 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 as the web and iOS versions. A /release-android skill builds the signed bundle and publishes it to a Google Play track through the Play Developer API.

Cue Notes for iPhone

Cue Notes has an App Store listing of its own: cue-notes-ios, SwiftUI over TextKit 2, carrying nothing of Cue but its notes. It is written fresh rather than borrowed from cue-ios, and it reads the same data-only conformance suite as the web and the server, so automerge-swift is held to the exact text positions and sync transcripts every other binding is. The app is offline-first: every note’s document lives in a SQLite store on the device, so an airplane-mode round trip loses nothing, and the socket merges it on return. Markdown renders in place as you type, and the system’s reach comes along — a share extension, a pinned-note widget whose checklist ticks from the Home Screen, Spotlight, and Shortcuts. Snapshot goldens, XCUITest flows against dev, and accessibility audits cover every screen.

Cue Notes for Android

Android gets the same app in its own idiom: cue-notes-android, Jetpack Compose over the platform text editor, written fresh rather than borrowed from cue-android or the iPhone app. Gradle modules enforce its layering — an engine and a markdown layer in plain Kotlin that run the conformance suite on the host in seconds, then design, editor, screens, and the app — wired by a hand-written composition root rather than a DI framework. Each open note’s document lives on a lane of its own, never on the main thread, so typing stays quick in a full 64 KB note. Phones and foldables are both first-class: list and note side by side when the fold opens, a split in tabletop posture, and one note open in several windows as one string. And it reaches past the screen: a share target with Direct Share, a pinned-note widget that ticks straight into the document, a Quick Settings tile, and a background sync that delivers words written offline without the app ever being opened.

22Terminal

The Go CLI

The CLI is a good example of how AI accelerates development. With the project context already established — API contracts, auth flows, the SSE protocol — Claude Code generated its first version from a single prompt: “Build a CLI for the app in Go, look at the other modules for functionality.”

Today it covers events and responses, decisions, trips, channels, every kind of chat, photos and files, notifications, search, and your profile, plus cue watch, which streams live SSE updates to the terminal. cue notes reads and writes Cue Notes as plain markdown the server merges three-way — sharing, history, and Clean up included — and cue notes watch follows them live over the notes socket. It’s built with Cobra for command structure and the Charmbracelet suite for polished terminal UIs, and emits clean JSON when piped. Tokens live in the system keyring. GoReleaser builds binaries for macOS and Linux (AMD64 and ARM64), generates shell completions, creates a GitHub release, and updates the Homebrew cask — all from a single make release.

23Workflow

Built with Claude Code

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

CLAUDE.md: the project brain

At the root of the repo sits CLAUDE.md, the standing instructions every session starts from. It carries the non-negotiables — the iOS API stability contract, the wire-format enum pattern, Flyway-only schema changes, no hardcoded secrets, never confusing dev with prod — alongside a routing table that points the agent to the right deeper doc for whatever it’s working on. AGENTS.md and GEMINI.md are symlinks to the same file, so Codex and Gemini CLI read exactly the same text.

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-notes-ios, cue-notes-android, cue-core-api-test, and cue-static-users) covering its layout, build commands, and the rules specific to it, plus a design-system companion for each web app. doc/architecture/ covers the cross-cutting designs — the API surface, SSE, the database schema, infrastructure, each chat kind, decisions, containers, the advanced event features, media, search, user levels, Cue Notes, rich links, image generation, and the AI plumbing.

Keeping CLAUDE.md a lean hub rather than a brain-dump is deliberate: less context per session, more accuracy per task. A standing rule keeps the docs honest — when a change alters what a doc describes, the doc changes in the same commit.

Skills: complex workflows as commands

The real leverage comes from custom skills: workflows defined in .claude/skills/, each a markdown file describing a multi-step operation the agent runs as a slash command. Thirty-eight of them cover the whole lifecycle:

Deploythe server stack
  • /deploy-devFast dev deploy: commit, push, build on the server, restart, health check
  • /deploy-prodZero-downtime production deploy, one replica at a time
  • /cue-deploy-verifyDeploy to dev, run the API tests, verify container health
  • /api-testRun the live API suite against the deployed dev server
Installa dev build onto a device
  • /install-iosBuild and install the debug app on a connected iPhone
  • /install-androidBuild and install the Android app on a device or the headless emulator
  • /install-notes-iosBuild and install Cue Notes for iPhone's dev build on the simulator or an iPhone
  • /install-notes-androidBuild and install Cue Notes for Android's dev build on the phone or fold emulator, or a device
Releaseship to users
  • /release-cliBuild, test, and release the Go CLI via GoReleaser and Homebrew
  • /release-macosBuild, sign, notarize, and publish a macOS release: DMG, GitHub Release, cask, appcast
  • /release-iosRun the unit suite, then archive, verify, and upload a Cue iOS build to TestFlight
  • /release-androidBuild the signed bundle and publish it to a Google Play track
  • /release-notes-iosArchive, verify, and upload a Cue Notes for iPhone build to TestFlight
  • /release-notes-androidSmoke-test the minified Cue Notes for Android bundle, then publish it to a Google Play track
  • /ios-release-notesWrite the App Store notes for the current iOS version, then bump it
  • /tagCut and push a semver tag, with a short note on what it represents
Dev dataa realistic playground
  • /reset-dev-dataSeed the dev database with realistic data covering every feature
  • /clear-dev-dataWipe all user data from the dev database
  • /static-usersCheck or reset the fixed-code dev sign-in accounts
Maintenancekeeping it current
  • /ai-evalRun the AI golden set against Vertex on the prod configuration; pass rate, latency and cost per feature
  • /dep-upgrade-coreUpgrade Gradle, Kotlin, and Spring deps; verify compile, tests, and boot
  • /dep-upgrade-webUpgrade npm deps in both web apps; verify builds and dev-server boot
  • /dep-upgrade-cliUpgrade Go modules; scan for vulnerabilities, verify build, vet, and cross-compile
  • /dep-upgrade-iosUpgrade Swift packages; verify the simulator build and unit tests
  • /dep-upgrade-notes-iosBump Cue Notes for iPhone's exact-pinned packages; verify the conformance suite and the simulator suites
  • /dep-upgrade-androidUpgrade the Gradle version catalog; verify tests, screenshot goldens, and the R8 release
  • /dep-upgrade-notes-androidUpgrade Cue Notes for Android's version catalog; verify the conformance suite, goldens, and the R8 bundle
  • /dep-upgrade-macosUpgrade Sparkle and the Notes engine's exact pins; verify the conformance suite, a clean build, and the unit tests
  • /refresh-public-docsMake /architecture, /database, and /api true to the source and a pleasure to read; clean prose, legible figures, purposeful motion
  • /refresh-docsCheck the internal design docs against the source; cut stale claims and history
  • /refresh-api-testLeave the live API suite fully covered, tight, pretty and current; verify it green against dev
  • /refresh-skillRewrite a skill as a bar for a capable agent, keeping every hard-won fact and command
Session workflowthe everyday loop
  • /verify-webProve a web change on the deployed dev site in headless Chromium, WebKit, or Firefox, down to the pixels
  • /verify-adminProve an admin change by rendering every dashboard page against dev, both themes, desktop and phone
  • /fuziStage, commit, and push all pending work in one pass
  • /wufengEnd-of-session review against CLAUDE.md: confirm delivery, fix flaws, exercise the change
Building something largephased projects
  • /lantuDraft the one-shot bootstrap prompt a later session runs to scaffold a phased project
  • /jungongCarry a bootstrapped project to its end unattended, one fresh subagent per phase

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

Memory: learning across sessions

Claude Code also keeps a persistent memory across conversations: preferences, project context, and workflow feedback are stored and recalled automatically. Here it’s kept deliberately small — most knowledge belongs in the code, the docs, or a skill, where the next session will actually read it.

24Choices

Interesting tech choices

Virtual threads (JDK 25)

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

Valkey for pub/sub and rate limiting

Valkey carries the SSE relay between replicas, atomic sliding-window rate limits for HTTP requests (a small Lua script over a sorted set), and short-lived state such as one-time sign-in codes. Fast, in-memory, and a few dozen lines of Kotlin.

Cloudflare R2 for media

Cue already terminates traffic at Cloudflare, so R2 means zero egress, an S3-compatible API, and private buckets gated by 15-minute presigned URLs. No third cloud account, no separate bill, and the 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, and 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 a tenth of the memory. It 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 everywhere else. No JPEG fallback — every supported browser handles WebP, and variants come out 30–50% smaller than JPEG at the same quality.

BlurHash placeholders

A 28-character string per item encodes a low-frequency preview. Clients paint it instantly as soft colored blocks while the real variant loads — even videos show their poster's BlurHash before they play. No more empty gray rectangles.

Two models, three profiles

In production Gemini 3.8 Flash does every text job — resolution, AI comments, weather, the decision brain, the Trip Brief, the live chat participants, Clean up, the photo descriptions and the cover's art director — and Nano Banana paints. Every call goes through one gateway and one router: a feature maps to a profile, analytical, chat or image, and each sets its own model, thinking level and time limit, so one can move without touching the others; a painting's model is the person's painter pick, resolved by the same router. One Vertex AI adapter 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.

A CRDT, not locks

Cue Notes merges every edit with Automerge — live typing, a week offline, a scripted save, an AI rewrite — so nobody ever meets a conflict dialog. The bindings run as released; a data-only conformance suite proves JavaScript, Java and Swift agree, and the server admits only plain text splices, the shape every binding agrees on.

Distroless containers

The Next.js apps run on Google's distroless images — no shell, no package manager, nothing but 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 the entities match the schema — it never modifies it.

Signal-based SSE

SSE events carry signal types, not data. Clients refetch through REST, keeping authorization 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 one atomic Lua script over a Valkey sorted set. Chat and DM posting use a separate in-memory 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, a case-insensitive @JsonCreator fromWire, and a custom AttributeConverter for persistence. Wires are lowercase or snake_case and never renamed once shipped, so 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, photo descriptions, video transcodes, late link previews — all fire through Spring ApplicationEvents after the transaction commits. Background work only runs once the write is durable: no phantom jobs from rolled-back transactions.

No GitHub Actions

Building directly on the server is faster than any CI platform: no artifact transfer, no cache warming, no YAML debugging. A handful of scripts do everything.

Fish at the front, bash on the server

Fish has cleaner syntax and better error handling than Bash, so the dev and prod entry points are Fish — about 120 lines between them, over a shared lib. The remote half is plain bash, piped over SSH, so it runs the same whatever the server's login shell.

The philosophy

Use the simplest thing that works. Build on personal servers, not cloud abstractions. Let the AI agent handle the toil. Write each part in the language that fits it 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.