An agent calling your APIs leaves you a transcript to trust. Kaja gives it a canvas instead.
Connect your gRPC, OpenAPI and MCP apps, and what your agent does is drawn, logged call by call, and paused for your approval whenever you want one.
- Your agent drives it. Kaja runs an MCP server, so your agent reads what your apps expose, writes TypeScript against them, and runs it. Nothing it does is invisible: every run lands in the sidebar next to your own.
- A canvas, not a transcript. A script draws what it produced. Tables fill row by row as a loop runs, page and search themselves, and sit alongside text and code.
- Every call is on the record. Request, response, headers and duration for each call in a run, whoever pressed Run.
- Approval when you want it. A script can hold a call until you approve it, so a write goes out when you say so and not before.
- gRPC, OpenAPI and MCP. Read the surface from your
.protofiles, from gRPC server reflection, from an OpenAPI document, or from another MCP server. Twirp is supported too. - Still yours to drive. Click a method and Kaja writes you the call, with full autocomplete for apps, methods and message fields.
- macOS & Docker. Available on the Mac App Store or as a Docker container for any environment.
docker run --pull always --name kaja -d -p 41520:41520 \
-v /my_app/proto:/workspace/proto \
-v /my_app/kaja.json:/workspace/kaja.json \
-v /my_app/scripts:/workspace/scripts \
--add-host=host.docker.internal:host-gateway kajatools/kaja:latest
Then open http://localhost:41520.
The scripts mount is optional: .ts files in /workspace/scripts appear under
Scripts in the sidebar, ready to open and run. The container serves its
workspace read-only, so they can't be edited from the browser — check them into
the repository they belong to and mount them in.
On macOS, apps are configured through the UI. The configuration is stored at ~/Library/Application Support/kaja/kaja.json.
With Docker, create a kaja.json file and mount it into the container. Every entry in apps is one app: a name plus one block whose key is the app's type, holding that type's parameters:
{
"apps": [
{
"name": "users",
"twirp": {
"url": "http://host.docker.internal:41522",
"proto_dir": "users/proto"
}
},
{
"name": "teams",
"grpc": {
"url": "host.docker.internal:41523",
"reflection": true,
"headers": { "Authorization": "Bearer xxx" }
}
}
]
}The server serves a workspace it does not own, so the UI shows this configuration read-only: it is managed by whoever mounts the file — checked into Git, deployed with the container — and not edited by the engineers running it.
Each app has a name and exactly one typed block:
| Type | Parameters |
|---|---|
grpc |
url, proto_dir (path to .proto files), reflection (use gRPC server reflection instead of local proto files), headers |
twirp |
url, proto_dir, headers |
headers are sent with each request (e.g. {"Authorization": "Bearer xxx"}); for gRPC they are sent as metadata.
Earlier versions used a top-level projects list with a protocol field. Kaja migrates these automatically on load — but to update a file by hand, move each project into apps and replace its protocol/url/protoDir/useReflection fields with a block named after the type.
Before:
{
"projects": [
{ "name": "users", "protocol": "RPC_PROTOCOL_TWIRP", "url": "http://host.docker.internal:41522", "protoDir": "users/proto" }
]
}After:
{
"apps": [
{ "name": "users", "twirp": { "url": "http://host.docker.internal:41522", "proto_dir": "users/proto" } }
]
}| Argument | Description |
|---|---|
--pull always |
Always pull the latest image. Kaja is updated frequently. |
--name kaja |
Name the container for easy management. |
-d |
Run in detached mode. |
-p 41520:41520 |
Map the container port. Kaja listens on 41520 by default. |
-v .../proto:/workspace/proto |
Mount your proto_path into the container. |
-v .../kaja.json:/workspace/kaja.json |
Mount your configuration file. |
--add-host=host.docker.internal:host-gateway |
Access host services from the container. |
The development scripts require Go and Bun installed. If not installed, they will offer to install them for you via Homebrew.
- Run in local server:
scripts/server(pass--editableto editworkspace/kaja.jsonfrom the UI) - Run in Docker:
scripts/docker - Run the desktop app:
scripts/desktop - Test UI:
(cd ui && bun test) - TSC UI:
(cd ui && bun run tsc) - Test server:
(cd server && go test ./... -tags development -v)— the tagscripts/serverbuilds with. Without it the packages embed a production UI bundle, which onlygo run cmd/build-ui/main.gowrites. - Update demo protos:
scripts/demo-protos— refreshes thequirksandgrpcb.inprotos inworkspace/. The demo services live in kaja-tools/website;theatre,seatingandconciergeneed no protos here, since they are OpenAPI, gRPC reflection and MCP respectively.
Both public deployments serve this repository's own workspace/ — the demo apps scripts/server starts on — baked into the image by the Dockerfile's demo stage. One image, two places it runs, so a change is clicked through on exactly what it will become:
- demo.kaja.tools (
deploy/demo/fly.toml) — deployed by the main workflow on every push tomain, and so from the commit that changed it. One machine stays up, so the first visitor of the day doesn't wait for a cold start. https://kaja-pr-<number>.fly.dev(deploy/preview/fly.toml) — one app per pull request, so a change can be clicked through before it is merged. The preview workflow rebuilds it on every push and destroys the app when the pull request closes; the URL is a comment on the pull request throughout. A preview runs no machine until someone opens its URL.
The workspace's configuration is read-only, like any server build, so neither ever asks Fly for a disk, and workspace/scripts/ ships with them — the demo opens with scripts to press Run on.
Fork pull requests are skipped: they have no access to the token. Setting this up in a fresh repository takes a FLY_API_TOKEN secret that may create and destroy apps (fly tokens create org <org> — an app-scoped deploy token can't create the per-pull-request apps), and optionally a FLY_ORG variable if the org isn't personal. The demo's own app and certificate are created once, by hand:
fly apps create kaja-demo
fly certs add demo.kaja.tools --app kaja-demoReleases are cut from GitHub — no local build needed. Every push to main uploads a new build to TestFlight. To ship a version, run the release workflow (Actions → Run workflow):
open(withpatch/minor/major) — bumps the version on a branch and opens a PR. Merge it yourself:mainis protected, so the bump has to arrive as a PR and pass thetestcheck. TestFlight builds carry the new version from then on.ship— tags the commit and publishes a GitHub Release for it, with notes covering the whole cycle. Run it when you promote one of those TestFlight builds to the App Store.