Skip to content
All work
NestJSPythonpgvectorMulti-tenantLLM tooling

PROM

An AI-native operating system for product development

Six deployables, 274,000 lines, a company of two. PROM takes a team from raw idea to shipped product inside a single workflow engine, where every artefact — personas, decisions, specs, tasks, launches — lands in a knowledge graph the AI reasons over. I co-founded it and own the technical side: the NestJS core API, the Python inference service, the Postgres/pgvector graph, billing and entitlements, the customer web app, an internal staff console, and a CLI that puts the whole thing inside your editor over MCP.

Role
Co-founder & CTO
Team
Co-founded by two · 602 of 605 commits mine
Timeline
May 2026 — Present
Status
Active
At a glance
lines of code
274k
TypeScript, Python, Prisma — tracked source only
deployables
6
API, web, admin, worker, AI service, CLI
REST endpoints
337
Across 37 modules and 57 controllers
Prisma models
69
62 forward migrations
test suites
89
Plus a testcontainers integration tier and pytest
AI prompt templates
~130
Versioned framework generators

The problem

Product teams run on a stack of tools that each solve one slice — docs here, tickets there, whiteboards somewhere else, an AI copilot bolted to each. Context dies at every seam. The decision made in a Slack thread never reaches the spec; the spec never reaches the ticket; the AI helping you write the ticket has no idea either exists.

PROM collapses the seams. One hierarchy — organisation, product, team, workflow, task — that the AI can actually reason over, because the structure is enforced rather than hoped for.

The approach

I started from the schema. The tenancy model, the org/product hierarchy and the audit strategy were settled before a single endpoint existed, because those are the decisions you cannot walk back once real data lands on them.

An individual account is just an Organization with is_personal = true and one owner. That single choice removed an entire class of solo-versus-team branching from the codebase — inviting a second member is the whole upgrade path.

Everything else was allowed to move fast. UI patterns, prompt copy, framework templates: cheap to change, so they change constantly.

How the AI layer actually works

The web app never talks to a model. It calls the Core API, whose ai module is a thin authenticated client to a separate Python service. That service assembles context in layers — system context, long-term memory, RAG hits, knowledge-graph lineage edges, phase rollups — and every layer is best-effort. If the graph query times out, the answer is thinner. It is never an error page.

Tool execution is the part I am most pleased with. When the model wants to create a task, it does not get database access. It calls back into the Core API carrying the user's own JWT, so every existing guard, scope check and rate limit applies unchanged. The model cannot see or do anything the human could not.

  • Model tiering with an automatic router, so the heaviest work reaches the strongest model and everything else runs on a cheaper one.
  • Spend is checked before a request is made rather than reconciled afterwards, which bounds cost by design instead of by hope.
  • A deterministic offline embedder and a mocked provider mean the full AI test suite runs with no network and no bill.
  • Idempotency keys make a retried generation free instead of billed twice.
System design

How it fits together

  1. Clients

    • Web app

      Next.js 15 · customer plane

    • Staff console

      Next.js · loopback-bound

    • prom CLI

      MCP over stdio

  2. Request pipeline

    • Rate limiting

      a floor on every route

    • Origin checks

      cross-site writes

    • Identity

      access + refresh tokens

    • Account state

      billing gate, applied globally

  3. Core API

    • 37 NestJS modules

      337 endpoints

    • Socket.IO gateway

      Redis adapter

    • MCP server

      tools for agents

    • Queue worker

      BullMQ consumers

  4. AI plane

    • Context assembler

      5 best-effort layers

    • Model gateway

      tier routing · budgets

    • Tool executor

      calls back with user token

  5. State

    • PostgreSQL

      69 models · tenant-scoped

    • pgvector

      vector(1024) HNSW

    • Redis

      queues · sessions · idempotency

    • MinIO / S3

      uploads

Two planes, five runtime services, one database. The AI service never touches Postgres directly — it goes back through the Core API with the caller's token.

  • Every business row is tenant-scoped, and per-request tenant identity travels with the request rather than being passed by hand.
  • The audit trail is append-only at the database level, so the guarantee survives migrations and console sessions.
  • Customer code and staff code never import each other. That boundary is enforced in review, not left to convention.
What I built

