apps/api
A Hono app with @hono/zod-openapi routes, PostgreSQL over the pg driver (raw parameterized SQL, no ORM), and Better Auth for everything account-shaped.
In development the full OpenAPI reference is at localhost:3001/reference. That’s the authoritative, always-current list. This page is the map.
Mounts
| Prefix | Auth | What |
|---|---|---|
/auth/* | Better Auth | sign-up, sign-in, verification, password reset, device authorization |
/admin/* | session + admin role | providers, models, marketplace sync, every MCP sandbox with live stats |
/widget/admin/* | session | list, create, edit and revoke widget keys |
/abilities/me/* | session | the user’s write-only ability API keys |
/nasi/* | bearer | cloud agent: turns, compaction, sessions, and the persona catalog |
/stats | session | the signed-in user’s own usage numbers |
/sandbox/connect | sandbox key or none | an MCP sandbox’s WebSocket: it registers, then serves the MCP requests tunnelled to it |
/sandbox, /sandbox/key, /sandbox/settings | session | the user’s own sandboxes, their sandbox key, and whether they share or use shared ones |
/sandbox/public | none | how many sandboxes are online, by country |
/telegram/admin/link | session | POST starts linking a Telegram account to the cloud bot (returns a one-time deep link), GET says whether it’s linked and since when, DELETE disconnects it |
/widget/<key>.js, /widget/turn | widget key + Origin | the public embed |
/config/models | shared secret | model resolution for tooling |
/config/export | none | the model defaults and safe-commands list that kaja config fetch downloads |
/health | none | liveness |
/health/ready | none | readiness: probes the database (503 without it) and object storage (degraded without it); the image’s HEALTHCHECK |
/reference | none | OpenAPI UI, development builds only |
Cloud agent: /nasi
The endpoints the CLI uses in cloud mode. All need a bearer token from device login.
| Method | Path | Purpose |
|---|---|---|
POST | /nasi/turn | run one turn, buffered: returns the whole response |
POST | /nasi/turn/stream | the same turn as SSE, with token deltas and a heartbeat |
POST | /nasi/compact | summarise a session now (/compact), keeping its latest turn. compacted is null when there was nothing to summarise |
GET | /nasi/info | which persona, model and tools this account resolves to |
GET | /nasi/personas | every persona in the catalog (id and label, default first), for pickers like the widget page’s |
GET | /nasi/sessions | list this user’s conversations |
GET | /nasi/sessions/{id} | one conversation’s metadata |
DELETE | /nasi/sessions/{id} | delete a conversation |
Turn requests and responses are the @kaja/schema/nasi contracts. Agent brain explains the status values and how session threads a conversation together. Both turn routes are rate-limited per user id, not per IP, so a shared NAT doesn’t starve everyone.
Ability keys: /abilities/me
Every user has every ability, and each persona’s abilities list picks what a turn uses, so there’s nothing to turn on; the only per-user part is keys.
| Method | Path | Purpose |
|---|---|---|
GET | /abilities/me | the abilities that take a key and some persona uses (name, description, required or optional, host or sandbox, whether the user saved one), and whether keys can be saved |
PUT / DELETE | /abilities/me/keys/{name} | save (and test: an HTTP tool’s check request, or connecting to an MCP server) or remove a key |
Keys live encrypted in user_secret, and no endpoint returns one. Without USER_SECRET_KEY the key routes answer 503, and abilities that need a key are left out of the list and of turns.
ABILITY_KEYS (web-search=BSA...,other=...) is a temporary server-wide key per ability, shared by every cloud user. An ability with one counts as needing only an optional key, and a user’s own key still wins. Admin-managed service keys, like provider keys, are meant to replace it.
Usage stats
GET /stats?days=30&tz=Asia/Tokyo (days 1 to 365, tz an IANA timezone, UTC when left out) returns the signed-in user’s own activity for the dashboard: totals, one entry per calendar day in tz (the web app sends the viewer’s timezone, and the database keeps UTC instants either way), sessions per channel (web or CLI, Telegram, widget), and per-tool calls with how often they asked first, failed and how long they took. It’s computed from the nasi_message and nasi_tool_call rows, so tokens, latencies and per-reply numbers only exist for replies saved after they were recorded.
Auth
Better Auth handles email/password with verification and reset, admin roles, and the device authorization grant the CLI uses. Session cookies are prefixed kaja, and the CLI holds a bearer token instead.
---
config:
look: handDrawn
theme: neo-dark
---
sequenceDiagram
participant C as kaja (CLI)
participant A as API
participant B as Browser
C->>A: request device code
A-->>C: user code + verification URL
C-->>B: user opens kaja.io/device
B->>A: sign in, approve the code
loop until approved
C->>A: poll
end
A-->>C: bearer token
Note over C: stored in the OS keychain,<br/>never on disk
Rate limits and the visitor’s IP
The API’s own limiters and Better Auth’s both key on the client IP from X-Forwarded-For, which the reverse proxy sets to whoever opened the connection. The web’s server-side session check (getSession in apps/web/src/lib/session.ts) goes back out through that proxy, so to the API every page render looks like it came from the web host, and all visitors share one bucket.
To avoid that, the web sends the visitor’s IP in x-kaja-client-ip together with the shared SSR_SECRET in x-kaja-ssr-secret. When the secret matches, core/ssr-client-ip.ts uses that IP for the Hono limiters and rewrites X-Forwarded-For before the request reaches Better Auth. Both headers are always stripped, and without a matching secret they’re ignored, so nobody outside can choose their own bucket.
Generate the secret with openssl rand -base64 32 and set the same value as SSR_SECRET on the API and the web.
Fail-closed config routes
/config/models is protected by a shared secret (CONFIG_API_TOKEN), not a user session, because it can return provider API keys. A missing or empty token denies every request to it, so a misconfiguration locks the door instead of opening it. /config/export is separate and public: it only serves the template files.
Conventions
- routes are declared with
@hono/zod-openapiand schemas from@kaja/schema/api - SQL is raw and parameterized, and user input is never interpolated
- DB row shapes stay private inside
services/, mapped to API types by private#rowTo…helpers - migrations only create, and are idempotent (see Database)
Errors and logging
There’s no logger package. A failure the code handles itself (which the Sentry middleware, seeing only errors that escape a handler, would never catch) goes through reportError: console.error plus Sentry.captureException, a no-op until Sentry is initialised in production. Recoverable problems are a plain console.warn. The agent brain’s own warnings (a skipped ability, a missing key, a failed MCP connection) arrive through setWarnHandler, which the server points at console.warn.
Emails
Templates are React Email components under src/emails/. Locally, MailDev catches everything at localhost:1080, so nothing leaves the machine.
Next: