ugly-app
v0.1.207
Published
A full-stack TypeScript framework for building production-ready web applications. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + MongoDB stack with built-in auth, real-time WebSockets, AI generation, storage, and a CLI f
Readme
ugly-app
A full-stack TypeScript framework for building production-ready web applications. Scaffold with npx ugly-app init my-app and get an opinionated Express + React + MongoDB stack with built-in auth, real-time WebSockets, AI generation, storage, and a CLI for every workflow.
What's included
- Server: Express + WebSocket with type-safe RPC and Zod validation
- Client: React + Vite with typed routing, lazy pages, and popup management
- Database: MongoDB with typed collections, dot-notation updates, indexes, migrations, and live document tracking
- Auth: JWT + HttpOnly cookies, ugly.bot OAuth out of the box, extensible via
AuthProvider - AI: Text generation (Together, Claude, OpenAI, Google, Groq, Fireworks, Kie) + image generation (Together, FAL, Google, Wavespeed, Kie) + embeddings + STT/TTS
- Storage: Cloudflare R2 / AWS S3 with presigned uploads
- Web search: Kagi and UglyBot providers with search, summarize, and enrich
- Billing: Usage-based billing with per-user/global limits, credits, and threshold alerts
- CLI:
ugly-appcommands for dev, build, deploy, migrations, logs, and auth utilities
Quick start
npx ugly-app init my-app
cd my-app
npm run devServer
createApp()
Entry point for the server. Creates an Express + WebSocket server with typed RPC, auth, and all framework services.
import {
createApp,
createUserHelper,
getFeedbackHandlers,
type AppConfigurator,
type RequestHandlers,
} from 'ugly-app';
import { dbDefaults } from 'ugly-app/shared';
import { requests } from '../shared/api';
import { collections } from '../shared/collections';
import { pages } from '../shared/pages';
import type { User } from '../shared/collections';
const userHelper = createUserHelper<User>(collections.user);
const maintainBotUserId = process.env.MAINTAIN_BOT_USER_ID ?? '';
const app = createApp(
{ requests },
{
...getFeedbackHandlers(maintainBotUserId),
getMe: async (userId: string) => {
const user = await userHelper.get(app.db, userId);
return { userId, email: user?.email, phone: user?.phone };
},
} satisfies RequestHandlers<typeof requests>,
collections,
(configurator: AppConfigurator) => {
configurator.setPages({ pages });
configurator.setUserHelper(userHelper);
configurator.setOnUserCreate(async (userId, info, db) => {
await userHelper.set(db, { id: userId, ...dbDefaults(), ...info });
});
},
);
const port = parseInt(process.env['PORT'] ?? '3000');
await app.start(port);Signature:
function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
registry: R,
requests: Partial<RequestHandlers<R['requests']>>,
appDefs: Defs,
configure?: (configurator: AppConfigurator) => void,
): App;The returned App object has:
start(port?)— starts the server (default port 3000)registerRoutes(fn)— mount additional Express routes after creationhttpServer— the underlying Node.js HTTP serverdb— theTypedDBinstance for direct database accesswss— the mainWebSocketServerinstance (path configured viasetWsPath, default'/rpc')dispatch(name, input, userId)— invoke an RPC handler programmatically
AppConfigurator
The optional fourth argument to createApp receives a configurator object:
| Method | Description |
|--------|-------------|
| setPages(options) | Serves pages with Vite (dev) or static files (prod). options: { pages, renderPage?, clientDistPath? } |
| setUserHelper(helper) | User lookup for WebSocket auth handshake |
| setOnUserCreate(handler) | Called on first login — must create the user record. (userId, { email?, phone? }, db) => Promise<void> |
| setAuth(provider) | Custom AuthProvider (default: ugly.bot OAuth). Provider must implement verify(code) and authUrl(origin) |
| setOnSocketMessage(handler) | Handle raw WebSocket messages. Return true to consume, false to let the framework handle it |
| registerRoutes(fn) | Mount custom Express routes — (router: express.Router) => void |
| setWorkerQueue(queue) | Register a background worker queue with start() and stop() |
| setWsPath(path) | Override the WebSocket path (default: '/rpc') |
| setOnWsAuth(handler) | Called after a WebSocket session is authenticated. (ws, userId, req) => void |
| setOnAfterStart(handler) | Called once after MongoDB, Redis, and NATS are ready. (db) => Promise<void> |
| setOnMinuteTick(handler) | Fires every minute (only when CLOCK_ENABLED=true). () => Promise<void> |
| setOnHourlyTick(handler) | Fires when the hour changes (only when CLOCK_ENABLED=true). (now, currentHour) => Promise<void> |
| setHealthHandler(handler) | Override the default GET /health endpoint handler. (req, res) => void |
Handler signatures
Handlers are plain async functions — no context object, just userId and input:
// req() — public, userId may be null
getPublicData: async (userId: string | null, input) => { ... }
// authReq() — authenticated, 401 auto-enforced, userId always a string
getMe: async (userId: string, input) => { ... }Access app.db, storage, and AI clients directly via imports or the app object — they are not injected into handlers.
Shared API definitions
All type definitions live in shared/ and are used by both server and client.
Requests (shared/api.ts)
import { authReq, defineRequests, req, z } from 'ugly-app/shared';
export const requests = defineRequests({
// Public request — handler receives (userId: string | null, input)
getPublicData: req({
input: z.object({ id: z.string() }),
output: z.object({ data: z.string() }),
}),
// Authenticated request — handler receives (userId: string, input)
getMe: authReq({
input: z.object({}),
output: z.object({ userId: z.string(), email: z.string().optional() }),
}),
// With rate limiting
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 },
}),
});req({ input, output })— defines a public request. Handler signature:(userId: string | null, input: I) => Promise<O>.authReq({ input, output })— defines an authenticated request. Handler signature:(userId: string, input: I) => Promise<O>. Returns 401 automatically if no token.defineRequests()— identity wrapper that preserves types.zis re-exported from Zod for convenience.
Every endpoint is accessible via both WebSocket (socket.request(name, input)) and HTTP (POST /api/:name { input }).
Collections (shared/collections.ts)
import { defineCollections } from 'ugly-app/shared';
import type { Note } from './types';
export const collections = defineCollections({
note: {
type: {} as Note,
meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
},
user: {
type: {} as User,
meta: { cache: true, trackable: false, public: false, cascadeFrom: null },
},
});Each collection definition has:
type— phantom field for TypeScript type inference (never read at runtime)meta— runtime metadata:cache— enable in-memory LRU caching forgetDoc;setDoc/deleteDocinvalidate ittrackable— allow real-timetrackDoc/trackDocssubscriptions via Change Streamspublic— allows client reads viagetDoc/trackDoc/trackDocswithout authcascadeFrom— parent collection name for cascade deletes (ornull)trackKeys?— fields usable as NATS routing keys fortrackDocs
onDelete?— optional async callback invoked on document deletion
All documents extend DBObject: { _id: string, version: number, created: Date, updated: Date }.
Pages (shared/pages.ts)
import { definePage, definePages } from 'ugly-app/shared';
export const pages = definePages({
'': definePage<{}>({ auth: false }), // /
'note/:noteId': definePage<{ noteId: string }>(), // /note/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;:param— single path segment;*param— greedy rest (captures slashes)auth: true(default) — requires login;auth: false— publicssr: true— server-renders the page (supplyrenderPagetosetPages())- The generic type parameter on
definePage<Params>()types the route's params — it's phantom (never set at runtime)
Client
Entry point (client/main.tsx)
import { createRoot } from 'react-dom/client';
import { AppProvider, createSocket, LoginPopup } from 'ugly-app/client';
import { requests } from '../shared/api';
import { RouterProvider } from './router';
import App from './App';
const token = (window as unknown as { __AUTH_TOKEN__?: string }).__AUTH_TOKEN__;
const root = createRoot(document.getElementById('root')!);
const loginPopup = <LoginPopup onSuccess={() => window.location.reload()} />;
if (!token) {
root.render(
<RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => false}>
<App socket={null} />
</RouterProvider>,
);
} else {
const userId = JSON.parse(atob(token.split('.')[1]!)).sub as string;
const socket = createSocket({ requests, url: '/rpc' });
socket.connect(token).then((user) => {
root.render(
<RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => true}>
<AppProvider socket={socket} userId={userId} user={user}>
<App socket={socket} />
</AppProvider>
</RouterProvider>,
);
});
}createSocket()
Creates a typed WebSocket client for RPC communication.
const socket = createSocket({ requests, url: '/rpc' });
await socket.connect(token); // returns UserBaseAppSocket methods:
| Method | Description |
|--------|-------------|
| connect(token) | Authenticate and connect. Returns the user object |
| request(name, input) | Invoke a typed request (query or mutation) |
| getDoc(collection, id) | Fetch a single document |
| getDocs(collection, filter?, opts?) | Query documents (filter, sort, limit, skip) |
| getQuery(collection, pipeline, opts?) | Run an aggregation pipeline |
| trackDoc(collection, id, cb) | Subscribe to real-time doc changes. Returns unsubscribe fn |
| trackDocs(collection, params, cb) | Subscribe to query results (keys, filter, sort, limit, skip). Returns unsubscribe fn |
| uploadFile(file, key) | Upload a file via presigned URL |
| emit(type, data) | Send a fire-and-forget message over WebSocket |
| send(type, data, timeout?) | Send a message and wait for a response |
| waitForConnection(timeout?) | Wait until the socket is connected |
| connectionState | Current state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'idle-disconnected' |
| disconnect() | Close the connection |
createSocket() options:
| Option | Description |
|--------|-------------|
| requests | The requests registry from shared/api.ts |
| url? | WebSocket path (default: '/rpc') |
| buildId? | Build identifier sent on connect |
| onCustomMessage? | Handle custom server-pushed messages |
| getUrlParams? | Extra query params appended to the WebSocket URL |
| messageReviver? | JSON reviver for incoming messages (e.g. Date parsing) |
createHttpClient()
Creates a typed HTTP client for RPC communication (no WebSocket needed).
import { createHttpClient } from 'ugly-app/client';
const client = createHttpClient({ requests, token: 'eyJ...', baseUrl: '' });
const result = await client.request('getMe', {});| Option | Description |
|--------|-------------|
| requests | The requests registry from shared/api.ts |
| token? | Bearer token for authenticated requests |
| baseUrl? | URL prefix (default: '' — relative paths: POST /api/:name) |
AppProvider
Wraps your app with context for useApp(). Provides user info, socket access, popup management, async loading overlay, splash screen, and localization.
<AppProvider
socket={socket}
userId={userId}
user={user}
splashScreen={<MySplash />} // optional
loadingOverlay={<MyLoader />} // optional — shown during runAsync()
localizer={(key, params) => t(key)} // optional i18n function
>
{children}
</AppProvider>useApp() returns:
| Field | Description |
|-------|-------------|
| userId | Current user ID |
| user | Current UserBase object |
| socket | The AppSocket instance |
| showPopup(content) | Show a popup (returns popup ID) |
| hidePopup(id) | Hide a specific popup |
| hideAllPopups() | Dismiss all popups |
| runAsync(label, fn, opts?) | Run an async operation with loading overlay |
| splashDone(step) | Mark a splash screen step as complete |
| localizer(key, params?) | Localize a string key |
useAppOptional() returns the same context or null if outside <AppProvider>.
useLocalizer() returns the localizer function (falls back to identity if outside provider).
Router
Setup (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 three things:
RouterProvider— wrap your app. Props:children,fallback?(shown before first route resolves),loginFallback?(shown for auth-guarded pages when unauthenticated),isAuthenticated?(function returning boolean)RouterView— renders the active page with animated transitions. Props:durationMs?,easing?,transitionComponent?,renderPage?useRouter()— hook returning the router context
Page map (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')),
['note/:noteId']: lazyPage(() => import('./pages/NotePage')),
['search']: lazyPage(() => import('./pages/SearchPage')),
['slow']: lazyPageLoader(() => import('./pages/SlowPageLoader')),
} satisfies PageMap<AppPages>;lazyPage(factory)— lazy-imports a default-exported React component. The component receives route params as props.lazyPageLoader(factory)— lazy-imports an async loader function(params) => Promise<ReactElement>for routes that need data fetching before render. The loader file is the chunk boundary — it can statically import its page component.
lazyPageLoader example:
// pages/SlowPageLoader.tsx
import SlowPage from './SlowPage'; // static import OK — same chunk
export default async function PageLoader({ id }: { id: string }) {
const data = await fetchSlowData(id);
return <SlowPage {...data} />;
}Navigation with useRouter()
const { push, replace, back, current, openPopup, closePopup, closeAllPopups } = useRouter();
push('note/:noteId', { noteId: '123' }); // pushes /note/123
replace('search', { q: 'hello' }); // replaces with /search?q=hello
back(); // browser back
// current route state
current.routeName; // e.g. 'note/:noteId'
current.params; // e.g. { noteId: '123' }All route names and params are fully typed based on your pages definition.
Popups
Always use useRouter().openPopup() — never build custom fixed overlays.
const { openPopup } = useRouter();
const handle = openPopup(<MyContent />, {
mode: 'transient', // 'block' | 'transient' | 'contextMenu'
slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none'
onClose: () => {}, // called when popup closes
containerStyle: {}, // CSS for the content wrapper
backgroundStyle: {}, // CSS for the backdrop
animConfig: { duration: 300, easing: myEasingFn },
renderLayer: (props) => <CustomLayer {...props} />, // fully replace the popup renderer
});
handle.hide(); // dismiss programmaticallyPopup modes:
block(default) — dark backdrop (40% opacity), clicking backdrop does NOT dismisstransient— light backdrop (20% opacity), clicking backdrop dismissescontextMenu— same as transient, intended for menus and pickers
renderLayer receives { content, spring, hide } — spring is an animated value from 0 to 1, hide is a function to close the popup.
Link component
import { Link } from 'ugly-app/client';
<Link router={router} to="note/:noteId" params={{ noteId: '123' }}>
View Note
</Link>Renders an <a> tag with the correct href. Intercepts clicks for client-side navigation (ctrl/cmd+click opens in new tab).
Animation system
Built-in animation primitives for transitions and popups. Animated.div and Animated.span accept AnimatedStyle — a style object where any CSS property can be an AnimatedValueRef or a TransformedValue instead of a static value.
import {
Animated,
createAnimatedValue,
useAnimatedValue,
easingFunctions,
FadeIn,
SlideFromBottom,
SlideFromRight,
} from 'ugly-app/client';
// Create an animated value (0->1 spring)
const spring = createAnimatedValue(0);
spring.start(1, { duration: 300, easing: easingFunctions.easeOut });
// Use in components — animated properties bypass React re-renders via direct DOM mutation
<Animated.div style={{ opacity: spring.to((v) => String(v)) }}>
Content
</Animated.div>
// Hook version — creates and manages an animated value in a component
const anim = useAnimatedValue(0);
// Pre-built entrance animations (wrap any children)
<FadeIn>{children}</FadeIn>
<SlideFromBottom>{children}</SlideFromBottom>
<SlideFromRight>{children}</SlideFromRight>AnimatedValueRef API:
| Member | Description |
|--------|-------------|
| current | Current numeric value |
| target | Target value |
| isAnimating | Whether an animation is in progress |
| start(target, config?) | Animate to target. Returns Promise<void> |
| set(value) | Jump to value immediately (no animation) |
| stop() | Cancel the current animation |
| subscribe(cb) | Subscribe to value changes. Returns unsubscribe fn |
| to(transform) | Create a transformed value for use in Animated styles |
AnimConfig: { duration?, easing?, onRest?, onUpdate?, immediate? }
Available easings: easingFunctions.linear, easeIn, easeOut, easeInOut, springGentle, springSnappy, springBouncy, slow.
Additional animation hooks:
useAnimatedTransition/useAnimatedPresence— mount/unmount transitions with phase trackinguseScrollAnimation/useStaggerAnimation— scroll-driven and staggered entrance animations
Screenshot capture
import { captureScreenshot } from 'ugly-app/client';
const dataUrl = await captureScreenshot(); // captures the current viewportUI components
ugly-app/client exports a set of built-in UI components:
Button, Card, EnumInput, Header, Image, Input, Modal, PageLayout, Panel, PopupPanel, Pressable, ScrollView, SettingGroup, Text, Toast, View, ResponsiveGrid, TabPicker, HeaderTabPicker, TabContent, TabContentAllActive
Auth
Auth uses HttpOnly cookies with server-side JWT injection. No localStorage needed.
Flow:
- User opens
<LoginPopup />which redirects to OAuth athttps://ugly.bot/oauth - Browser posts the auth code to
POST /auth/verify— server setsauth_tokenHttpOnly cookie - On every page load the server refreshes the cookie and injects the token into HTML:
<script>window.__AUTH_TOKEN__ = "eyJ..."</script> - Client reads
window.__AUTH_TOKEN__synchronously and connects the WebSocket
Logout:
await fetch('/auth/logout', { method: 'POST' });
window.location.reload();Built-in auth endpoints:
| Endpoint | Description |
|----------|-------------|
| POST /auth/verify | Exchange OAuth code for a 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 |
Custom auth provider:
configurator.setAuth({
verify: async (code: string) => ({ userId: '...', email: '...' }),
authUrl: (origin: string) => 'https://my-oauth.com/authorize?...',
registerRoutes: (router) => { /* optional extra routes */ },
});The AuthProvider interface:
interface AuthProvider {
verify(code: string): Promise<{ userId: string; email?: string; phone?: string; token?: string }>;
authUrl(origin: string): string;
registerRoutes?(router: express.Router): void;
}Database (TypedDB)
Access via app.db or by importing createTypedDB / getMongoClient from 'ugly-app'.
All methods accept either a CollectionDef object (from defineCollections) or a plain collection name string as the first argument.
Writing
// Insert or replace a document
await db.setDoc(collections.note, doc);
await db.setDoc(collections.note, doc, { skipIfExists: true });
// Partial update — only specified fields (supports dot-notation paths)
await db.setDocFields(collections.note, id, { title: 'New title' });
// Partial update — returns null if document doesn't exist (no error)
const doc = await db.setDocFieldsOrIgnore(collections.note, id, { title });
// Partial update — creates the document if it doesn't exist (obj = default doc for insert)
await db.setDocFieldsOrCreate(collections.note, id, { title }, defaultDoc);
// MongoDB update operators ($inc, $addToSet, $pull, $unset, $set)
await db.setDocOp(collections.note, id, { $inc: { views: 1 } });
await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } }); // no error if missingReading
const note = await db.getDoc(collections.note, id);
const notes = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
// Aggregation pipeline
const results = await db.getQuery<MyResult>('note', pipeline, { skip, limit });
const count = await db.getQueryCount('note', pipeline);
const raw = await db.getQueryRaw<T>('note', pipeline);
// Dynamic/untyped access (when collection name is a runtime string)
const doc = await db.rawGetDoc(collectionName, id);
const docs = await db.rawGetDocs(collectionName, filter);Deleting
await db.deleteDoc(collections.note, id); // single doc (cascades via cascadeFrom)
await db.deleteQuery(collections.note, { userId }); // bulk delete by filter (cascades + calls onDelete)Caching
const cached = db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key);
const key = db.cacheKey('prefix', id); // generate a cache keyHelpers
import { createUserHelper } from 'ugly-app';
import { dbDefaults } from 'ugly-app/shared';
// dbDefaults() returns { version: 1, created: new Date(), updated: new Date() }
const doc = { id: newId(), ...dbDefaults(), title: 'Hello' };
// createUserHelper — typed user CRUD with get, set, update methods
const userHelper = createUserHelper<User>(collections.user);
const user = await userHelper.get(db, userId);
await userHelper.set(db, { id: userId, ...dbDefaults(), email });Indexes
// shared/dbIndexes.ts
import { defineDbIndexes } from 'ugly-app/shared';
export const dbIndexes = defineDbIndexes({
note: {
indexes: [
{ fields: { userId: 1, created: -1 } },
{ fields: { title: 1 }, unique: true },
],
searchIndexes: [
{ name: 'note_search', fields: { title: 'string', body: 'string' } },
],
vectorIndexes: [
{ name: 'note_embedding', field: 'embedding', dimensions: 1536, similarity: 'cosine' },
],
},
});Run npm run db:init to create/update indexes.
AI
Text generation
import { createTextGenClient } from 'ugly-app';
const textGen = createTextGenClient();
const text = await textGen.generate(messages);
const json = await textGen.generateJson(schema, messages); // Zod schema, retries on parse failure
const result = await textGen.generateWithTools(messages, tools); // automatic tool-call loop| Provider | provider value | Default model | JSON | Tools | Vision |
|----------|-----------------|---------------|------|-------|--------|
| Together AI | 'together' | Llama-4-Maverick-17B-128E | yes | yes | yes |
| Anthropic | 'claude' | claude-sonnet-4-6 | yes | yes | yes |
| OpenAI | 'openai' | gpt-4o | yes | yes | yes |
| Google | 'google' | gemini-2.5-flash | yes | yes | yes |
| Groq | 'groq' | llama-3.3-70b-versatile | yes | yes | no |
| Fireworks | 'fireworks' | llama-v3p1-70b-instruct | yes | yes | yes |
| Kie.ai | 'kie' | gemini-2.0-flash | yes | yes | yes |
Use provider: 'auto' (default) to let the system pick based on requirements.
Image generation
import { createImageGenClient } from 'ugly-app';
const imageGen = createImageGenClient();
const url = await imageGen.generate(prompt, { width: 1024, height: 1024 });| Provider | provider value |
|----------|-----------------|
| Together AI (FLUX schnell) | 'together' |
| FAL (FLUX pro) | 'fal' |
| Google (Imagen 3) | 'google' |
| Wavespeed | 'wavespeed' |
| Kie.ai (Kolors) | 'kie' |
Embeddings
import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const similarity = cosineSimilarity(vectorA, vectorB);Speech-to-text (STT)
Server-side STT uses a provider registry pattern. Providers auto-register when their API key env var is set.
import { registerSTTProvider, selectSTTProvider, getAllSTTProviders } from 'ugly-app';Built-in STT providers:
| Provider | Import | Env var | Mode |
|----------|--------|---------|------|
| Deepgram (nova-2) | deepgramSTTProvider | DEEPGRAM_API_KEY | Real-time streaming via WebSocket |
| OpenAI Whisper | openAIWhisperSTTProvider | OPENAI_API_KEY | Batch (buffers audio, transcribes on stop) |
| Groq Whisper | groqWhisperSTTProvider | GROQ_API_KEY | Batch |
STT provider interface:
interface STTProvider {
name: string;
apiKeyEnv: string;
connect(
onTranscript: (result: STTTranscript) => void,
onError: (err: string) => void,
lang?: string,
options?: STTConnectOptions,
onUsage?: (report: STTUsageReport) => void,
): Promise<STTSession>;
}
interface STTSession {
sendAudio(pcm16: Buffer): void; // PCM16 at 16kHz mono
stop(): Promise<void>;
}
interface STTTranscript { text: string; isFinal: boolean; lang?: string; words?: STTWord[] }Client-side hook:
import { useSTT } from 'ugly-app/client';
const { start, stop, transcript, isListening } = useSTT(socket, options);Text-to-speech (TTS)
import { registerTTSProvider, selectTTSProvider, azureTTSProvider } from 'ugly-app';Built-in TTS provider:
| Provider | Import | Env vars |
|----------|--------|----------|
| Azure TTS | azureTTSProvider | AZURE_TTS_KEY, AZURE_TTS_REGION |
TTS provider interface:
interface TTSProvider {
name: string;
apiKeyEnv: string;
stream(text: string, voice: string, options?: TTSStreamOptions): AsyncGenerator<TTSChunk>;
}
interface TTSChunk {
audio: Buffer; // PCM16, mono, 24kHz
word?: string;
startMs?: number;
durationMs?: number;
visemes?: TTSViseme[]; // When requestVisemes=true (for lip sync)
}Client-side hook:
import { useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';Web search
import { createWebSearchClient, registerWebSearchProvider } from 'ugly-app';
const search = createWebSearchClient(userId);
const results = await search.search({ query: 'hello', limit: 10 });
const summary = await search.summarize({ url: 'https://...' });
const web = await search.enrichWeb({ query: 'topic' });
const news = await search.enrichNews({ query: 'topic' });WebSearchClient methods:
| Method | Description |
|--------|-------------|
| search({ query, limit? }) | Web search — returns { items, related? } |
| summarize({ url?, text? }) | Summarize a URL or text — returns summary string |
| enrichWeb({ query }) | Enriched web results |
| enrichNews({ query }) | Enriched news results |
Built-in providers: Kagi (KAGI_API_KEY) and UglyBot.
Custom providers
import { registerTextGenProvider, registerImageGenProvider, registerEmbeddingProvider } from 'ugly-app';
registerTextGenProvider('myProvider', myTextGenImplementation);
registerImageGenProvider('myProvider', myImageGenImplementation);
registerEmbeddingProvider('myProvider', myEmbeddingImplementation);Client-side AI calls
The client can call AI through the server proxy without managing tokens directly:
import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
const text = await callTextGen(input);
const json = await callJsonGen(input);
const image = await callImageGen(input);These call POST /ai/request (same-origin, avoids CORS). The server verifies the user JWT and forwards to https://ugly.bot using the UGLY_BOT_TOKEN env var.
Storage
// Server-side — put a file
const url = await storage.put('temp', key, buffer, 'image/png');
// Move from temp to public bucket
const publicUrl = await storage.moveToPublic(tempKey, destKey);
// Get a public URL
const url = storage.url('public', destKey);
// Browser direct upload via presigned URL
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);Buckets: 'public' and 'temp'. Supports Cloudflare R2 (production) or MinIO (dev).
The StorageClient interface:
interface StorageClient {
put(bucket: 'public' | 'temp', key: string, body: Buffer, contentType: string): Promise<string>;
moveToPublic(tempKey: string, destKey: string): Promise<string>;
url(bucket: 'public' | 'temp', key: string): string;
presignedPut(bucket: 'temp', key: string): Promise<{ uploadUrl: string; resultUrl: string }>;
}Static build-time assets go in client/public/. Never hardcode /asset/... paths — use the buildId from shared/Build.ts.
Billing
Usage-based billing with per-user limits, global provider limits, pre-paid credits, and threshold alerts.
import { initBillingGateway, getBillingGateway } from 'ugly-app';
const billing = initBillingGateway(db, {
global: { hourlyUsd: 100, thresholdPct: 0.8 },
providers: {
openai: { hourlyUsd: 50, thresholdPct: 0.9 },
},
profitMarginPct: 0.2, // 20% markup on costs
});
// Charge a user
await billing.charge({
userId: '...',
provider: 'openai',
model: 'gpt-4o',
type: 'textGen', // 'textGen' | 'imageGen' | 'stt' | 'tts' | 'embedding' | 'service'
costUsd: 0.03,
inputTokens: 1000,
outputTokens: 500,
});
// Pre-flight check
const canPay = await billing.canCharge(userId, 0.05);
// Grant credits
await billing.grantCredit(userId, 10.00);
// Query spend
const usage = await billing.getSpend(userId, { from, to });
// Threshold callbacks
billing.setUserThresholdCallback((userId, period, spend, limit) => { /* alert */ });
billing.setGlobalThresholdCallback((period, spend, limit) => { /* alert */ });Per-user limits are resolved via setUserLimitHook():
billing.setUserLimitHook(async (userId) => ({
hourlyUsd: 5,
dailyUsd: 50,
weeklyUsd: 200,
thresholds: { hourly: 0.8, daily: 0.9, weekly: 0.95 },
}));The billing state machine tracks spend across hourly, daily, and weekly windows. Global and per-provider limits are always enforced; user credits act as a fallback when user limits are exceeded but never bypass global limits.
Experiments
A/B testing with deterministic user bucketing and event tracking.
// shared/experiments.ts
import type { Experiment } from 'ugly-app/shared';
export const experiments: Experiment[] = [
{
id: 'new-onboarding',
name: 'New Onboarding Flow',
description: 'Test the redesigned onboarding',
active: true,
branches: [
{ id: 'control', name: 'Control', weight: 50 },
{ id: 'variant', name: 'Variant', weight: 50 },
],
events: ['ONBOARDING_COMPLETE', 'ONBOARDING_SKIP'],
},
];Server-side assignment:
import { getExperimentAssignments, getExperimentBranch } from 'ugly-app';
// Get all active experiment assignments for a user
const branches = getExperimentAssignments(userId, sessionId, experiments);
// => { 'new-onboarding': 'variant' }
// Get a single experiment branch
const branch = getExperimentBranch(experiment, userId, sessionId);Bucketing uses a deterministic hash of experimentId:userId (or sessionId if no user), so the same user always gets the same branch. Weights control the relative distribution across branches.
Event logging
Server-side event capture for analytics, tied to experiments.
import { eventLogCapture, eventLogServerCapture } from 'ugly-app';
// Capture with returned eventId
const { eventId } = await eventLogCapture({
eventName: 'BUTTON_CLICK',
sessionId,
userId,
properties: { page: 'home' },
experimentBranches: branches,
}, userId);
// Fire-and-forget server capture
await eventLogServerCapture('SESSION_START', { source: 'web' }, sessionId, userId, branches);Query functions:
| Function | Description |
|----------|-------------|
| eventLogGetList(input) | Paginated event list (cursor, date range, filters) |
| eventLogGetTopUsers(input) | Top users by event count |
| eventLogGetTopSessions(input) | Top sessions by event count |
| eventLogGetTopEvents(input) | Top events by frequency |
| eventLogGetCounts(input) | Time-series counts (granularity: 'seconds' | 'minutes' | 'days') |
| eventLogGetUniqueUsersCounts(input) | Unique users per time interval |
| eventLogGetUniqueSessionsCounts(input) | Unique sessions per time interval |
Additional server APIs
import { sendEmail, sendTemplateEmail, loadEmailTemplate } from 'ugly-app';
await sendEmail({ to: '[email protected]', subject: 'Hello', html: '<p>Hi</p>' });
await sendTemplateEmail('welcome', { name: 'Alice' }, { to: '[email protected]' });Push notifications
import { sendPush, sendFcmPush } from 'ugly-app';
await sendPush(userId, { title: 'New message', body: 'You have a new message' });NATS (pub/sub)
import { natsPublish, natsSubscribe, subscribeCollection, subscribeDoc } from 'ugly-app';
natsPublish('my.subject', payload);
const sub = natsSubscribe('my.subject', (msg) => { /* ... */ });
sub(); // unsubscribeRedis
import { getRedisClient, redisGet, redisSet, redisDel, redisPublish, redisSubscribe } from 'ugly-app';
const redis = getRedisClient();Worker queues
import { createWorkerQueue } from 'ugly-app';
const queue = createWorkerQueue({
streamName: 'JOBS', // NATS stream name (default: 'JOBS')
concurrency: 10, // max concurrent jobs (default: 10)
maxRetries: 3, // max retry attempts (default: 3)
clockServerOnly: true, // only process if IS_CLOCK_SERVER=true (default: true)
});
queue.registerHandler<MyPayload>('sendEmail', async (job) => {
job.working(); // extend ack deadline for long-running jobs
await doWork(job.payload);
});
await queue.enqueue('sendEmail', { to: '[email protected]' }, { delay: 5000 });
configurator.setWorkerQueue(queue); // register with the app for lifecycle managementScheduled tasks — prevent duplicate enqueuing via MongoDB upsert:
import { enqueueTask } from 'ugly-app';
await enqueueTask(taskDoc, queue, { delay: 60000 });Billing
import { initBillingGateway, getBillingGateway } from 'ugly-app';
await initBillingGateway({ /* config */ });
const billing = getBillingGateway();Client error capture
import { captureClientError, initClientLogger } from 'ugly-app/client';
initClientLogger(); // call once at startup — captures unhandled errors
captureClientError(error); // manually report an error to POST /api/client-errorFeedback
import { FeedbackButton, setFeedbackContext, clearFeedbackContext } from 'ugly-app/client';
// Render the built-in feedback button (bottom-right, always at [data-id="feedback-button"])
<FeedbackButton />
// Set contextual data attached to feedback submissions
setFeedbackContext({ page: 'editor', noteId: '123' });
clearFeedbackContext();Server-side, getFeedbackHandlers(maintainBotUserId) provides the RPC handlers for submitting and managing feedback. User feedback history is available at GET /my_feedback (requires auth cookie, returns markdown).
Rate limiting
Rate limiting is configured per-endpoint in the request definition:
submitFeedback: authReq({
input: z.object({ ... }),
output: z.object({ ... }),
rateLimit: { max: 20, window: 60 }, // 20 requests per 60 seconds
})The framework enforces rate limits automatically before calling the handler.
Built-in endpoints
| Endpoint | Description |
|----------|-------------|
| GET /health | Health check — returns { status: 'ok', timestamp } |
| POST /auth/verify | Exchange OAuth code for a 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 |
| POST /api/:name | RPC endpoint — dispatches any registered request handler |
| POST /ai/request | AI proxy — forwards to ugly.bot (requires auth) |
| POST /api/client-error | Client-side error capture |
| GET /my_feedback | User feedback history (markdown, requires auth) |
Migrations
Never change a collection field type without writing a migration:
// server/migrations/001-add-bio.ts
export const name = '001-add-bio';
export async function up(db: Db) {
await db.collection('user').updateMany({}, { $set: { bio: '' } });
}Run with npm run db:migrate. Use npm run db:migrate -- --status to preview pending migrations.
CLI reference
| 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 .uglyapp config |
| ugly-app dev | Start all dev services (Docker, server, Vite, tsc, eslint) |
| ugly-app build | Production build |
| ugly-app login | Login to ugly.bot |
| ugly-app db:init | Create/update MongoDB indexes |
| ugly-app db:migrate | Run pending migrations (--status to preview) |
| ugly-app deploy:single | Deploy to a single server |
| ugly-app deploy:multi | Deploy to multiple servers |
| ugly-app publish:assets | Push static assets to CDN (--dry-run to preview) |
| ugly-app purge:assets | Clean old builds (--keep <n>, --dry-run) |
| ugly-app test:e2e | Run Playwright end-to-end tests (--headed for browser) |
| ugly-app logs:local | Query local dev logs |
| ugly-app logs:server | Query server logs from MongoDB |
| ugly-app error:local / error:server | Query error logs |
| ugly-app perf:local / perf:server | Query performance metrics |
| ugly-app feedback | Query user feedback submissions |
| ugly-app auth:create-account | Create an account in the database |
| ugly-app auth:create-token | Generate a JWT for a userId |
| ugly-app textGen [prompt] | Generate text via AI (--model, --system-prompt, --max-tokens, --json) |
| ugly-app imageGen [prompt] | Generate an image via AI (--model, --output <path>) |
Package entry points
| Import path | Description |
|-------------|-------------|
| ugly-app | Server APIs (createApp, DB, auth, AI, email, storage, billing, worker queues, etc.) |
| ugly-app/shared | Shared types and utilities (defineRequests, defineCollections, definePage, experiments, Zod, etc.) |
| ugly-app/client | Client APIs (createSocket, createRouter, AppProvider, components, animations, audio, etc.) |
| ugly-app/playwright | Playwright test utilities |
| ugly-app/webrtc | WebRTC utilities |
Environment variables
| Variable | Description |
|----------|-------------|
| JWT_SECRET | Required — signs auth tokens |
| JWT_EXPIRY_SECONDS | Token lifetime (optional, default: 2592000 = 30 days) |
| MONGODB_URI | MongoDB connection string |
| PORT | Server port (default: 3000) |
| NODE_ENV | development or production |
| UGLY_BOT_TOKEN | App token for AI proxy (/ai/request) |
| REDIS_URL | Redis (optional, in-memory fallback for dev) |
| NATS_URL | NATS server URL |
| CLOCK_ENABLED | Set to true to enable setOnMinuteTick/setOnHourlyTick handlers |
| IS_CLOCK_SERVER | Set to true on the instance that should process delayed worker queue jobs |
| STORAGE_ACCOUNT_ID | Cloudflare R2 account ID |
| STORAGE_ACCESS_KEY_ID | R2 access key |
| STORAGE_SECRET_ACCESS_KEY | R2 secret key |
| STORAGE_PUBLIC_BUCKET | R2 public bucket name |
| STORAGE_TEMP_BUCKET | R2 temp bucket name |
| STORAGE_PUBLIC_URL | Base URL for public assets |
| TOGETHER_API_KEY | Together AI key |
| ANTHROPIC_API_KEY | Anthropic Claude key |
| OPENAI_API_KEY | OpenAI key |
| GOOGLE_API_KEY | Google Gemini key |
| GROQ_API_KEY | Groq key |
| KIE_API_KEY | Kie.ai key |
| KIE_BASE_URL | Kie.ai base URL override (optional) |
| DEEPGRAM_API_KEY | Deepgram STT key |
| AZURE_TTS_KEY | Azure TTS key |
| AZURE_TTS_REGION | Azure TTS region |
| KAGI_API_KEY | Kagi web search key |
| MAILGUN_API_KEY | Mailgun key |
| MAILGUN_DOMAIN | Mailgun sending domain |
| MAILGUN_FROM | Default from address |
Client-side variables must be prefixed with VITE_.
Shared utilities
ugly-app/shared exports common helpers used by both server and client:
import {
isDefined,
compact,
debounce,
formatDate,
formatRelativeTime,
oneSecond,
oneMinute,
oneHour,
oneDay,
oneWeek,
} from 'ugly-app/shared';
isDefined(value); // type guard — true if not null/undefined
compact([1, null, 2, undefined]); // [1, 2] — filters out null/undefined
const debouncedFn = debounce(fn, 300);
formatDate(new Date()); // locale-formatted date string
formatRelativeTime(new Date()); // "2 hours ago", "just now", etc.
// Time constants (milliseconds)
oneSecond; // 1000
oneMinute; // 60_000
oneHour; // 3_600_000
oneDay; // 86_400_000
oneWeek; // 604_800_000Tech stack
Node.js · TypeScript · Express · React 19 · Vite · Tailwind CSS · MongoDB · NATS · Redis · Cloudflare R2 · Zod · JWT (jose) · ugly.bot OAuth
