Skip to content
nikcli
Docs
Documentation

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.

Terminal
bash
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
Everything below is copied from a real run

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.

Terminal
bash
curl -s http://127.0.0.1:4199/global/health
response
json
{"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.

Terminal
bash
curl -s "http://127.0.0.1:4199/path?directory=$PWD"
response
json
{
  "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.

Terminal
bash
curl -s "http://127.0.0.1:4199/project/current?directory=$PWD"
response
json
{
  "id": "c6d9c4f98cc34c8bfb0a40b3a60eca6f624750e6",
  "worktree": "/Users/me/demo",
  "canonical": "/Users/me/demo",
  "vcs": "git",
  "time": { "created": 1789656731762, "updated": 1789656731762 },
  "sandboxes": []
}
request + response
bash
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.

Terminal
bash
curl -s "http://127.0.0.1:4199/experimental/tool/ids?directory=$PWD"
response
json
["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.

Terminal
bash
curl -s "http://127.0.0.1:4199/agent?directory=$PWD"
response (trimmed)
json
[
  {
    "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 … */
]
Permission rules are ordered and last-match-wins

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:

Terminal
bash
curl -s "http://127.0.0.1:4199/session?directory=$PWD"
response
json
[]

Creating one returns the whole session, not just an id:

Terminal
bash
curl -s -X POST "http://127.0.0.1:4199/session?directory=$PWD" \
  -H 'content-type: application/json' \
  -d '{"title":"api tour"}'
response
json
{
  "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.

Terminal
bash
curl -s "http://127.0.0.1:4199/file?path=.&directory=$PWD"
response
json
[
  { "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:

Terminal
bash
curl -s "http://127.0.0.1:4199/file/content?path=greet.ts&directory=$PWD"
response
json
{
  "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.

Terminal
bash
curl -s "http://127.0.0.1:4199/find?pattern=TODO&directory=$PWD"
response
json
[
  {
    "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:

Terminal
bash
curl -s "http://127.0.0.1:4199/find/file?query=greet&directory=$PWD"
response
json
["greet.ts","README.md"]
Both files matched "greet"

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.

real responses, idle project
text
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.

Terminal
bash
curl -sN "http://127.0.0.1:4199/event?directory=$PWD"
response
text
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:

Terminal
bash
curl -s -w '\nHTTP %{http_code}\n' "http://127.0.0.1:4199/session/ses_doesnotexist?directory=$PWD"
response
json
{"name":"NotFoundError","data":{"message":"Session not found: ses_doesnotexist"}}
HTTP 404

A body that fails schema validation reports the exact path that was wrong:

Terminal
bash
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 '{}'
response
json
{
  "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:

Terminal
bash
curl -s -w '\nHTTP %{http_code}\n' "http://127.0.0.1:4199/file/content?path=nope.ts&directory=$PWD"
response
json
{"type":"text","content":""}
HTTP 200
A missing file reads as empty, not as 404

/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.

Terminal
bash
curl -s "http://127.0.0.1:4199/nope?directory=$PWD"
response — HTTP 200
html
<!doctype html>
<html lang="en">
  <head>
    <title>NikCLI</title>
    ...
An unknown path returns the web app, not a 404

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.

tour.sh
bash
#!/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"
Then throw it away

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.