A guided tour with real responses
Every request on this page was run against a real nikcli server, and every response is what actually came back.
A throwaway server
The fastest way to learn this API is to hit it. Point NIKCLI_TEST_HOME somewhere
disposable and the server keeps its sessions, config and database there instead of in your
real one — so you can create and delete freely.
mkdir -p ~/demo && cd ~/demo && git init -q
printf 'export function greet(name: string) {\n // TODO: localise this\n return `Hello, ${name}!`\n}\n' > greet.ts
printf '# demo\n' > README.md
git add -A && git commit -qm initial
# an isolated home, so nothing here touches your real sessions or config
NIKCLI_TEST_HOME=~/demo/.nikcli-home \
nikcli serve --hostname 127.0.0.1 --port 4199
The responses on this page were captured from exactly that setup. The only edit is
cosmetic: the long temporary paths were rewritten to /Users/me/demo, and two
very long arrays are marked where they were trimmed.
Is it alive
Health is instance-less — it needs no project and no auth on loopback, which makes it the right thing to poll while waiting for a server to come up.
curl -s http://127.0.0.1:4199/global/health {"healthy":true,"version":"local","revision":"local"} version reads local here because this server was run from a source
checkout; an installed build reports its release.
Where am I
/path answers the question every integration asks first: which directories is
this instance actually using? Note home pointing at the throwaway — that is the
isolation working.
curl -s "http://127.0.0.1:4199/path?directory=$PWD" {
"home": "/Users/me/demo/.nikcli-home",
"state": "/Users/me/demo/.nikcli-home/state",
"config": "/Users/me/demo/.nikcli-home/config",
"worktree": "/Users/me/demo",
"directory": "/Users/me/demo"
} /project/current resolves the project for the directory you selected. The
id is a stable hash of the worktree, so it is the same on every call and across
restarts.
curl -s "http://127.0.0.1:4199/project/current?directory=$PWD" {
"id": "c6d9c4f98cc34c8bfb0a40b3a60eca6f624750e6",
"worktree": "/Users/me/demo",
"canonical": "/Users/me/demo",
"vcs": "git",
"time": { "created": 1789656731762, "updated": 1789656731762 },
"sandboxes": []
} curl -s "http://127.0.0.1:4199/vcs?directory=$PWD"
{"branch":"main"} What can it do
The tool registry is the honest answer to "what can the agent actually do here". This is the real list from a default install — and the endpoint a plugin author checks to confirm a registered tool arrived.
curl -s "http://127.0.0.1:4199/experimental/tool/ids?directory=$PWD" ["invalid","question","bash","monitor","read","tree","glob","grep","edit","write",
"multiedit","task","delegation","context_collect","context_related","context_diagnostics",
"memory_search","generate_image","artifact","webfetch","todowrite","todoread","create_goal",
"get_goal","update_goal","websearch","codesearch","repo_clone","repo_overview","skill",
"apply_patch","lsp","plan_exit","plan_enter","speak","voice","opentui","plugin","advisor",
"delegator","search_tools","code_mode","browser_control","computer","herdr"] /agent returns each agent with its resolved permission table, which is where
those tools get gated. Trimmed heavily here — the real payload is long.
curl -s "http://127.0.0.1:4199/agent?directory=$PWD" [
{
"name": "build",
"mode": "primary",
"native": true,
"permission": [
{ "permission": "*", "pattern": "*", "action": "allow" },
{ "permission": "browser_control","pattern": "*", "action": "ask" },
{ "permission": "computer", "pattern": "*", "action": "ask" },
{ "permission": "read", "pattern": "*", "action": "allow" },
{ "permission": "read", "pattern": "*.env", "action": "ask" },
{ "permission": "read", "pattern": "*.env.example","action": "allow" }
/* … trimmed … */
]
}
/* … more agents … */
]
Notice read: * allowed, then read: *.env set to ask, then
read: *.env.example allowed again. Reading the list top to bottom is the only
way to predict what a given path will do.
Sessions
A fresh project has none:
curl -s "http://127.0.0.1:4199/session?directory=$PWD" [] Creating one returns the whole session, not just an id:
curl -s -X POST "http://127.0.0.1:4199/session?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"title":"api tour"}' {
"id": "ses_f50250f8fffexTvasNDaQeiLXP",
"slug": "swift-eagle",
"projectID": "c6d9c4f98cc34c8bfb0a40b3a60eca6f624750e6",
"directory": "/Users/me/demo",
"title": "api tour",
"version": "local",
"time": { "created": 1789656756337, "updated": 1789656756337 },
"skills": []
} slug is a human-readable handle generated alongside the id —
swift-eagle is easier to say out loud than
ses_f50250f8fffexTvasNDaQeiLXP. projectID matches what
/project/current returned, which is how a session stays bound to its project.
Files and search
File listing is project-relative, and carries ignored so a client can grey out
what git is not tracking.
curl -s "http://127.0.0.1:4199/file?path=.&directory=$PWD" [
{ "name": "greet.ts", "path": "greet.ts", "absolute": "/Users/me/demo/greet.ts", "type": "file", "ignored": false },
{ "name": "README.md", "path": "README.md", "absolute": "/Users/me/demo/README.md", "type": "file", "ignored": false }
] Reading returns content as a tagged object rather than a bare string:
curl -s "http://127.0.0.1:4199/file/content?path=greet.ts&directory=$PWD" {
"type": "text",
"content": "export function greet(name: string) {\n // TODO: localise this\n return `Hello, ${name}!`\n}"
} Text search returns ripgrep's own match structure — line number, byte offset and the span of each match within the line, which is enough to highlight without re-searching client side.
curl -s "http://127.0.0.1:4199/find?pattern=TODO&directory=$PWD" [
{
"path": { "text": "greet.ts" },
"lines": { "text": " // TODO: localise this" },
"line_number": 2,
"absolute_offset": 38,
"submatches": [{ "match": { "text": "TODO" }, "start": 5, "end": 9 }]
}
] Filename search is fuzzy, which is worth knowing before you treat it as a filter:
curl -s "http://127.0.0.1:4199/find/file?query=greet&directory=$PWD" ["greet.ts","README.md"] README.md came back too. /find/file ranks by fuzzy match rather
than filtering by substring, so treat the order as meaningful and the tail as noise.
The empty shapes
Worth seeing once, because "empty" is not spelled the same way everywhere. Most collections return a bare array; loops and missions return an object with the collection inside it.
GET /session/{id}/message -> []
GET /session/{id}/todo -> []
GET /session/{id}/diff -> []
GET /file/status -> []
GET /permission -> []
GET /question -> []
GET /pty -> []
GET /lsp -> []
GET /experimental/workspace -> []
GET /loop -> {"loops":[],"runtimes":[]}
GET /mission -> {"missions":[],"runtimes":[]}
That asymmetry is not an accident — the loop and mission payloads carry
runtimes alongside the definitions — but it will catch you if you assume every
list endpoint returns an array.
The event feed
The feed is plain server-sent events. Connecting to an idle instance gets you the handshake and then silence until something happens.
curl -sN "http://127.0.0.1:4199/event?directory=$PWD" data: {"type":"server.connected","properties":{}} server.connected arriving is the confirmation your subscription is live — which
is exactly the moment it is safe to submit a prompt without losing the first tokens. See
Streaming a response.
What errors look like
A declared domain error carries its name and data:
curl -s -w '\nHTTP %{http_code}\n' "http://127.0.0.1:4199/session/ses_doesnotexist?directory=$PWD" {"name":"NotFoundError","data":{"message":"Session not found: ses_doesnotexist"}}
HTTP 404 A body that fails schema validation reports the exact path that was wrong:
curl -s -w '\nHTTP %{http_code}\n' \
-X POST "http://127.0.0.1:4199/session/ses_x/message?directory=$PWD" \
-H 'content-type: application/json' -d '{}' {
"data": {},
"error": [{
"expected": "array",
"code": "invalid_type",
"path": ["parts"],
"message": "Invalid input: expected array, received undefined"
}],
"success": false
}
HTTP 400
That error array is the schema validator's own output, so path points at the
field — here, a missing parts on a prompt.
Two responses that are not errors, and surprise people:
curl -s -w '\nHTTP %{http_code}\n' "http://127.0.0.1:4199/file/content?path=nope.ts&directory=$PWD" {"type":"text","content":""}
HTTP 200 /file/content answers 200 with content: "" for a path that does
not exist. If your code distinguishes "empty file" from "no file", check with
/file first — the status code will not tell you.
curl -s "http://127.0.0.1:4199/nope?directory=$PWD" <!doctype html>
<html lang="en">
<head>
<title>NikCLI</title>
...
Unmatched non-/mobile paths fall through to the hosted web app so it can be
served from the same origin. So a typo in an endpoint gives you an HTML page with status
200 — if a client suddenly starts failing to parse JSON, check the path before anything
else.
The whole tour as a script
Run this in the demo project with the server up, and you get every response on this page in one go.
#!/usr/bin/env bash
# tour.sh — run the whole thing against a throwaway server
set -euo pipefail
BASE="http://127.0.0.1:4199"
DIR="$PWD"
say() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
get() { curl -s -m 20 "$BASE$1"; echo; }
say "health"; get "/global/health"
say "paths"; get "/path?directory=$DIR"
say "project"; get "/project/current?directory=$DIR"
say "vcs"; get "/vcs?directory=$DIR"
say "tools"; get "/experimental/tool/ids?directory=$DIR"
say "files"; get "/file?path=.&directory=$DIR"
say "grep TODO"; get "/find?pattern=TODO&directory=$DIR"
say "create session"
SESSION=$(curl -s -X POST "$BASE/session?directory=$DIR" \
-H 'content-type: application/json' -d '{"title":"api tour"}' | jq -r .id)
echo "$SESSION"
say "session"; get "/session/$SESSION?directory=$DIR"
say "messages"; get "/session/$SESSION/message?directory=$DIR"
say "diff"; get "/session/$SESSION/diff?directory=$DIR"
say "errors"
curl -s -w ' <- HTTP %{http_code}\n' "$BASE/session/ses_nope?directory=$DIR"
Because the server ran with NIKCLI_TEST_HOME pointed at
~/demo/.nikcli-home, deleting that directory removes every session and every
row this tour created. Nothing touched your real state.