npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ugly-app

v0.1.474

Published

A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time docume

Readme

ugly-app

A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with npx ugly-app init my-app and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, and a CLI for every workflow.

ugly-app is designed to be deployed and operated through ugly.bot — the platform handles auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app UGLY_BOT_TOKEN.

What's included

  • Server: Express + WebSocket with type-safe RPC and Zod validation
  • Client: React + Vite with typed routing, lazy pages, animated transitions, popup management
  • Database: PostgreSQL (JSONB) via the data proxy, with full-text search (search) and vector search (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, optional auto-login through the ugly.bot iframe, and provider wiring.

// client/main.tsx
import { bootstrapApp, FeedbackButton } from 'ugly-app/client';
import { requests } from '../shared/api';
import { RouterProvider, RouterView } from './router';
import './styles.css';

bootstrapApp({
  requests,
  RouterProvider,
  render: () => (
    <>
      <RouterView />
      <FeedbackButton />
    </>
  ),
  strings: { /* optional StringsProviderConfig */ },
});

BootstrapAppOptions:

| Field | Description | |-------|-------------| | requests | Your RequestRegistry (merged with framework requests internally). | | messages? | Your MessageRegistry (merged with framework messages). | | RouterProvider | The RouterProvider returned from createRouter(). | | render | Callback returning the app's UI tree (typically <RouterView /> + <FeedbackButton />). | | root? | Root element / selector (default '#root'). | | fallback? | UI for unmatched routes (default: tiny "404"). | | socketUrl? | Override the WebSocket path (default /rpc). | | strings? | Localization config — when present, wraps the tree with <StringsProvider>. | | keyboard?: false | Disable the framework <KeyboardProvider> wrapper. |

bootstrapApp reads window.__AUTH_TOKEN__ (injected by the server). If absent, it renders unauthenticated and lets the router show loginFallback for auth-guarded pages. If present, it connects the socket, mounts <AppProvider>, and renders.

Routing — createRouter()

// client/router.ts
import { createRouter } from 'ugly-app/client';
import { pages } from '../shared/pages';
import { allPages } from './allPages';

export const { RouterProvider, RouterView, useRouter } = createRouter({
  pages,
  allPages,
});

createRouter returns:

  • RouterProvider — props: children, fallback?, loginFallback?, isAuthenticated?, autoLogin?. Manages route state, browser history, popups, and <AutoLoginGate> (silent iframe-based login check against ugly.bot).
  • RouterView — renders the active page with animated transitions. Props: durationMs?, easing?, transitionComponent? (replaces ViewFlipper), renderPage? (sync alternative to allPages loaders).
  • 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 () => { ... }) — shows loading overlay
  splashDone,    // mark a splash-screen step complete
  localizer,     // (key, params?) => string — alias for useLocalizer
} = useApp();

useAppOptional() returns null outside the provider; useLocalizer() returns a localizer that falls back to identity.

Link component

import { Link } from 'ugly-app/client';

<Link router={router} to="user/:userId" params={{ userId: '123' }}>View profile</Link>

Renders an <a> with the right href, intercepts clicks for client-side navigation, and lets ctrl/cmd+click open in a new tab.

Direct socket access

AppSocket is exposed through useApp().socket. Common methods:

| Method | Description | |--------|-------------| | request(name, input) | Invoke a typed RPC handler. | | getDoc(collection, id) | Server-mediated doc fetch. | | getDocs(collection, filter?, opts?) | Filtered query. | | trackDoc(collection, id, cb) | Live subscription — returns unsubscribe. | | trackDocs(collection, params, cb) | Live filtered subscription. | | uploadFile(file, key) | Presigned upload to the temp bucket. | | connectionState | 'connecting' \| 'connected' \| 'reconnecting' \| 'disconnected' \| 'idle-disconnected'. | | disconnect() | Close the connection. |

For pure HTTP (no WebSocket), use createHttpClient({ requests, token?, baseUrl? }).


Auth

ugly-app uses HttpOnly cookies and server-side JWT injection — no localStorage, no client-side token handling.