The work, specifically

  1. 01

    Designed a modular monolith with two isolated planes — customer and staff — each with its own identity, audit trail, guard chain and IP allowlist.

  2. 02

    Built the retrieval layer on Postgres and pgvector rather than a separate vector database: a vector(1024) HNSW cosine index over an entity/edge knowledge graph.

  3. 03

    Made the paywall a global guard so it cannot be forgotten, and the audit log append-only via Postgres rules so it cannot be rewritten.

  4. 04

    Wrote an AI orchestration service in Python that assembles context in five best-effort layers, so a retrieval failure degrades answer quality instead of breaking the stream.

  5. 05

    Constrained the model to the user's own permissions: AI tools call back into the core API with the caller's token, which makes RBAC bypass structurally impossible.

  6. 06

    Published a CLI to npm that links a customer repo to a product and proxies the MCP server over stdio, so coding agents get live product context.

  7. 07

    Shipped Stripe subscriptions, entitlements, trial pausing and per-call credit gating, with raw-body signature verification mounted ahead of the JSON parser.

Decisions

Choices I would defend in a review

Including what each one cost. A decision without a stated trade-off is usually a decision that was never really made.

Postgres + pgvector, not a vector database

Keep embeddings in the primary database as a vector(1024) column with an HNSW cosine index.

Why

Retrieval is always tenant-scoped and almost always joined against relational data. Living in the same database means one transaction, one backup, one permission model, and no drift between the graph and the rows it describes.

Trade-off

Gives up the raw recall ceiling of a dedicated engine. At this data volume the join locality is worth far more, and the migration path stays open.

Modular monolith over microservices

One deployable for the whole core domain; extract only when a measured boundary justifies it.

Why

A two-person company shipping thirty-plus domains cannot afford thirty deployment pipelines. Module boundaries give the isolation benefit; the single process removes the distributed-systems tax.

Trade-off

One service can only scale as a unit. The AI service was extracted precisely because it had a real, measured isolation reason — a different language, a different scaling profile, and a blast radius worth containing.

Account state is checked globally, not per route

The billing gate runs in the request pipeline for every route, blocking writes for lapsed accounts while leaving reads open.

Why

A per-handler billing check is one forgotten line away from a paid feature being free. Making it global means a new endpoint is covered the moment it exists, and skipping it has to be written down.

Trade-off

A pipeline-level rule is invisible to unit tests — a spec can pass while the gate is broken, so verifying it takes a real request. That cost is documented next to the rule.

A signed contract between the two languages

OpenAPI 3.1 in a shared package generates the TypeScript wire types, with a codegen-drift check in CI.

Why

NestJS and FastAPI cannot share a type system, so the contract has to live somewhere neutral and be mechanically enforced. Drift between two services in two languages is the kind of bug that ships.

Things that broke

Bugs worth writing down

Symptom

One AI feature started failing in production, on one quality tier only, with no commit in the git log that could explain it.

Diagnosis

The failure tracked a paid tier, which made it look like an account or quota problem. Bisecting the request instead of believing the story killed that theory in ten minutes: the simplest call succeeded, and adding one parameter reproduced the failure. A newer model had stopped accepting a sampling parameter that older ones accept, and the code sent it unconditionally.

Resolution

Sampling parameters now go through an allowlist that omits rather than sends on an unrecognised model — losing a little reproducibility beats losing the feature. The dependency carries a version ceiling with the incident written beside it, and a table-driven test pins the behaviour per model, including the unknown-model fail-safe.

Symptom

A configuration value crossing a service boundary silently disabled a whole feature for most of its possible settings, surfacing only as UI cards turning red.

Diagnosis

The value is validated on arrival, but the schema on the receiving side had drifted from what the sending side actually emits. The validation error was then swallowed by an exception handler catching a type broad enough to hide it, so a schema problem surfaced as an authentication failure.

Resolution

The shared values are now generated from one source instead of hand-mirrored on both sides, and the handler no longer catches a type wide enough to disguise a validation failure. The full settings matrix is covered by tests.

Symptom

A store selector that looked entirely ordinary froze a page in an infinite render loop.

Diagnosis

The selector built a new array on every call, so the reference comparison that decides whether to re-render never held. Found by cutting the subscription out and watching the loop stop — the component was never the problem.

Resolution

Selectors now read from the store and views memoise their own derivations. Written into the repository's known-traps list so it costs the next person a paragraph instead of an afternoon.

Stack

What it is made of

Core API
NestJS 11TypeScriptPrisma 6Passport / JWTArgon2class-validatorSwagger
AI service
Python 3.12FastAPIAnthropic SDKVoyage embeddingsPydanticstructlogmypy strict
Data
PostgreSQLpgvectorRedisMinIO / S3BullMQ
Web
Next.js 15React 19TanStack QueryZustandTailwindRadix UISocket.IOThree.js
Platform
TurborepoDocker ComposeGitHub ActionsOpenTelemetryPrometheusStripeOpenAPI 3.1

Next case study

Porsche Customer Relations Hub

Winning entry — Porsche Digital Campus Challenge