Architecture
How the Elmo monorepo is structured and how the pieces fit together.
Monorepo Structure
Elmo is a pnpm monorepo managed by Turborepo. The codebase is split into apps (deployable) and packages (shared libraries).
.
├── apps/
│ ├── web/ # Main dashboard (TanStack Start)
│ ├── worker/ # Background job runner (pg-boss)
│ ├── cli/ # @elmohq/cli npm package
│ └── www/ # Marketing site (this site)
│
├── packages/
│ ├── ui/ # Shared UI components (shadcn/ui)
│ ├── lib/ # Shared business logic, DB schema, AI providers
│ ├── config/ # Deployment configuration types
│ ├── deployment/ # Deployment mode resolution
│ ├── demo/ # Demo mode implementation
│ ├── local/ # Local development mode
│ ├── whitelabel/ # White-label mode
│ ├── og/ # Shared OG image generation
│ └── api-spec/ # OpenAPI specification
│
├── docker/ # Dockerfile (multi-stage)
├── e2e/ # End-to-end tests
├── biome.json # Linting and formatting
├── turbo.json # Task pipeline configuration
└── pnpm-workspace.yamlTech Stack
| Layer | Technology |
|---|---|
| Language | TypeScript 5.x |
| Framework | TanStack Start (App Router, SSR) |
| Styling | Tailwind CSS v4 |
| Components | shadcn/ui (new-york style) |
| Data Fetching | SWR |
| Database | PostgreSQL with Drizzle ORM |
| Job Queue | pg-boss (Postgres-based) |
| Testing | Vitest |
| Linting | Biome |
| Monorepo | Turborepo + pnpm workspaces |
How the Apps Connect
For the runtime topology (web, worker, Postgres, LLM providers), see the System Architecture diagram.
What matters for development:
- The Web App serves the dashboard UI and the REST API (
/api/v1). - The Worker processes background jobs. An answer goes through separate stages, each retrying on its own:
- Query (
process-prompt): calls the AI model and stores its answer, nothing more. Only this stage calls a paid provider, and nothing after it can make it call again. - Derive (
derive-runs): extracts each answer's text and citations, and asks Jev (TypeSafe) which companies it mentions and their sentiment; positions come from where each is first named. A failure retries this stage alone and never falls back to name matching. A sweep every few minutes asks again for answers still unjudged. - Roll up (
refresh-rollups): rebuilds the aggregates the dashboard reads. An answer counts once it's judged.
reprocess) by queueing the brand's stale answers for the derive stage, most recent first, so any number of workers share a backfill. - Query (
- Both import shared code from
packages/lib(database schema, AI provider config) andpackages/config(deployment types). - The CLI generates Docker Compose configurations and manages the deployment lifecycle.
Deployment Modes
packages/deployment resolves the active mode at runtime from DEPLOYMENT_MODE and supplies that mode's feature flags, branding, and auth hooks:
| Mode | Description |
|---|---|
local | Single-org, email/password auth |
demo | Read-only demonstration |
whitelabel | Multi-org with Auth0 SSO |
cloud | Managed multi-tenant, self-serve signup with Stripe billing |
Database
The database schema is defined with Drizzle ORM in packages/lib/src/db/. Key tables include:
- brands — Brand profiles with name, website, domains, aliases
- prompts — Tracking prompts associated with brands
- evaluations — Results from querying AI models
- citations — URLs cited by AI models in their responses
Migrations are generated with drizzle-kit generate and applied with drizzle-kit migrate.
Never use drizzle-kit push against a deployment. It diffs the live database
against the schema files rather than replaying migrations, so it drops any index
a migration created that the schema cannot express — INCLUDE indexes, and
anything on the generated better-auth tables. It also records nothing in
drizzle.__drizzle_migrations, leaving migrate unable to tell what has run.
Provider Abstraction
All AI scraping flows through a single Provider interface in packages/lib/src/providers/. The runtime wiring:
SCRAPE_TARGETS(env var) holds comma-separatedmodel:provider[:version][:online]entries.parseScrapeTargets()turns the string intoModelConfig[].getProvider(providerId).run(model, prompt, { webSearch, version })dispatches to a concreteregistry/implementation and returns a normalizedScrapeResult(textContent,rawOutput,webQueries,citations,modelVersion).
User-facing setup is documented in Providers.
Was this page helpful?