ugly-app
v0.1.474
Published
A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time docume
Readme
ugly-app
A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with npx ugly-app init my-app and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, and a CLI for every workflow.
ugly-app is designed to be deployed and operated through ugly.bot — the platform handles auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app UGLY_BOT_TOKEN.
What's included
- Server: Express + WebSocket with type-safe RPC and Zod validation
- Client: React + Vite with typed routing, lazy pages, animated transitions, popup management
- Database: PostgreSQL (JSONB) via the data proxy, with full-text search (
search) and vector search (Qdrantvector) - Auth: HttpOnly cookies + JWT, ugly.bot OAuth out of the box, extensible via
AuthProvider - AI: Text, image, embeddings, web search — all proxied through ugly.bot (no per-provider keys in your app)
- Realtime: NATS pub/sub and document change subscriptions (
trackDoc/trackDocs) - Storage: S3-compatible buckets with presigned uploads
- Workers & cron:
setWorkers()registers named async tasks with optional Zod input schemas and cron schedules - Localization: Strings tables with critical-string SSR injection
- Experiments: Deterministic A/B bucketing tied to event logging
- CLI:
ugly-appcommands for dev, build, deploy, migrations, logs, AI, and auth
Quick start
npx ugly-app init my-app
cd my-app
npm run devThe scaffold gives you a working app at http://localhost:4321 with todo CRUD, AI chat, file upload, auth demo, collab editing, and ~20 other test pages wired up.
Server
createApp()
The single server entry point. Returns an App that owns Express, the WebSocket server, the typed DB, and the RPC dispatcher.
import {
createApp,
type AppConfigurator,
type RequestHandlers,
} from 'ugly-app';
import { dbDefaults } from 'ugly-app/shared';
import { requests, messages } from '../shared/api';
import { collections } from '../shared/collections';
import { pages } from '../shared/pages';
const app = createApp(
{ requests, messages },
{
createTodo: async (userId, { text }) => {
const _id = crypto.randomUUID();
await app.db.setDoc(collections.todo, { _id, userId, text, done: false, ...dbDefaults() });
return { id: _id };
},
} satisfies RequestHandlers<typeof requests>,
collections,
(configurator: AppConfigurator) => {
configurator.setPages({ pages });
},
);
await app.start(parseInt(process.env['PORT'] ?? '4321'));Signature:
function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
registry: R, // { requests, messages }
requests: Partial<RequestHandlers<R['requests']>>, // handler implementations
appDefs: Defs, // collections from defineCollections()
configure?: (c: AppConfigurator) => void,
deleteHandlers?: DeleteHandlers<Defs>, // per-collection onDelete hooks
): App<CollectionMap<typeof BUILTIN_DEFS & Defs>>;The returned App object has:
start(port?)— start the server (default port 3000; templates use 4321)db— theTypedDBinstance, also available globally via importshttpServer— the underlying Nodehttp.Serverwss— the mainWebSocketServer(path set bysetWsPath, default/rpc)dispatch(name, input, userId)— invoke an RPC handler programmaticallyregisterRoutes(fn)— mount more Express routes after creation
Framework-managed background services start automatically: schema drift check, NATS connection + KV buckets (TTS, RATELIMIT), data-proxy connection, event counter flush, TTL cleanup for log tables, console / error capture, and ugly.bot log forwarding.
AppConfigurator
Passed to the optional fourth argument of createApp. Every method is optional.
| Method | Description |
|--------|-------------|
| setPages({ pages, renderPage?, clientDistPath? }) | Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves dist/client. Provide renderPage for SSR on pages with ssr: true. |
| setUserHelper(helper) | Customize how the framework reads / writes the user collection during WebSocket auth (default looks up by id in a generic user collection). |
| setOnUserCreate(handler) | Called on first login with (userId, { email?, phone? }, db) — your chance to create the user record. |
| setAuth(provider) | Replace the default ugly.bot OAuth provider. Must implement verify(code) and authUrl(origin). |
| setOnSocketMessage(handler) | Single raw-WebSocket message handler. Return true to consume, false to fall through. |
| addSocketMessageHandler(handler) | Append to the handler chain; first to return true wins. |
| setWsPath(path) | Override the WebSocket path (default /rpc). |
| setOnWsAuth(handler) | (ws, userId, req) => void — fires after a socket session authenticates. |
| setOnAfterStart(handler) | (db) => Promise<void> — called once after data-proxy + NATS are ready. |
| setOnMinuteTick(fn) / setOnHourlyTick(fn) | Framework-managed periodic callbacks. Only fire when CLOCK_ENABLED=true. |
| setHealthHandler(fn) | Override the default GET /health response. |
| setExperiments(experiments) | Register Experiment definitions for initSession / captureEvent bucketing. |
| setOnEmail(handler) | Handle inbound emails routed to {domain}@ugly.bot (called via internal HTTP). |
| setCronTasks(tasks, handlers) | Legacy cron-only registry. Prefer setWorkers(). |
| setWorkers(workers, handlers) | Register named async tasks with optional Zod input schema and cron schedule. Powers /_workers/manifest, POST /_workers/run, and the cron orchestrator. |
| setStrings(config) | Localization config — framework injects language + critical strings into SSR HTML and exposes resolveLanguage / getCriticalStrings. |
| registerRoutes(fn) | Mount custom Express routes. |
| setWorkerQueue(queue) | Register a WorkerQueue with start() / stop() for app lifecycle management. |
Handler signatures
Handlers are plain async functions — no context object:
// req() — public, userId may be null
getPublicData: async (userId: string | null, input) => { ... }
// authReq() — authenticated, framework returns 401 if no/invalid token
getMe: async (userId: string, input) => { ... }Inside a handler, access state via captured imports — app.db, storage, pgQuery, uglyBotRequest, etc. There is no injected context.
Built-in framework requests
createApp automatically registers several framework handlers, accessible from any client via the normal RPC pipeline:
| Name | Purpose |
|------|---------|
| userGet | Returns { userId, name, avatarUri } for the given user (or caller). |
| initSession | Records a session start, returns experiment branch assignments. |
| captureEvent | Records a client event tied to the session and experiment branches. |
| textGen / imageGen | AI proxies — server-validated, billed through ugly.bot. |
| kagiSearch / kagiSummarize / kagiEnrichWeb / kagiEnrichNews | Web search via ugly.bot. |
| uploadUrl | Issues a presigned PUT for the temp bucket. |
| submitFeedbackBot | Forwards db.captureFeedback writes for the maintain-bot persona. |
App-provided handlers with the same name override the framework's defaults.
Shared API definitions
shared/ is consumed by both server and client. Keep all Zod schemas, types, collections, and route declarations here.
Requests (shared/api.ts)
import { authReq, defineRequests, req, z } from 'ugly-app/shared';
export const requests = defineRequests({
// Public — handler signature: (userId: string | null, input) => Promise<output>
getPublicData: req({
input: z.object({ id: z.string() }),
output: z.object({ data: z.string() }),
}),
// Authenticated — 401 enforced automatically, userId guaranteed string
getMe: authReq({
input: z.object({}),
output: z.object({ userId: z.string(), email: z.string().optional() }),
}),
// With per-endpoint rate limiting (enforced before handler runs)
submitFeedback: authReq({
input: z.object({ type: z.enum(['bug', 'design', 'feature']), message: z.string() }),
output: z.object({ id: z.string() }),
rateLimit: { max: 20, window: 60 },
}),
});Every request is reachable as both socket.request(name, input) (WebSocket) and POST /api/:name { input } (HTTP). z is re-exported from Zod for convenience.
Collections (shared/collections.ts)
import { defineCollections, InferDocType } from 'ugly-app/shared';
import { z } from 'zod';
export const TodoSchema = z.object({
userId: z.string(),
text: z.string(),
done: z.boolean(),
});
export type Todo = InferDocType<typeof TodoSchema>;
export const collections = defineCollections({
todo: {
schema: TodoSchema,
meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
},
});CollectionMeta:
cache— readgetDocthrough an LRU cache; writes invalidate it.trackable— enables real-timetrackDoc/trackDocsvia NATS.public— allow unauthenticated client reads.cascadeFrom— parent collection for cascade deletes.trackKeys?— fields usable as NATS routing keys fortrackDocs.search?: { fields, language? }— PostgreSQL full-text search columns.vector?: { dimensions, source }— Qdrant vector index over the named JSONB path.
All documents extend DBObject: { _id, version, created, updated }. Use dbDefaults() to stamp the latter three on inserts.
After schema changes, run npm run db:schema-gen and then npm run db:migrate. The app refuses to start when drift is detected (set SCHEMA_CHECK_SKIP=true only as a last resort).
Pages (shared/pages.ts)
import { definePage, definePages } from 'ugly-app/shared';
export const pages = definePages({
'': definePage<{}>({ auth: false }), // /
'user/:userId': definePage<{ userId: string }>(), // /user/abc
'search': definePage<{ q?: string }>({ auth: false }), // /search?q=foo
'blog/*slug': definePage<{ slug: string }>({ ssr: true }), // /blog/any/path
});
export type AppPages = typeof pages;:parammatches a single path segment;*paramis greedy (captures slashes).- The generic on
definePage<Params>()is phantom — never set at runtime, used for client-side type inference. authdefaults totrue.ssrdefaults tofalse.- Query-string params are declared in
Paramsbut never appear in the path template.
Client
bootstrapApp()
The recommended entrypoint. Handles auth detection, socket creation, optional auto-login through the ugly.bot iframe, and provider wiring.
// client/main.tsx
import { bootstrapApp, FeedbackButton } from 'ugly-app/client';
import { requests } from '../shared/api';
import { RouterProvider, RouterView } from './router';
import './styles.css';
bootstrapApp({
requests,
RouterProvider,
render: () => (
<>
<RouterView />
<FeedbackButton />
</>
),
strings: { /* optional StringsProviderConfig */ },
});BootstrapAppOptions:
| Field | Description |
|-------|-------------|
| requests | Your RequestRegistry (merged with framework requests internally). |
| messages? | Your MessageRegistry (merged with framework messages). |
| RouterProvider | The RouterProvider returned from createRouter(). |
| render | Callback returning the app's UI tree (typically <RouterView /> + <FeedbackButton />). |
| root? | Root element / selector (default '#root'). |
| fallback? | UI for unmatched routes (default: tiny "404"). |
| socketUrl? | Override the WebSocket path (default /rpc). |
| strings? | Localization config — when present, wraps the tree with <StringsProvider>. |
| keyboard?: false | Disable the framework <KeyboardProvider> wrapper. |
bootstrapApp reads window.__AUTH_TOKEN__ (injected by the server). If absent, it renders unauthenticated and lets the router show loginFallback for auth-guarded pages. If present, it connects the socket, mounts <AppProvider>, and renders.
Routing — createRouter()
// client/router.ts
import { createRouter } from 'ugly-app/client';
import { pages } from '../shared/pages';
import { allPages } from './allPages';
export const { RouterProvider, RouterView, useRouter } = createRouter({
pages,
allPages,
});createRouter returns:
RouterProvider— props:children,fallback?,loginFallback?,isAuthenticated?,autoLogin?. Manages route state, browser history, popups, and<AutoLoginGate>(silent iframe-based login check against ugly.bot).RouterView— renders the active page with animated transitions. Props:durationMs?,easing?,transitionComponent?(replacesViewFlipper),renderPage?(sync alternative toallPagesloaders).useRouter()— returns the router context (see below).
Page map — lazyPage / lazyPageLoader
// client/allPages.ts
import { lazyPage, lazyPageLoader } from 'ugly-app/client';
import type { PageMap } from 'ugly-app/shared';
import type { AppPages } from '../shared/pages';
export const allPages = {
['']: lazyPage(() => import('./pages/HomePage')),
['user/:userId']: lazyPage(() => import('./pages/UserPage')),
['slow/:id']: lazyPageLoader(() => import('./pages/SlowPageLoader')),
} satisfies PageMap<AppPages>;lazyPage(factory)— lazy-imports a default-exportedReact.ComponentType<Params>. The page receives route params as props.lazyPageLoader(factory)— lazy-imports an async loader(params) => Promise<ReactElement>. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.
Example loader:
// pages/SlowPageLoader.tsx
import SlowPage from './SlowPage';
export default async function PageLoader({ id }: { id: string }) {
const data = await fetchSlowData(id);
return <SlowPage {...data} />;
}Navigation — useRouter()
const { current, push, replace, back, openPopup, closePopup, closeAllPopups } = useRouter();
push('user/:userId', { userId: '123' }); // → /user/123
replace('search', { q: 'hello' }); // → /search?q=hello
back(); // browser history back
current.routeName; // typed union of all route keys
current.params; // typed params for the current routeAll route names and params are fully typed against pages. Internally push / replace are no-ops when buildUrl() produces a URL that doesn't match a registered route (and emit a console.error).
Popups — openPopup()
Always use useRouter().openPopup() for modals, sheets, and menus. The router owns the popup layer, manages the spring animation, and stacks popups z-index-correctly.
const { openPopup } = useRouter();
const handle = openPopup(<MyContent />, {
mode: 'transient', // 'block' (default) | 'transient' | 'contextMenu'
slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none' (default)
onClose: () => {},
containerStyle: { /* CSS for the content wrapper */ },
backgroundStyle: { /* CSS for the backdrop */ },
animConfig: { duration: 300, easing: myEasingFn },
renderLayer: (props) => <CustomLayer {...props} />, // fully replace the layer renderer
});
handle.hide(); // dismiss programmaticallyModes:
block(default) — 40% opacity backdrop, does not dismiss on backdrop click.transient— 20% opacity backdrop, dismisses on backdrop click.contextMenu— same as transient, intended for menus and pickers.
renderLayer receives { content, spring, hide } — spring is an AnimatedValueRef driving 0 → 1, hide closes the popup.
AppProvider & useApp()
bootstrapApp mounts <AppProvider> automatically after socket connect. Use useApp() inside any page to access the active user and socket.
const {
userId, // current user id
user, // UserBase doc
socket, // AppSocket — typed RPC client
uglyBotSocket, // optional UglyBotSocket for direct platform calls (STT/TTS, etc.)
showPopup, // legacy popup API (prefer useRouter().openPopup)
hidePopup,
hideAllPopups,
runAsync, // runAsync('label', async () => { ... }) — shows loading overlay
splashDone, // mark a splash-screen step complete
localizer, // (key, params?) => string — alias for useLocalizer
} = useApp();useAppOptional() returns null outside the provider; useLocalizer() returns a localizer that falls back to identity.
Link component
import { Link } from 'ugly-app/client';
<Link router={router} to="user/:userId" params={{ userId: '123' }}>View profile</Link>Renders an <a> with the right href, intercepts clicks for client-side navigation, and lets ctrl/cmd+click open in a new tab.
Direct socket access
AppSocket is exposed through useApp().socket. Common methods:
| Method | Description |
|--------|-------------|
| request(name, input) | Invoke a typed RPC handler. |
| getDoc(collection, id) | Server-mediated doc fetch. |
| getDocs(collection, filter?, opts?) | Filtered query. |
| trackDoc(collection, id, cb) | Live subscription — returns unsubscribe. |
| trackDocs(collection, params, cb) | Live filtered subscription. |
| uploadFile(file, key) | Presigned upload to the temp bucket. |
| connectionState | 'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'idle-disconnected'. |
| disconnect() | Close the connection. |
For pure HTTP (no WebSocket), use createHttpClient({ requests, token?, baseUrl? }).
Auth
ugly-app uses HttpOnly cookies and server-side JWT injection — no localStorage, no client-side token handling.
Flow:
- Unauthenticated user lands on a page; the optional
<AutoLoginGate>opens a hidden iframe tohttps://ugly.bot/iframe-authto check for an existing platform session. - If found, the iframe posts an OAuth code back; the client POSTs to
/auth/verify; the server exchanges it throughuglyBotAuthProvider, sets theauth_tokenHttpOnly cookie, and the page reloads authenticated. - If not found (or timeout after 4 s),
<RouterProvider>showsloginFallbackfor auth-required routes. The default fallback is<LoginPopup>, which openshttps://ugly.bot/oauthin a popup window. - On every authenticated request, the server verifies the cookie token by calling
${UGLY_BOT_URL}/verifyand injectswindow.__AUTH_TOKEN__into the HTML so the client can pass it on the WebSocket handshake.
Token-in-URL embed mode: any GET request with ?token=<JWT> will, if the token verifies against ugly.bot, set the cookie and 302-redirect to the same URL without the token — letting any page be embedded in an iframe.
Built-in routes (mounted on every app):
| Endpoint | Description |
|----------|-------------|
| POST /auth/verify | Exchange an OAuth code for a session cookie. |
| POST /auth/logout | Clear the cookie. |
| GET /auth/token | Refresh and return the current token (used by clients that need an explicit token). |
| GET /auth/url | Return the OAuth popup URL. |
Custom provider:
configurator.setAuth({
verify: async (code) => ({ userId: '...', token: 'platform-issued-jwt' }),
authUrl: (origin) => `https://my-oauth.example/authorize?origin=${origin}`,
registerRoutes: (router) => { /* optional extra routes */ },
});Server-side helpers (ugly-app):
verifyToken(token)— verifies a token against ugly.bot and returns theuserId.getRequestUser(req)— synchronous decode of the per-projectUGLY_PROJECT_TOKENcookie set by ugly.bot's wake-on-traffic gate. Returns{ userId } | nullwithout a network round-trip; safe to use as the primary auth check in deployed-app handlers.
Database — TypedDB
Access via app.db or via import { app } from './your-app-module'. All methods accept a CollectionDef (from defineCollections) or a plain collection name string.
Writing
await db.setDoc(collections.note, doc); // upsert
await db.setDoc(collections.note, doc, { skipIfExists: true }); // insert-only
await db.setDocFields(collections.note, id, { title: 'New' }); // partial; throws if missing
await db.setDocFieldsOrIgnore(collections.note, id, { title }); // returns null if missing
await db.setDocFieldsOrCreate(collections.note, id, { title }, default); // upsert with default
await db.setDocOp(collections.note, id, { $inc: { views: 1 } }); // MongoDB-style ops
await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } });Supported update operators: $inc, $addToSet, $pull, $unset, $set. All keys are dot-notation, fully typed against the collection's schema.
Reading
const doc = await db.getDoc(collections.note, id);
const docs = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
// Typed SQL-native query API (preferred for new code)
const notes = await db.find(collections.note, { userId, done: { $ne: true } }, { sort: { created: -1 }, limit: 20 });
const count = await db.findCount(collections.note, { userId });
const sample = await db.findRandom(collections.note, { userId }, 5);
// Aggregation pipelines (legacy / advanced)
const results = await db.getQuery<MyResult>('note', pipeline, { skip, limit });
const total = await db.getQueryCount('note', pipeline);
// Dynamic / untyped access — when the collection name is a runtime string
await db.rawGetDoc('note', id);
await db.rawGetDocs('note', filter);Deleting
await db.deleteDoc(collections.note, id); // cascade-deletes children
await db.deleteWhere(collections.note, { userId }); // typed bulk delete
await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk deletePass deleteHandlers as the 5th argument to createApp to run per-collection onDelete callbacks.
Search
// Full-text search — requires `search: { fields, language? }` on the collection
const hits = await db.searchDocs(collections.note, 'react hooks', { limit: 10 });
// Vector search — requires `vector: { dimensions, source }` (uses Qdrant)
const similar = await db.vectorSearch(collections.note, embeddingVector, { limit: 10 });Caching
db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key); // broadcasts invalidation via NATS
const k = db.cacheKey('prefix', id);Helpers
import { createUserHelper, dbDefaults } from 'ugly-app';
const newDoc = { _id: crypto.randomUUID(), ...dbDefaults(), title: 'Hi' };
// ^^^^^^^^^^^^^^^ { version: 1, created, updated }
const userHelper = createUserHelper<User>(collections.user);
const user = await userHelper.get(db, userId);Direct SQL & infra
Imports available from ugly-app:
pgQuery(sql, params?)— parameterized SQL on the data proxy.ensureTable(...),tableExists(...),ensureSearchColumn(...).ensureQdrantCollection(...),upsertVector(...),searchVectors(...),deleteVector(...),deleteQdrantCollection(...).connectNats(),natsPublish(subject, payload),natsSubscribe(subject, cb),ensureKvBucket(name, opts),jsPublish(...),jsConsumerCreate(...),jsConsumerConsume(...).subscribeCollection,subscribeDoc,subscribeDocKey— NATS subjects emitted by the data proxy on writes.
AI
AI calls are proxied through ugly.bot — your app never holds an AI provider key. Pass UGLY_BOT_TOKEN in the environment and the framework handles routing, balance tracking, retries, and per-user billing.
Server-side text generation
import { createTextGenClient } from 'ugly-app';
const textGen = createTextGenClient(userId);
const text = await textGen.generate(messages, { model: 'gemini_2_5_flash' });Or call the framework textGen request directly:
import { uglyBotRequest } from 'ugly-app';
const { message } = await uglyBotRequest<{ message: { content: string } }>('textGen', {
model: 'gemini_2_5_flash',
messages: [{ role: 'user', content: 'Hello!' }],
options: { maxTokens: 512 },
});Available models are exposed via textGenModels / textGenModelData from ugly-app — the platform supports Claude, GPT, Gemini, Together, Groq, Fireworks, and Kie families.
Server-side image generation
import { createImageGenClient } from 'ugly-app';
const imageGen = createImageGenClient(userId);
const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });imageGenModels / imageGenModelData enumerate available models (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
Embeddings
import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const vector = await embeddings.embed('hello world');
const sim = cosineSimilarity(vectorA, vectorB);Web search
import { createWebSearchClient } from 'ugly-app';
const search = createWebSearchClient(userId);
await search.search({ query: 'react 19', limit: 10 });
await search.summarize({ url: 'https://...' });
await search.enrichWeb({ query: 'topic' });
await search.enrichNews({ query: 'topic' });Client-side AI calls
Calls from React components go through the framework RPC pipeline — no token plumbing in the browser:
import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
const text = await callTextGen({ messages, model: 'gemini_2_5_flash' });
const json = await callJsonGen({ messages, schema, model: 'gemini_2_5_flash' });
const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_schnell' });STT / TTS
Speech goes directly from the browser to ugly.bot — never proxied through your app server.
import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
const { start, stop, transcript, isListening } = useSTT(socket, options);
const { speak, stop: stopTTS } = useTTS(socket, { voice: 'alloy' });Storage
S3-compatible. Two logical buckets:
temp— short-lived uploads (presigned PUT from the browser).public— durable, served by CDN.
Server-side:
import { createStorageClient } from 'ugly-app';
const storage = createStorageClient();
await storage.put('temp', key, buffer, 'image/png');
const publicUrl = await storage.moveToPublic(tempKey, destKey);
const url = storage.url('public', destKey);
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);Client-side, use socket.uploadFile(file, key) — it requests a presigned URL via the built-in uploadUrl framework request and streams the upload. In dev, uploads go through a same-origin /_s3 proxy to avoid CORS with local MinIO.
STORAGE_KEY_PREFIX (env) prefixes all keys — useful for per-environment isolation.
Workers & cron
// shared/cron.ts
import { defineWorkers, z } from 'ugly-app/shared';
export const cronTasks = defineWorkers({
dailyCleanup: {
schedule: '0 3 * * *', // every day at 03:00 UTC
description: 'Delete completed todos older than 30 days',
},
resyncSearch: {
inputSchema: z.object({ since: z.string().datetime() }),
description: 'Re-embed search vectors since the given ISO timestamp',
},
});// server/index.ts
const cronHandlers: WorkerHandlers<typeof cronTasks> = {
dailyCleanup: async () => { /* runs on schedule */ },
resyncSearch: async ({ since }) => { /* runs on manual trigger from studio */ },
};
configurator.setWorkers(cronTasks, cronHandlers);Each worker can have inputSchema, outputSchema, schedule, timeout, description. Workers without a schedule are still invocable via POST /_workers/run (auth: localhost in dev, Authorization: Bearer $CRON_SECRET in prod). Scheduled workers also appear in /_cron/manifest for the deploy orchestrator.
Localization
configurator.setStrings({
defaultLang: 'en',
langs: ['en', 'es'],
criticalKeys: ['app.title', 'nav.home'],
getTable: (lang) => tables[lang] ?? tables.en,
});The framework injects window.__LANG__, window.__STRINGS_VERSION__, and window.__CRITICAL_STRINGS__ into SSR HTML. Use useLocalizer() / useStrings() / useLang() / useChangeLanguage() on the client.
Experiments
import type { Experiment } from 'ugly-app/shared';
export const experiments: Experiment[] = [
{
id: 'new-onboarding',
name: 'New Onboarding',
active: true,
branches: [
{ id: 'control', weight: 50 },
{ id: 'variant', weight: 50 },
],
events: ['ONBOARDING_COMPLETE'],
},
];
configurator.setExperiments(experiments);Bucketing is deterministic: hash(experimentId + userId) (or sessionId for unauthenticated users). The framework's initSession / captureEvent requests automatically tag events with the user's branch assignments.
Built-in endpoints
| Endpoint | Description |
|----------|-------------|
| GET /health | Health check — returns { status, timestamp, lastRequestAt }. |
| POST /api/:name | Dispatch any registered request handler over HTTP. |
| POST /auth/verify | Exchange OAuth code for session cookie. |
| POST /auth/logout | Clear the auth cookie. |
| GET /auth/token | Refresh and return the current token. |
| GET /auth/url | Get the OAuth popup URL. |
| GET /_workers/manifest | Worker definitions (used by ugly-studio). |
| POST /_workers/run | Synchronously invoke a worker handler. |
| GET /_workers/runs | Recent in-memory worker runs (last 200). |
| GET /_cron/manifest | Cron tasks for the deploy orchestrator. |
| POST /api/_cron/:taskName | Trigger a cron task (auth via CRON_SECRET). |
| POST /internal/email-callback | Inbound email gateway (auth via INTERNAL_EMAIL_SECRET). |
| PUT /_s3/* | Dev-only S3 upload proxy (avoids CORS with MinIO). |
Package entry points
| Import path | Description |
|-------------|-------------|
| ugly-app | Server: createApp, TypedDB, auth, AI clients, NATS, storage, email, push, workers. |
| ugly-app/shared | Cross-tier: defineRequests, defineCollections, definePage, defineWorkers, Zod, experiments, time constants. |
| ugly-app/client | React: bootstrapApp, createRouter, lazyPage, AppProvider, components, animations, audio, AI helpers. |
| ugly-app/conversation/{shared,server,client} | AI chat sessions with persisted history. |
| ugly-app/collab/{server,client} | Yjs-based collaborative editing. |
| ugly-app/markdown/{shared,client} | Markdown rendering + editor. |
| ugly-app/webrtc, ugly-app/webrtc/server | WebRTC video rooms. |
| ugly-app/three/{server,client} | Three.js scene helpers. |
| ugly-app/worker | Worker queue runtime. |
| ugly-app/playwright | Test utilities. |
| ugly-app/vite, ugly-app/eslint | Build-tool plugins. |
Environment variables
| Variable | Description |
|----------|-------------|
| PORT | Server port (templates default to 4321). |
| NODE_ENV | development or production. |
| UGLY_BOT_TOKEN | App token for the ugly.bot platform — required for AI, logs, billing. |
| UGLY_BOT_URL | Override the platform base URL (default https://ugly.bot). |
| DATA_PROXY_URL | WebSocket URL for the data proxy (default ws://localhost:4200). |
| DATA_PROXY_TOKEN | Auth token for the data proxy. |
| STORAGE_KEY_PREFIX | Prefix all storage keys (per-env isolation). |
| MINIO_ENDPOINT | Dev-only S3 endpoint for the upload proxy. |
| NATS_PREFIX / COMPOSE_PROJECT_NAME | NATS subject prefix for per-env isolation. |
| CLOCK_ENABLED | true to enable setOnMinuteTick / setOnHourlyTick. |
| CRON_SECRET | Bearer secret for POST /api/_cron/:taskName and prod POST /_workers/run. |
| MAINTAIN_BOT_USER_ID | User id allowed to access admin-only handlers. |
| INTERNAL_EMAIL_SECRET | Shared secret for /internal/email-callback. |
| JWT_SECRET | Required when using getRequestUser() for the per-project session cookie. |
| APP_DOMAIN | App domain; combined with NATS_PREFIX for getRequestUser() validation. |
| LOG_CAPTURE_URL | Studio override for client log capture (empty → ugly.bot default). |
| UGLY_APP_HMR | Set to false to disable Vite HMR in dev. |
| SCHEMA_CHECK_SKIP | true to start despite schema drift (unsafe). |
Browser-visible variables must be prefixed VITE_ and consumed via import.meta.env.VITE_*.
CLI
| Command | Description |
|---------|-------------|
| ugly-app init <name> | Scaffold a new project. |
| ugly-app upgrade | Upgrade framework config files to the latest version. |
| ugly-app configure | Generate/update .uglyapp config. |
| ugly-app login | Authenticate with ugly.bot. |
| ugly-app url | Print the local dev server URL. |
| ugly-app deploy | Build + push to production infrastructure. |
| ugly-app prod --buildId <id> | Promote a build to prod. |
| ugly-app versions | List deployed versions. |
| ugly-app versions:prune | Clean up non-prod versions. |
| ugly-app infra:destroy | Tear down all project infra. |
| ugly-app textGen [prompt] | Generate text via AI (--model, --system-prompt, --max-tokens, --json). |
| ugly-app imageGen [prompt] | Generate an image (--model, --output <path>). |
| ugly-app error:dev / error:prod | Query error logs (your tunnel / production). |
| ugly-app perf:dev / perf:prod | Query performance metrics. |
| ugly-app feedback:dev / feedback:prod | Query user feedback. |
| ugly-app feedback:submit / feedback:resolve | Manage feedback (run with --help for flags). |
Inside a scaffolded project, the same commands are available via npm run … scripts — see templates/CLAUDE.md for the full list.
Migrations
Schema changes must be deliberate:
- Update the Zod schema in
shared/collections.ts. - Run
npm run db:schema-gen— produces a migration file with compile-blockingREPLACE_MEplaceholders for any non-trivial change. - Replace every
REPLACE_MEwith the correct migration logic. - Run
npm run db:migrate.
The framework refuses to start when drift is detected (set SCHEMA_CHECK_SKIP=true only as a temporary escape hatch).
Tech stack
Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · ugly.bot platform
