Documentation
Set up Kaja
Install Kaja, connect an API, and run your first call.
- Desktop
- Install Kaja and add your APIs from the sidebar.
- Docker
- Put your APIs, variables and scripts in kaja.json, and run the container.
Installation
Mount kaja.json, your protos and your scripts into /workspace. Kaja serves the UI on port 41520.
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 \
-e KAJA_TOKEN="$TOKEN" \
--add-host=host.docker.internal:host-gateway kajatools/kaja:latestThen open localhost:41520.
- --pull always
- Pull the latest image.
- --name kaja
- Name the container.
- -d
- Run in the background.
- -p 41520:41520
- Map the port. Kaja listens on 41520.
- -v .../proto:/workspace/proto
- .proto files for gRPC and Twirp apps.
- -v .../kaja.json:/workspace/kaja.json
- Apps and variables.
- -v .../scripts:/workspace/scripts
- Optional. .ts scripts, listed under Files.
- -e KAJA_TOKEN=...
- Optional. The value of the secret variable named token.
- --add-host=host.docker.internal:host-gateway
- Reach services on the host as host.docker.internal.
The workspace is read-only. Kaja never writes to the mounted files. Edit them on disk.
Apps
An app is one API Kaja can call. Its services and methods appear in the sidebar under the app's name.
Define your apps in kaja.json. Each one has a name and a block naming its type.
{
"apps": [
{ "name": "users", "twirp": {
"url": "http://host.docker.internal:41522",
"proto_dir": "proto/users" } },
{ "name": "teams", "grpc": {
"url": "host.docker.internal:41523", "reflection": true } },
{ "name": "theatre", "openapi": {
"spec_url": "https://theatre.kaja.tools/openapi.yaml" } },
{ "name": "concierge", "mcp": {
"url": "https://concierge.kaja.tools/mcp" } }
]
}Each type takes its own keys:
- grpc
url,proto_dir,reflection,headers- twirp
url,proto_dir,headers- openapi
spec_url,base_url,headers- mcp
url,headers
proto_dir is relative to /workspace. A gRPC app with reflection needs no protos. headers are sent with every call. Services running on the host are reached as host.docker.internal.
Scripts import an app by name: import { Teams } from "teams".
If the app won't connect
“Couldn't reach the server.”
Inside the container, localhost is the container itself. Point the app at host.docker.internal and start the container with --add-host=host.docker.internal:host-gateway.
“The server answered, but doesn't serve the reflection API.”
The gRPC server is reachable, but it does not register the reflection service. Drop reflection from the app and set proto_dir to a folder of .proto files under /workspace.
“Couldn't parse the document”, or “That URL returned a web page, not an OpenAPI document”
The spec_url is serving HTML, such as a documentation site or a sign-in redirect, or the file is not valid JSON or YAML. Fetch that URL yourself and check it returns the spec itself rather than a page about it.
Run your first call
Once an app is connected, four steps get you a response.
1. Open an app in the sidebar and click one of its methods.
2. Kaja writes a typed call into a new draft and opens it in the editor. Every field of the request is written out, so you can see what the method takes before you change anything.
3. Click Run at the top right, or press Ctrl+⏎ (⌘⏎ on a Mac).
4. The run opens below the editor on Calls. Click the call to read the request Kaja sent and the response it got back.
Writing scripts
Scripts let you call APIs with TypeScript. Click a method to create a typed call, or start with an empty script. Click Run to run it.
import { kaja } from "kaja";
import { Theatre } from "theatre";
import { Seating } from "seating";
const { shows } = await Theatre.ListShows({ city: "Chicago" });
kaja.table(["show", "starts"], shows.map((show) => [show.id, show.startsAt]));
await kaja.approve(
Seating.BookSeats({ showId: shows[0].id, seatIds: ["F7", "F8"] }),
);A script is TypeScript with top-level await and one import for each app. Results appear below the editor:
- Calls
- Every request and response.
- Canvas
- Tables, text, questions and anything else the script drew.
- Stats
- Latency and timing for the run.
Scripts also get fetch and console, both bound to the current run. A fetch request appears in Calls like any other call.
Every generated method takes headers as its second argument. Call .withHeaders() when you also need the headers the API answered with.
Ctrl+P (⌘P on a Mac) finds a method, file or draft by name.
Kaja helpers
A script can import kaja to draw on the run's canvas, ask you a question, or hold a call for approval. The table lists every helper. The entries under it have the detail.
import { kaja } from "kaja";
import { Theatre } from "theatre";
const catalog = kaja.table(["id", "title"], async function* () {
for (let cursor = ""; ; ) {
const page = await Theatre.ListMovies({ cursor });
catalog.total(page.total);
yield* page.movies.map((movie) => [movie.id, movie.title]);
if (!(cursor = page.nextCursor)) return;
}
});import { kaja } from "kaja";
import { Theatre } from "theatre";
const { theaters } = await Theatre.ListTheaters({ city: "" });
const cities = [...new Set(theaters.map((theater) => theater.city))];
const city = await kaja.askSelect(
"Where are you tonight?",
cities.sort().map((name) => ({ label: name, value: name })),
);
const mood = await kaja.askStr("And what do you feel like?");
const party = await kaja.askInt("How many of you?");import { kaja } from "kaja";
import { Theatre } from "theatre";
import { Seating } from "seating";
const { shows } = await Theatre.ListShows({ limit: 1 });
kaja.rateLimit(Seating);
const reads = kaja.table(["read", "remaining"]);
for (let read = 1; read <= 150; read++) {
const { headers } = await Seating.GetSeatMap({
showId: shows[0].id,
}).withHeaders();
reads.row(read, headers["ratelimit-remaining"] ?? "—");
}import { kaja } from "kaja";
import { Theatre } from "theatre";
const report = await kaja.perfTest(
() => Theatre.ListMovies({ limit: 25 }),
{ duration: "12s", concurrency: 6, warmup: "2s", rampUp: "4s" },
);
kaja.text(`p99 ${Math.round(report.latency.p99 ?? 0)} ms`);The editor uses these declarations for autocomplete and inline documentation, and an agent reads the same ones when it writes a script. The whole of it is the kaja module declaration.
Files and drafts
Files shows the scripts folder you mounted. Edit these files on disk; Kaja picks up the change the next time you run them.
A script you write in the browser stays a draft in that browser. The container never writes to the mounted folder. To keep a script, add it to the scripts folder on disk yourself.
Variables
Variables are named values shared by scripts and app configuration. Read one in a script with kaja.variables.NAME. Use ${NAME} in app configuration, either by itself or inside a longer value.
Define variables in kaja.json.
{
"variables": {
"host": "host.docker.internal:41523",
"token": "${secret}",
"tenant": "${env:TENANT_ID}"
},
"apps": [
{ "name": "teams", "grpc": {
"url": "${host}",
"headers": { "Authorization": "Bearer ${token}" } } }
]
}- "value"
- A plain value, kept in the file.
- "${secret}"
- A secret, read from
KAJA_NAMEin the container's environment. - "${env:X}"
- The environment variable
X.
Secrets never reach the browser. ${secret} is resolved inside the container. Scripts get the placeholder, not the value.
If a variable doesn't resolve
The Variables tab shows “KAJA_TOKEN not set”, and calls go out with ${token} unexpanded.
A ${secret} variable reads KAJA_NAME from the container's environment, and nothing set it. Pass it on the docker run line as -e KAJA_TOKEN=... and start the container again.
Deeplinks
Saved scripts have deeplinks. Right-click the file, or open the menu beside Run, and choose Copy deeplink….
http://localhost:41520/#run/whats-on?city=ChicagoThe link opens the script and fills in its input values. Click Run to run it.
Read the query parameters through kaja.input. Every value arrives as a string. A script run from the editor with nothing to carry gets an empty input:
const city = kaja.input.city ?? (await kaja.askStr("Which city?"));
const { shows } = await Theatre.ListShows({ city });Run with parameters…, in the menu beside Run, asks for the keys the script reads and then runs it.
Agents
Kaja provides an MCP server for your connected APIs. An agent can inspect the available methods, write TypeScript scripts, and run them through Kaja.
Open the plug menu at the top of the sidebar and turn on the MCP server. Copy the command or configuration shown for your agent.
The endpoint is Kaja's address plus /mcp. The token belongs to this browser. Scripts run in this tab, so keep it open while the agent works.
- list_services
- One TypeScript signature per method, marked read or write.
- describe_method
- The types a method uses, and an example call.
- run_script
- Runs a script. Every request and response comes back in the result.
Scripts an agent creates appear as drafts under the agent's name. Its requests appear in Calls. A call wrapped in kaja.approve waits until you approve it.
If the agent loses the connection
The agent's tool call fails with “no Kaja window is attached to this agent session”.
A script runs in the Kaja tab, not in the container, and that tab was closed or reloaded. Open localhost:41520 again and leave it open while the agent works.