Flow:

  1. Unauthenticated user lands on a page; the optional <AutoLoginGate> opens a hidden iframe to https://ugly.bot/iframe-auth to check for an existing platform session.
  2. If found, the iframe posts an OAuth code back; the client POSTs to /auth/verify; the server exchanges it through uglyBotAuthProvider, sets the auth_token HttpOnly cookie, and the page reloads authenticated.
  3. If not found (or timeout after 4 s), <RouterProvider> shows loginFallback for auth-required routes. The default fallback is <LoginPopup>, which opens https://ugly.bot/oauth in a popup window.
  4. On every authenticated request, the server verifies the cookie token by calling ${UGLY_BOT_URL}/verify and injects window.__AUTH_TOKEN__ into the HTML so the client can pass it on the WebSocket handshake.

Token-in-URL embed mode: any GET request with ?token=<JWT> will, if the token verifies against ugly.bot, set the cookie and 302-redirect to the same URL without the token — letting any page be embedded in an iframe.

Built-in routes (mounted on every app):

| Endpoint | Description | |----------|-------------| | POST /auth/verify | Exchange an OAuth code for a session cookie. | | POST /auth/logout | Clear the cookie. | | GET /auth/token | Refresh and return the current token (used by clients that need an explicit token). | | GET /auth/url | Return the OAuth popup URL. |

Custom provider:

configurator.setAuth({
  verify: async (code) => ({ userId: '...', token: 'platform-issued-jwt' }),
  authUrl: (origin) => `https://my-oauth.example/authorize?origin=${origin}`,
  registerRoutes: (router) => { /* optional extra routes */ },
});

Server-side helpers (ugly-app):

  • verifyToken(token) — verifies a token against ugly.bot and returns 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.

Search

// Full-text search — requires `search: { fields, language? }` on the collection
const hits = await db.searchDocs(collections.note, 'react hooks', { limit: 10 });

// Vector search — requires `vector: { dimensions, source }` (uses Qdrant)
const similar = await db.vectorSearch(collections.note, embeddingVector, { limit: 10 });

Caching

db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key);                                       // broadcasts invalidation via NATS
const k = db.cacheKey('prefix', id);

Helpers

import { createUserHelper, dbDefaults } from 'ugly-app';

const newDoc = { _id: crypto.randomUUID(), ...dbDefaults(), title: 'Hi' };
//                                          ^^^^^^^^^^^^^^^ { version: 1, created, updated }

const userHelper = createUserHelper<User>(collections.user);
const user = await userHelper.get(db, userId);

Direct SQL & infra

Imports available from ugly-app:

  • pgQuery(sql, params?) — parameterized SQL on the data proxy.
  • ensureTable(...), tableExists(...), ensureSearchColumn(...).
  • ensureQdrantCollection(...), upsertVector(...), searchVectors(...), deleteVector(...), deleteQdrantCollection(...).
  • connectNats(), natsPublish(subject, payload), natsSubscribe(subject, cb), ensureKvBucket(name, opts), jsPublish(...), jsConsumerCreate(...), jsConsumerConsume(...).
  • subscribeCollection, subscribeDoc, subscribeDocKey — NATS subjects emitted by the data proxy on writes.

AI

AI calls are proxied through ugly.bot — your app never holds an AI provider key. Pass UGLY_BOT_TOKEN in the environment and the framework handles routing, balance tracking, retries, and per-user billing.

Server-side text generation

import { createTextGenClient } from 'ugly-app';
const textGen = createTextGenClient(userId);

const text = await textGen.generate(messages, { model: 'gemini_2_5_flash' });

Or call the framework textGen request directly:

import { uglyBotRequest } from 'ugly-app';
const { message } = await uglyBotRequest<{ message: { content: string } }>('textGen', {
  model: 'gemini_2_5_flash',
  messages: [{ role: 'user', content: 'Hello!' }],
  options: { maxTokens: 512 },
});

Available models are exposed via textGenModels / textGenModelData from ugly-app — the platform supports Claude, GPT, Gemini, Together, Groq, Fireworks, and Kie families.

Server-side image generation

import { createImageGenClient } from 'ugly-app';
const imageGen = createImageGenClient(userId);

const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });

imageGenModels / imageGenModelData enumerate available models (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).

Embeddings

import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const vector = await embeddings.embed('hello world');
const sim = cosineSimilarity(vectorA, vectorB);

Web search

import { createWebSearchClient } from 'ugly-app';
const search = createWebSearchClient(userId);

