npm.io
0.1.723 • Published 10h agoCLI

ugly-app

Licence
Version
0.1.723
Deps
73
Size
11.1 MB
Vulns
0
Weekly
12.2K

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 (Qdrant vector)
  • 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-app commands for dev, build, deploy, migrations, logs, AI, and auth

Quick start

npx 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.


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 — the TypedDB instance, also available globally via imports
  • httpServer — the underlying Node http.Server
  • wss — the main WebSocketServer (path set by setWsPath, default /rpc)
  • dispatch(name, input, userId) — invoke an RPC handler programmatically
  • registerRoutes(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 — 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).

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;
  • :param matches a single path segment; *param is greedy (captures slashes).
  • The generic on definePage<Params>() is phantom — never set at runtime, used for client-side type inference.
  • auth defaults to true. ssr defaults to false.
  • Query-string params are declared in Params but never appear in the path template.

Client

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.

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?, 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).
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-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} />;
}
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 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).

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 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.

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.

Two modes, picked from the auth block in the project's .uglyapp config:

  • Mode A — 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 B — 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.

Mode A — ugly.bot OAuth (default)
  1. Unauthenticated user lands on a page. The router's <AutoLoginGate> opens a hidden iframe to ${UGLY_BOT_URL}/iframe-auth to check for an existing platform session (4 s timeout).
  2. If found, the iframe 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.
  3. If not found (or timeout), per-route auth checks surface the framework's <AuthRoot><LoginPopup>, which opens ${UGLY_BOT_URL}/oauth in a popup window.
  4. On every authenticated page request, the server verifies the cookie token by calling ${UGLY_BOT_URL}/verify, then injects window.__AUTH_TOKEN__ into the HTML so the client can attach it to the WebSocket handshake.
Mode B — Self-issued sessions

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>.
  • The <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.
  • Google login lives at GET /auth/google/url + POST /auth/google/callback when enabled.
Token-in-URL embed

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.

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.
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.
Custom provider
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 */ },
});
Server-side helpers (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.

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 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 });
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);
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:

  1. Update the Zod schema in shared/collections.ts.
  2. Run npm run db:schema-gen — produces a migration file with compile-blocking REPLACE_ME placeholders for any non-trivial change.
  3. Replace every REPLACE_ME with the correct migration logic.
  4. 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