Two services, one Postgres database, and a rule I had to defend more than once while building it: the FastAPI service running Kortex's three AI agents has no way to write to that database directly. This is the how. The what and why is in the build log.
Two services that never call each other directly
Kortex is a Turborepo monorepo. apps/web is a Next.js 16 app with a tRPC API, Clerk for learner auth, and the only Prisma client in the system. apps/core is a FastAPI service holding the three agents: Architect, Author, Quizmaster. Nothing in apps/core calls into apps/web and waits, and nothing in apps/web calls into apps/core and waits either. They coordinate through Postgres and Inngest, an event bus.
A course starts as a tRPC mutation: insert an unpublished course row, then send a course.create event onto Inngest. Inngest invokes the Architect function, create-course-structure, configured for 2 retries since it's a 5-to-10-minute run that touches three external services in sequence: Gemini, Tavily, and whatever PDFs or YouTube links the admin supplied. It researches, generates the full module, lesson, and quiz structure in one Gemini call, and posts the result to /internal/courses/:id/structure.
That route runs the entire insert, modules, lessons, and quizzes, inside a single Prisma transaction, so a failure partway through doesn't leave a course with three modules and no quiz. It hands back a map from the Architect's placeholder ids to the real database ids, because the Architect can't mint valid cuids on its own.
The Architect uses that map to fire one lesson.generate event per lesson. Inngest fans those out to the Author function, generate-lesson-content, capped at 5 concurrent runs with 3 retries: tighter concurrency than the Architect gets, because it's hitting Gemini's rate limits from several directions at once, and the extra retry clears most of the transient rate-limit errors that show up under that load. Each invocation does a RAG lookup against the course's Qdrant collection, drafts the lesson in MDX, and posts it back to /internal/lessons/:id.
Why nothing calls Postgres except Next.js
Every one of those internal POSTs carries an x-internal-secret header, checked against INTERNAL_API_SECRET on the way in. Not Clerk, not an admin session, just a shared secret, because these routes are never called from a browser. They exist for exactly one reason: giving apps/core a way to persist what it generates.
That reason is also why the routes exist at all instead of a direct database connection. Prisma doesn't ship a Python client. apps/core could have gotten a second ORM, or raw SQL against the tables Prisma already manages, with its own migration history running alongside Prisma's. I didn't want two things that could describe the schema differently and drift apart. So apps/core doesn't touch Postgres. It calls back into the service that already owns the schema, and lets that service write.
The cost is one extra HTTP hop for every lesson, stacked on a background job that already takes minutes. That's not the trade-off I worry about. A second migration history quietly diverging from the first, six months from now, is.
Running it
You need Bun 1.3+, Python 3.13+ with uv, and Docker.
git clone git@github.com:yash27007/kortex.git
cd kortex
bun install
# Postgres, Redis, Qdrant, and the Inngest dev server
docker compose up -d
cd packages/db && bunx prisma db push && cd ../..Each service reads its own .env file rather than one shared root file:
| Service | Key variables | For |
|---|---|---|
apps/core/.env | GEMINI_API_KEY, TAVILY_KEY, INTERNAL_API_SECRET | Gemini, Tavily web research, the shared secret with apps/web |
apps/web/.env.local | DATABASE_URL, Clerk keys, INTERNAL_API_SECRET | Postgres, learner auth, the same shared secret |
bun run dev starts apps/web on :3000 and apps/core on :8000 through Turborepo. Visit /admin/login to create a course, /sign-up for a learner account. Admin credentials fall back to a documented, insecure development default if you skip setting them, so a fresh clone runs with no extra steps. Production refuses to start without them set explicitly.