await search.search({ query: 'react 19', limit: 10 });
await search.summarize({ url: 'https://...' });
await search.enrichWeb({ query: 'topic' });
await search.enrichNews({ query: 'topic' });

Client-side AI calls

Calls from React components go through the framework RPC pipeline — no token plumbing in the browser:

import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';

const text  = await callTextGen({ messages, model: 'gemini_2_5_flash' });
const json  = await callJsonGen({ messages, schema, model: 'gemini_2_5_flash' });
const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_schnell' });

STT / TTS

Speech goes directly from the browser to ugly.bot — never proxied through your app server.

import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';

const { start, stop, transcript, isListening } = useSTT(socket, options);
const { speak, stop: stopTTS } = useTTS(socket, { voice: 'alloy' });

Storage

S3-compatible. Two logical buckets:

  • temp — short-lived uploads (presigned PUT from the browser).
  • public — durable, served by CDN.

Server-side:

import { createStorageClient } from 'ugly-app';
const storage = createStorageClient();

await storage.put('temp', key, buffer, 'image/png');
const publicUrl = await storage.moveToPublic(tempKey, destKey);
const url = storage.url('public', destKey);
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);

Client-side, use socket.uploadFile(file, key) — it requests a presigned URL via the built-in uploadUrl framework request and streams the upload. In dev, uploads go through a same-origin /_s3 proxy to avoid CORS with local MinIO.

STORAGE_KEY_PREFIX (env) prefixes all keys — useful for per-environment isolation.


Workers & cron

// shared/cron.ts
import { defineWorkers, z } from 'ugly-app/shared';

export const cronTasks = defineWorkers({
  dailyCleanup: {
    schedule: '0 3 * * *',   // every day at 03:00 UTC
    description: 'Delete completed todos older than 30 days',
  },
  resyncSearch: {
    inputSchema: z.object({ since: z.string().datetime() }),
    description: 'Re-embed search vectors since the given ISO timestamp',
  },
});
// server/index.ts
const cronHandlers: WorkerHandlers<typeof cronTasks> = {
  dailyCleanup: async () => { /* runs on schedule */ },
  resyncSearch: async ({ since }) => { /* runs on manual trigger from studio */ },
};

configurator.setWorkers(cronTasks, cronHandlers);

Each worker can have inputSchema, outputSchema, schedule, timeout, description. Workers without a schedule are still invocable via POST /_workers/run (auth: localhost in dev, Authorization: Bearer $CRON_SECRET in prod). Scheduled workers also appear in /_cron/manifest for the deploy orchestrator.


Localization

configurator.setStrings({
  defaultLang: 'en',
  langs: ['en', 'es'],
  criticalKeys: ['app.title', 'nav.home'],
  getTable: (lang) => tables[lang] ?? tables.en,
});

The framework injects window.__LANG__, window.__STRINGS_VERSION__, and window.__CRITICAL_STRINGS__ into SSR HTML. Use useLocalizer() / useStrings() / useLang() / useChangeLanguage() on the client.


Experiments

import type { Experiment } from 'ugly-app/shared';

export const experiments: Experiment[] = [
  {
    id: 'new-onboarding',
    name: 'New Onboarding',
    active: true,
    branches: [
      { id: 'control', weight: 50 },
      { id: 'variant', weight: 50 },
    ],
    events: ['ONBOARDING_COMPLETE'],
  },
];

configurator.setExperiments(experiments);

Bucketing is deterministic: hash(experimentId + userId) (or sessionId for unauthenticated users). The framework's initSession / captureEvent requests automatically tag events with the user's branch assignments.


Built-in endpoints

