bun add.
Create the project
keja init copies the starter into an empty directory, writes .env from .env.example, runs bun install, runs git init, and adds src/public/hello.ts plus src/service/ping.ts.
keja.config.ts and set db to { enabled: false } until the data section below. Project lists the other config fields.
.env already sets HOST, HTTP_PORT, and HTTP_SERVICE_PORT. The starter values are 127.0.0.1, 3000, and 3001.
keja dev prints no issues found when the project loads. Request the public port:
{ "message": "hello" }. src/public/hello.ts is the route that produced it:
path is /hello. The /v1 in the URL is the version, from the next section. Saving this file reloads the route. keja dev prints reloading... and then no issues found.
The version in the URL
keja.versioning.ts is the catalog. The starter publishes one version:
GET /v1/hello asks for v1 and the route path /hello. Versioning explains what happens when a later version exists and this path does not.
Change the greeting
Add a query parameter so the handler reads the request. Routes describesrequest. Responses describes respond.Hello.
{ "message": "hello Ada" }. A request with no name returns HTTP 400.
The second server
The same process listens onHTTP_SERVICE_PORT. Service routes are POST. The starter’s src/service/ping.ts is POST /ping.
{ "ok": true }. Servers lists the reserved routes on both ports, including /_health and /docs.
When the service stores data
Turn Postgres back on when a route needs to store a row. OwnedObjects is the column shape. TypeIDs is the_id. Database is migrations, repositories, and seed data.
Set db to { enabled: true } in keja.config.ts. .env needs a postgres:// or postgresql:// DATABASE_URL. Restart keja dev after that change so the pool opens. The database pool stays as it was at boot.
The starter already has migrations/0001_init.sql. Add the notes table as the next file, migrations/0002_notes.sql:
keja.repositories.ts turns on list for that table:
keja generate writes src/db/repositories.ts. A route attaches notes.list.schema and passes the tenant itself. The tenant is a value your service already knows. This handler uses a fixed one so the first list runs:
src/public/notes.ts. After the reload:
{ "notes": [], "nextCursor": null } until a row exists. notes.meta is the stored row, including _id and the timestamps. notes.schema is the domain columns only, here message. keja seed inserts starter rows. The Database page shows defineSeeder.
Argus, Vault, and Cloudflare Access stay off until a service needs them.