Bun + Turborepo monorepo. Backend Elysia, frontend Next.js (SSR) + shadcn/ui, auth better-auth, ORM Drizzle + PostgreSQL. Self-hosted with Docker Compose; Coolify is the deploy target of the reference instance.
Prefer the most specific AGENTS.md for the area being changed: every app and some packages
have their own, and it wins over this file. The CLAUDE.md next to it is a pointer, not a
second source.
IMPORTANT — always follow, no exceptions: Follow KISS and YAGNI. Write the simplest thing that works, avoid premature abstractions, and don't add them until multiple concrete implementations actually need them. No overengineering.
Before writing new code, study the existing codebase and follow its established patterns and conventions when they are sound. Reuse existing shared modules, components, and utilities instead of duplicating or reinventing them; extend what's there rather than adding parallel solutions.
Write a minimum of comments. A comment explains why, the code explains what — cover a comment
with your hand and read the code under it: if the fact is recoverable from the code alone, the
comment does not belong there. A wrong comment is worse than no comment, so change a comment in
the same edit as the code it describes, or delete it. The full rules, and the pass that removes
comments that no longer hold, are in the tidy skill.
Write plainly and literally. This applies to all prose: documentation, code comments, commit messages, and chat replies.
- No metaphors, no personification, no figurative language. Do not write "the parser is the heart of the step", "data lives in Postgres", "self-healing queue", "the ticket flows through the pipeline". State the fact directly: "the parser is the main task of the step", "data is stored in Postgres", "a failed donor is retried on the next run".
- No rhetorical flourishes or dramatization. Describe what something does, not how important or elegant it is.
- Technical terms that happen to be metaphors in origin (watermark, backlog, queue, pipeline) are fine — they are standard terminology, not stylistic choices.
- Prefer short, direct sentences over expressive ones.
Write the final state, not the edit history. When the user changes a decision, rewrite docs/comments to describe only the current design. Do not record the transformation of their thinking.
- Do not write "not X, but Y", "now in the DB instead of in code", "decided", "moved from a separate step", "previously we planned". Just state Y.
- Do not annotate a choice with why it replaced an earlier idea from this conversation. The reader wants the current requirement, not how we got here.
- Forward-looking design rationale ("column
project_keyis added now so steps 3–4 need no migration") and cross-references between sections are fine — they explain the current design, not a discarded one. - A discarded idea is only worth mentioning if it is a lasting "considered and rejected because…" that a future reader would otherwise re-propose — and then state it once, plainly, not as a correction to a prior draft.
- Runtime is Bun, never npm/yarn/pnpm. Install with
bun install, run scripts withbun run. - All code, comments, and strings are in English.
- Cross-package imports use the workspace protocol:
"@repo/db": "workspace:*". Packages export raw.ts(see each package'sexports) — Bun and Next transpile them, no build step. - Run tasks from the repo root via Turborepo:
bun run dev/build/typecheck.
apps/api Elysia (Bun) — mounts better-auth at /api/auth/* :3000
apps/web Next.js App Router, SSR (not SPA) + shadcn + TanStack Q :3001
apps/worker webhook and notification delivery, agent runs, schedules
apps/bot Telegram bot, long polling
packages/db @repo/db — Drizzle client, schema, migrations
packages/auth @repo/auth — better-auth server instance + instance auth settings
packages/crypto @repo/crypto — AES-256-GCM encryption for secrets at rest
packages/mailer @repo/mailer — SMTP/Resend transport for outbound email
packages/agent-tools @repo/agent-tools — tool definitions for the AI agent runtime
packages/runner @itsaplan/runner — CLI that runs an external agent's queued tasks on the operator's own machine
packages/eslint-config @repo/eslint-config — shared ESLint config
Dependency graph: api → @repo/auth → @repo/db. The web app never imports packages
directly — it talks to the API over HTTP (better-auth client + fetch).
| Command | Purpose |
|---|---|
bun run dev |
api + web in watch mode (turbo) |
bun run build |
build all apps |
bun run typecheck |
tsc across the workspace |
bun run lint |
ESLint across the workspace |
bun run format:check |
Prettier check (CI runs the same) |
bun run test |
run each app's test suite (turbo) |
bun run db:generate |
generate SQL migrations from Drizzle schema |
bun run db:migrate |
apply migrations |
bun run db:migrate:test |
apply migrations to the test DB (.env.test) |
bun run auth:generate |
regenerate better-auth tables → packages/db/src/schema/auth.ts |
bun install
cp .env.example .env && cp apps/web/.env.example apps/web/.env
# set BETTER_AUTH_SECRET in .env: openssl rand -base64 32
docker compose -f docker-compose.dev.yml up -d # dev Postgres + MinIO
bun run db:migrate # apply migrations
bun run dev # api :3000 + web :3001- Root
.env(copy from.env.example) feeds api, drizzle, and docker-compose. Bun apps load it via--env-file=../../.env; drizzle viadotenvindrizzle.config.ts. apps/web/.envis separate — Next reads env only from its own folder. It holdsNEXT_PUBLIC_API_URL(same value as the rootAPI_URL); in the Docker build it comes from a build arg.NEXT_PUBLIC_*are inlined at build time.- Local dev DB:
docker compose -f docker-compose.dev.yml up -d(the deploy composes do NOT publish the DB port). Host 5432 is often taken → usePOSTGRES_PORT=5433and setDATABASE_URL=...localhost:5433...in.env. - A new environment variable belongs in
.env.exampleand, if a service needs it, in the compose files.
| File | Purpose |
|---|---|
docker-compose.dev.yml |
local backing services only: Postgres + MinIO. The apps run on the host. |
docker-compose.yml |
self-hosting stack. Builds every service from source (docker compose up -d --build), reads a plain .env, requires the secrets via ${VAR:?}. |
docker-compose.coolify.yml |
the same stack for Coolify: reads its generated SERVICE_* variables and builds the images from source. |
docker-compose.test.yml |
test gate against a throwaway Postgres. |
A change to the deploy stack usually has to land in both docker-compose.yml and
docker-compose.coolify.yml. The api applies migrations on startup (migrate.ts in
its Dockerfile CMD). bot runs Telegram long polling and must stay at one replica.
docker-compose.test.yml runs the test suite against a throwaway Postgres, in a
container built from the same image as production. Integration tests need a live
database, so they cannot run during docker build — this compose file is the
mechanism instead. Build the images, then run the suite (non-zero exit on any
failing test):
docker compose -f docker-compose.test.yml build
docker compose -f docker-compose.test.yml run --rm api-testrun starts api-test's dependencies (Postgres healthy, the MinIO bucket init
completed), runs the suite, and exits with its code. It does not use
--abort-on-container-exit, which tears the stack down the moment the one-shot
minio-test-init exits, before api-test starts.
The test database is created by the postgres-test service (POSTGRES_DB=vela_test,
tmpfs — nothing persists). The test job in .github/workflows/ci.yml runs these
same commands.
.github/workflows/ci.yml runs on every pull request: format:check + lint +
typecheck, an app build, and the compose test gate. codeql.yml scans for security
issues. Do not add a workflow that duplicates a job an existing one already runs.
Images are not published to a registry: every deploy builds from source
(docker compose up -d --build).
Versioning is automated by release-please (release.yml). It reads the
Conventional Commit subjects that land on main and keeps an open release PR
that accumulates the next version and CHANGELOG.md. Merging the release PR
tags the commit and publishes the GitHub Release. PR titles are squash-merged
into main, so the PR title is the commit subject release-please reads; it must
be a valid Conventional Commit (enforced by pr-title.yml).
The type in the subject picks the version bump, so pick it by what the change gives the user, not by how much code it touches:
feat:— minor. A capability that did not exist: a new entity, page, integration, or a setting that unlocks behaviour. Reserve it for what would earn a "Highlights" entry in the release notes.improvement:— patch. A visible change to something that already exists: a redesign, a reworked layout, a theme, an interaction that got better. Not a bug fix, not a new capability.fix:— patch. Behaviour that was wrong is now right.- everything else (
perf,refactor,docs,build,ci,test,chore,revert) — patch.
The major is never automatic. While the version is 0.x, ! (e.g. feat!:)
bumps the minor (bump-minor-pre-major), and 1.0.0 is cut deliberately with a
Release-As: 1.0.0 footer in the squash commit body. That footer overrides the
computed version for any release; release-please reads it before the versioning
strategy runs. After 1.0.0, ! bumps the major on its own.
improvement is not in the Conventional Commits standard list. release-please
bumps the patch for any type it does not recognise, and the entry reaches the
changelog because changelog-sections in release-please-config.json maps it to
an "Improvements" section — a type missing from that list is dropped from the
notes. Every type in that list gets its own section in the changelog, including
chore and test. The list of types a PR title may use lives in
pr-title.yml; the two have to stay in sync.
The scope is the name of one app or package (api, web, worker, bot, db,
auth, …). A change spanning several of them carries no scope.
"package-name": "" in release-please-config.json is what lets the release be
created at all. With separate-pull-requests: false release-please names the
release PR branch release-please--branches--main, without a component, while the
node release type otherwise derives the component from the name in the root
package.json. The two disagree, release-please skips the release, the merged PR
keeps its autorelease: pending label, and every later run stops at
There are untagged, merged release PRs outstanding - aborting. An empty package
name makes both sides resolve to no component. Tags stay vX.Y.Z — that is
include-component-in-tag: false, a separate setting.
Cutting a release also moves the release branch to the released commit, which
is what the deploy target follows. main stays the only branch anyone works in;
release is a mirror with a single writer, so it never needs a merge back. It
appears with the first published release.
Agents do not commit. Do the work, then write the proposed commit message in
chat as a Conventional Commit (type(scope): summary) for the user to run. Do
not call git commit or git push unless the user explicitly asks.
Run the changed code through two skills, in order:
tidy— simplify and refine: reuse, dead code, naming, altitude.code-review— find bugs, security issues, and rule violations.
tidy first so review sees the final shape; a review before tidy goes stale when
tidy moves the code. This is separate from the CI gate (format:check + lint +
typecheck), which still must pass.
- Don't add a build step for shared packages — they're consumed as source. The one
exception is
packages/runner: it is the only member published to npm, so it bundles todist/(bun build --target=node) and its source stays free of Bun APIs — the published CLI runs on plain Node. Release it by hand (npm publishafter bumpingversion); release-please manages the application's version, not this package's. - When adding a new workspace member under
packages/*orapps/*, add a matchingCOPY <path>/package.json ./<path>/line to every Dockerfile that installs deps (apps/api,apps/web,apps/worker,apps/bot). The install stage copies manifests by an explicit list, so a missing one failsbun install --frozen-lockfilein the Docker build withWorkspace dependency "@repo/<name>" not found, even though local dev works. - Changing the better-auth config in
packages/authmay change its tables → runbun run auth:generatethenbun run db:generate+db:migrate. - Frontend and backend on different domains in prod: adjust cookie
sameSite/secureandAPP_URL(seepackages/auth/AGENTS.md). - Tests run on
bun testwith Eden Treaty. Onlyapps/apihas a suite so far; its setup and the rules for writing tests are inapps/api/AGENTS.md. They are integration tests against a real test Postgres (.env.test), not mocks. bun --filterneeds the=form:bun --filter='@repo/db' run <script>. The space formbun --filter <name> run <script>matches no packages in Bun 1.3.9.
Per-package details are in each package's AGENTS.md.