
Product
Socket MCP Adds Org Alerts, Threat Feed Review, and Package Inspection
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.
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
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.
search) and vector search (Qdrant vector)AuthProvidertrackDoc / trackDocs)setWorkers() registers named async tasks with optional Zod input schemas and cron schedulesugly-app commands for dev, build, deploy, migrations, logs, AI, and authnpx ugly-app init my-app
cd my-app
npm run dev
The 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.
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 — the TypedDB instance, also available globally via importshttpServer — the underlying Node http.Serverwss — the main WebSocketServer (path set by setWsPath, default /rpc)dispatch(name, input, userId) — invoke an RPC handler programmaticallyregisterRoutes(fn) — mount more Express routes after creationFramework-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.
AppConfiguratorPassed 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. |
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.
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/ is consumed by both server and client. Keep all Zod schemas, types, collections, and route declarations here.
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.
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 — read getDoc through an LRU cache; writes invalidate it.trackable — enables real-time trackDoc / trackDocs via NATS.public — allow unauthenticated client reads.cascadeFrom — parent collection for cascade deletes.trackKeys? — fields usable as NATS routing keys for trackDocs.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).
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;
:param matches a single path segment; *param is greedy (captures slashes).definePage<Params>() is phantom — never set at runtime, used for client-side type inference.auth defaults to true. ssr defaults to false.Params but never appear in the path template.bootstrapApp()The recommended entrypoint. Handles auth detection, socket creation, silent auto-login through the ugly.bot iframe (Mode A only), 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's per-route auth guard surface the framework's <AuthRoot> (Mode A → <LoginPopup>; Mode B → magic-link form + optional Google button). If the token is present, it connects the socket, mounts <AppProvider>, and renders.
If bootstrapApp is loaded at /auth/magic-link/verify (Mode B), it renders the <MagicLinkCallback> component instead of the regular app shell — that path is the fallback target when the server route is shadowed by a static-assets handler.
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?, isAuthenticated?. Manages route state, browser history, popups, and the silent auto-login iframe (<AutoLoginGate> — only in Mode A; skipped when window.__UGLY_APP_AUTH_MODE__ === 'self'). When isAuthenticated() returns false and the current route declares auth: true, the framework's <AuthRoot> is rendered in place of the page — apps cannot override this fallback.RouterView — renders the active page with animated transitions. Props: durationMs?, easing?, transitionComponent? (replaces ViewFlipper), renderPage? (sync alternative to allPages loaders, called with RouterStateRaw).useRouter() — returns the router context (see below).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-exported React.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} />;
}
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 route
All 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).
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 programmatically
Modes:
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 () => { ... }, options?) — shows loading overlay while pending
splashDone, // mark a splash-screen step complete
localizer, // (key, params?) => string — alias for useLocalizer
} = useApp();
useApp is generic in TAsyncOptions so apps can pass an option type through to a custom loadingOverlay element. useAppOptional() returns null outside the provider; useLocalizer() returns a localizer that prefers <StringsProvider> data, falls back to the AppProvider localizer prop, and finally to identity.
Link componentimport { 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.
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? }).
ugly-app uses HttpOnly cookies and server-side JWT injection — no localStorage, no client-side token handling.
Two modes, picked from the auth block in the project's .uglyapp config:
mode: 'uglybot' (default when no auth block is present). Auth is delegated to ugly.bot OAuth. AI / email / push proxies bill the end user's ugly.bot credits.mode: 'self'. The app issues its own sessions via magic-link email and/or Google OAuth. AI / email / push proxies bill the developer's ugly.bot account using the project's AI_PROXY_TOKEN.window.__UGLY_APP_AUTH_MODE__ is injected into every page so client code (incl. <AuthRoot> and the router's <AutoLoginGate>) can branch correctly.
<AutoLoginGate> opens a hidden iframe to ${UGLY_BOT_URL}/iframe-auth to check for an existing platform session (4 s timeout).postMessages an OAuth code back. The client POSTs to /auth/verify; the server exchanges it through uglyBotAuthProvider, sets the auth_token HttpOnly cookie, and reloads.<AuthRoot> → <LoginPopup>, which opens ${UGLY_BOT_URL}/oauth in a popup window.${UGLY_BOT_URL}/verify, then injects window.__AUTH_TOKEN__ into the HTML so the client can attach it to the WebSocket handshake.When .uglyapp sets auth.mode: 'self', buildAuth() wires the magic-link provider as primary; if providers.google.clientId is configured it adds Google as an extra provider on the same router.
<AuthRoot> renders <MagicLinkForm> (plus the Google button when configured) instead of <LoginPopup>.<AutoLoginGate> is skipped entirely — the ugly.bot iframe would either silently fail or set a cookie that won't verify against the local session.POST /auth/magic-link/request accepts an email, mints a one-time token (default 15 min lifetime, configurable via auth.magicLinkExpiresMin), and emails a link.GET /auth/magic-link/verify?token=… validates the token, calls onUserCreate on first login, sets the session cookie, and redirects.GET /auth/google/url + POST /auth/google/callback when enabled.Any GET request with ?token=<JWT> will, if the token verifies, set the cookie and 302-redirect to the same URL without the token parameter — useful for embedding any page in an iframe.
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. |
GET /auth/url | Return the OAuth popup URL. |
POST /auth/magic-link/request | Mode B only — send a magic-link email. |
GET /auth/magic-link/verify | Mode B only — verify a magic-link token and set the cookie. |
GET /auth/google/url, POST /auth/google/callback | Mode B only — Google OAuth, registered when configured. |
configurator.setAuth({
verify: async (code) => ({ userId: '...', token: 'platform-issued-jwt' }),
authUrl: (origin) => `https://my-oauth.example/authorize?origin=${origin}`,
registerRoutes: (router, { db, onUserCreate }) => { /* extra routes */ },
});
ugly-app)verifyToken(token) — verifies a token against ugly.bot and returns the userId.getRequestUser(req) — synchronous decode of the per-project UGLY_PROJECT_TOKEN cookie set by ugly.bot's wake-on-traffic gate. Returns { userId } | null without a network round-trip; safe to use as the primary auth check in deployed-app handlers.TypedDBAccess via app.db or via import { app } from './your-app-module'. All methods accept a CollectionDef (from defineCollections) or a plain collection name string.
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.
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);
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 delete
Pass deleteHandlers as the 5th argument to createApp to run per-collection onDelete callbacks.
// 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 });
db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key); // broadcasts invalidation via NATS
const k = db.cacheKey('prefix', id);
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);
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 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.
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.
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).
import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const vector = await embeddings.embed('hello world');
const sim = cosineSimilarity(vectorA, vectorB);
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' });
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' });
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' });
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.
// 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.
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.
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.
| 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). |
| 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. |
| 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_*.
| 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.
Schema changes must be deliberate:
shared/collections.ts.npm run db:schema-gen — produces a migration file with compile-blocking REPLACE_ME placeholders for any non-trivial change.REPLACE_ME with the correct migration logic.npm run db:migrate.The framework refuses to start when drift is detected (set SCHEMA_CHECK_SKIP=true only as a temporary escape hatch).
Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · ugly.bot platform
FAQs
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
The npm package ugly-app receives a total of 2,859 weekly downloads. As such, ugly-app popularity was classified as popular.
We found that ugly-app demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Product
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.

Product
Socket Firewall blocks malicious VS Code and Open VSX extensions before install, protecting developers from compromised editor marketplaces.

Research
More than 140 Mastra npm packages were compromised in a supply chain attack that used a typosquatted dependency to deliver a cross-platform infostealer during installation.