| Endpoint | Description | |----------|-------------| | GET /health | Health check — returns { status, timestamp, lastRequestAt }. | | POST /api/:name | Dispatch any registered request handler over HTTP. | | POST /auth/verify | Exchange OAuth code for session cookie. | | POST /auth/logout | Clear the auth cookie. | | GET /auth/token | Refresh and return the current token. | | GET /auth/url | Get the OAuth popup URL. | | GET /_workers/manifest | Worker definitions (used by ugly-studio). | | POST /_workers/run | Synchronously invoke a worker handler. | | GET /_workers/runs | Recent in-memory worker runs (last 200). | | GET /_cron/manifest | Cron tasks for the deploy orchestrator. | | POST /api/_cron/:taskName | Trigger a cron task (auth via CRON_SECRET). | | POST /internal/email-callback | Inbound email gateway (auth via INTERNAL_EMAIL_SECRET). | | PUT /_s3/* | Dev-only S3 upload proxy (avoids CORS with MinIO). |


Package entry points

| Import path | Description | |-------------|-------------| | ugly-app | Server: createApp, TypedDB, auth, AI clients, NATS, storage, email, push, workers. | | ugly-app/shared | Cross-tier: defineRequests, defineCollections, definePage, defineWorkers, Zod, experiments, time constants. | | ugly-app/client | React: bootstrapApp, createRouter, lazyPage, AppProvider, components, animations, audio, AI helpers. | | ugly-app/conversation/{shared,server,client} | AI chat sessions with persisted history. | | ugly-app/collab/{server,client} | Yjs-based collaborative editing. | | ugly-app/markdown/{shared,client} | Markdown rendering + editor. | | ugly-app/webrtc, ugly-app/webrtc/server | WebRTC video rooms. | | ugly-app/three/{server,client} | Three.js scene helpers. | | ugly-app/worker | Worker queue runtime. | | ugly-app/playwright | Test utilities. | | ugly-app/vite, ugly-app/eslint | Build-tool plugins. |


Environment variables

| Variable | Description | |----------|-------------| | PORT | Server port (templates default to 4321). | | NODE_ENV | development or production. | | UGLY_BOT_TOKEN | App token for the ugly.bot platform — required for AI, logs, billing. | | UGLY_BOT_URL | Override the platform base URL (default https://ugly.bot). | | DATA_PROXY_URL | WebSocket URL for the data proxy (default ws://localhost:4200). | | DATA_PROXY_TOKEN | Auth token for the data proxy. | | STORAGE_KEY_PREFIX | Prefix all storage keys (per-env isolation). | | MINIO_ENDPOINT | Dev-only S3 endpoint for the upload proxy. | | NATS_PREFIX / COMPOSE_PROJECT_NAME | NATS subject prefix for per-env isolation. | | CLOCK_ENABLED | true to enable setOnMinuteTick / setOnHourlyTick. | | CRON_SECRET | Bearer secret for POST /api/_cron/:taskName and prod POST /_workers/run. | | MAINTAIN_BOT_USER_ID | User id allowed to access admin-only handlers. | | INTERNAL_EMAIL_SECRET | Shared secret for /internal/email-callback. | | JWT_SECRET | Required when using getRequestUser() for the per-project session cookie. | | APP_DOMAIN | App domain; combined with NATS_PREFIX for getRequestUser() validation. | | LOG_CAPTURE_URL | Studio override for client log capture (empty → ugly.bot default). | | UGLY_APP_HMR | Set to false to disable Vite HMR in dev. | | SCHEMA_CHECK_SKIP | true to start despite schema drift (unsafe). |

Browser-visible variables must be prefixed VITE_ and consumed via import.meta.env.VITE_*.


CLI

| Command | Description | |---------|-------------| | ugly-app init <name> | Scaffold a new project. | | ugly-app upgrade | Upgrade framework config files to the latest version. | | ugly-app configure | Generate/update .uglyapp config. | | ugly-app login | Authenticate with ugly.bot. | | ugly-app url | Print the local dev server URL. | | ugly-app deploy | Build + push to production infrastructure. | | ugly-app prod --buildId <id> | Promote a build to prod. | | ugly-app versions | List deployed versions. | | ugly-app versions:prune | Clean up non-prod versions. | | ugly-app infra:destroy | Tear down all project infra. | | ugly-app textGen [prompt] | Generate text via AI (--model, --system-prompt, --max-tokens, --json). | | ugly-app imageGen [prompt] | Generate an image (--model, --output <path>). | | ugly-app error:dev / error:prod | Query error logs (your tunnel / production). | | ugly-app perf:dev / perf:prod | Query performance metrics. | | ugly-app feedback:dev / feedback:prod | Query user feedback. | | ugly-app feedback:submit / feedback:resolve | Manage feedback (run with --help for flags). |

Inside a scaffolded project, the same commands are available via npm run … scripts — see templates/CLAUDE.md for the full list.


Migrations

Schema changes must be deliberate:

  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