Documentation
Set up Kaja
Connect your apps, hand it your variables, and press Run. Everything here is either the same on both platforms or marked for the one you picked.
Installation
Install from the Mac App Store. Apps, variables and scripts are all set up in the app itself — there is nothing to mount.
Your configuration lands at ~/Library/Application Support/kaja/kaja.json. You can read it, but the UI is the place to change it.
One container, one port. Mount your protos, your configuration file, and — if you have them — a folder of scripts.
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:latestThen open localhost:41520. The container serves a workspace it does not own, so nothing in it can be edited from the browser.
What each argument is for:
- --pull always
- Always pull the latest image. Kaja is updated often.
- --name kaja
- Name the container, so it is one to stop and start by hand.
- -d
- Run it detached, in the background.
- -p 41520:41520
- Map the port. Kaja listens on 41520.
- -v .../proto:/workspace/proto
- Mount the .proto files your gRPC and Twirp apps read.
- -v .../kaja.json:/workspace/kaja.json
- Mount your configuration file, which is the next section.
- -v .../scripts:/workspace/scripts
- Optional. A folder of .ts files, which land in the sidebar ready to run.
- --add-host=host.docker.internal:host-gateway
- Reach a service running on the host from inside the container.
Apps
An app is one service surface Kaja read. Every entry in apps is a name plus one block whose key is the app's type — the key is the type, so an app is never two things at once.
{
"apps": [
{ "name": "users", "twirp": {
"url": "http://localhost:41522", "proto_dir": "users/proto" } },
{ "name": "teams", "grpc": {
"url": "localhost:41523", "reflection": true } },
{ "name": "theatre", "openapi": {
"spec_url": "https://theatre.kaja.tools/openapi.yaml" } },
{ "name": "concierge", "mcp": {
"url": "https://concierge.kaja.tools/mcp" } }
]
}- grpc
url,proto_dir,reflection,headers- twirp
url,proto_dir,headers- openapi
spec_url,base_url,headers- mcp
url,headers
headers go out with every request — as metadata on a gRPC app. A gRPC or Twirp app reads its surface from the proto_dir you mount, or from server reflection instead.
You add apps in the UI and Kaja writes this file for you — a form per type, which reads the server before it saves anything. The shape is the same on both platforms, which is what lets a configuration move between them.
Read-only. The server serves a workspace it does not own, so the UI shows this configuration but won't edit it. It belongs to whoever mounts the file — checked into Git, deployed with the container.
Variables
A variable is a named value your scripts and your app configuration can both read. Scripts get them as kaja.variables.NAME; configuration expands ${NAME} anywhere in a value, including inside a longer URL.
{
"variables": {
"host": "localhost:41523",
"token": "${secret}",
"tenant": "${env:TENANT_ID}"
},
"apps": [
{ "name": "teams", "grpc": {
"url": "${host}",
"headers": { "Authorization": "Bearer ${token}" } } }
]
}A value either is the value, or it names where the value is kept:
- "value"
- The value is the value. It lives in the file.
- "${secret}"
- Held outside the file: the OS keychain, else
KAJA_NAMEin the environment. - "${env:X}"
- The environment variable
X, which may sit inside a longer value. - unset
- A variable whose source holds nothing isn't defined at all —
${NAME}passes through as written.
${secret} is not a second kind of variable. There is one list and one namespace, and the only axis is where the value lives — which is what keeps kaja.json safe to commit.
Edit them in the Variables tab, behind the sidebar's { } button. A secret can be stored in the macOS keychain — which wins over the environment, because it is where you typed it — and scripts read the resolved value.
No keychain on the server. A ${secret} resolves from KAJA_NAME in the container's environment, and kaja.variables is the configuration's own text — a script runs in someone else's browser here, so a value the file doesn't carry never reaches it.
Scripts
A script is TypeScript with a typed import for every app you connected. Click a method in the sidebar and Kaja writes you the call; press Run and every call it makes is logged, with whatever it drew beside it.
import { kaja } from "kaja";
import { Teams } from "teams/proto/teams";
const team = await kaja.approve(
Teams.CreateTeam({ name: "Acme", tenant: kaja.variables.tenant }),
);
kaja.table(["name", "id"], [[team.name, team.id]]);kaja.table, kaja.text and kaja.code draw on the canvas, and kaja.askStr asks you a question mid-run. kaja.approve holds a call in front of you until you approve it, which is where a write belongs.
Scripts are files in your workspace's scripts folder. Edit them in Kaja or in your own editor — both see the same files — and anything you run without naming stays a draft in the sidebar until you do.
Bring your own. Mount a folder of .ts files at /workspace/scripts and they appear in the sidebar, ready to run. The container can't write them, so check them into the repository they belong to; drafts you write in the browser stay in the browser.
Agents
Kaja runs an MCP server, so your agent reads what your apps expose, writes TypeScript against them, and runs it — through the same client you are looking at.
It starts with the app. Copy the connection command from the plug in the status bar, which has snippets for Claude Code, Claude Desktop and Cursor.
claude mcp add --transport http kaja http://127.0.0.1:41521/mcp \
--header "Authorization: Bearer <token>"Three tools cover the loop, and none of them grows with the size of your API:
list_services— the index, one TypeScript signature per method, each markedreadorwrite.describe_method— the declarations that signature names, and a call to start from.run_script— runs it.create_scriptkeeps it as a file.
Nothing it does is invisible. A snippet runs in a draft pinned at the top of your sidebar under the agent's own name, and every run lands in a console beside your own — same calls, same canvas, same approvals.
Desktop only. The MCP server needs a workspace the process owns, so it ships with the Mac app and the container doesn't run one. Everything else on this page works the same in both.
Pointing Kaja at somebody else's MCP server is the other direction, and the container does that fine — it is the mcp app type above.
Core concepts
Five words cover most of Kaja, and they mean the same thing on both platforms.
- App
- One service surface Kaja read: from .proto files, from reflection, from an OpenAPI document, or from another MCP server.
- Script
- TypeScript with typed imports for every connected app. You write it, or your agent does.
- Run
- One press of Run, kept with every call it made — request, response, headers, duration — whoever pressed it.
- Canvas
- What a run drew instead of printed: tables that fill as a loop goes, alongside text and code.
- Approval
- A script can hold a call until you approve it, so a write goes out when you say so and not before.
Then open the demo and press Run on something. It is this same app, with three services already connected.



