Using the API
Start a server and drive it — with curl and the typed client side by side, one task at a time.
Start a server
There is no hosted API. nikcli runs on your machine and the API is how everything else talks to it — the web app, the desktop app and the mobile app are all clients of these endpoints.
nikcli serve --hostname 127.0.0.1 --port 4096
If 4096 is taken the server falls back to an ephemeral port rather than
failing. Take the URL from the output instead of hardcoding it.
Your first request
Health is instance-less, so it answers before anything else is configured.
curl -s http://127.0.0.1:4096/global/health Then install the client, which is generated from the same contract this guide describes.
npm install @nikcli-ai/sdk import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"
const client = createNikcliClient({
baseUrl: "http://127.0.0.1:4096",
directory: process.cwd(),
})
const { data } = await client.global.health()
console.log(data)
They do not reject by default. Guard on data — not on error,
which is typed unknown and so does not narrow. Full explanation in
the result envelope.
Authenticate
On loopback with no password set, nothing is required. Once the server is reachable from elsewhere, it is.
NIKCLI_SERVER_PASSWORD=secret nikcli serve --hostname 0.0.0.0 --port 4096
curl -s -u nikcli:secret http://127.0.0.1:4096/session
curl -s -H "Authorization: Bearer $NIKCLI_TOKEN" http://127.0.0.1:4096/session
# for clients that cannot set headers, such as EventSource
curl -s "http://127.0.0.1:4096/session?token=$NIKCLI_TOKEN" const client = createNikcliClient({
baseUrl: "http://127.0.0.1:4096",
headers: { Authorization: `Bearer ${process.env.NIKCLI_TOKEN}` },
}) Pick the project
One server hosts many projects, so most endpoints need to know which. Pass
directory as a query parameter or as the x-nikcli-directory header —
the client sets the header for you from its own directory option.
curl -s "http://127.0.0.1:4096/project/current?directory=$PWD"
curl -s http://127.0.0.1:4096/project/current -H "x-nikcli-directory: $PWD" // the client default
await client.project.current()
// override for one call
await client.session.list({ directory: "/Users/me/other-project" }) Create a session and prompt it
A session holds the conversation and the changes made in it. Create one, then send prompts to its id.
SESSION=$(curl -s -X POST "http://127.0.0.1:4096/session?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"title":"triage"}' | jq -r .id)
curl -s -X POST "http://127.0.0.1:4096/session/$SESSION/message?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"parts":[{"type":"text","text":"Which tests are failing, and why?"}]}' const { data: session } = await client.session.create({ title: "triage" })
const { data: reply } = await client.session.prompt({
sessionID: session!.id,
parts: [{ type: "text", text: "Which tests are failing, and why?" }],
})
for (const part of reply!.parts) {
if (part.type === "text") console.log(part.text)
} session.prompt waits for the whole turn. To get output as it is produced, use
promptAsync and the event feed — see
Streaming a response.
A prompt is an array of parts, so one message can mix text with file attachments, an agent
reference or a subtask. { type: "text", text } is the common case.
Read what changed
Sessions edit files. Ask the session what it changed rather than diffing the working tree yourself — this scopes the answer to that session's work.
curl -s "http://127.0.0.1:4096/session/$SESSION/diff?directory=$PWD"
curl -s "http://127.0.0.1:4096/session/$SESSION/message?directory=$PWD" const { data: diff } = await client.session.diff({ sessionID: session!.id })
const { data: messages } = await client.session.messages({ sessionID: session!.id, limit: 20 }) Read and write files
File access goes through the instance, so paths are resolved inside the selected project rather than against the server's own working directory.
curl -s "http://127.0.0.1:4096/file/content?path=src/index.ts&directory=$PWD"
curl -s -X PUT "http://127.0.0.1:4096/file/content?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"path":"NOTES.md","content":"# Notes\n"}'
curl -s "http://127.0.0.1:4096/file/status?directory=$PWD" const { data: file } = await client.file.read({ path: "src/index.ts" })
await client.file.write({ path: "NOTES.md", content: "# Notes\n" })
const { data: status } = await client.file.status() Search the project
Three searches, matching what the agent's own tools use: text, filenames and symbols.
curl -s "http://127.0.0.1:4096/find?pattern=TODO&directory=$PWD"
curl -s "http://127.0.0.1:4096/find/file?query=router&directory=$PWD"
curl -s "http://127.0.0.1:4096/find/symbol?query=createNikcliClient&directory=$PWD" const { data: matches } = await client.find.text({ pattern: "TODO" })
const { data: files } = await client.find.files({ query: "router" })
const { data: symbols } = await client.find.symbols({ query: "createNikcliClient" }) Run a shell command
Running a command inside a session records it in the transcript, so the agent sees the result on the next turn. That is usually what you want over shelling out yourself.
const { data: result } = await client.session.shell({
sessionID: session!.id,
agent: "build",
command: "bun test",
}) session.shell takes an agent, because the command runs under that
agent's permissions rather than unconditionally.
Answer a permission request
If the agent asks for approval and nothing answers, the turn waits. An automated client has to decide — which is exactly why the endpoints exist.
const { data: pending } = await client.permission.list()
for (const request of pending ?? []) {
await client.permission.reply({
requestID: request.id,
reply: "once", // "once" | "always" | "reject"
})
}
Watching permission.asked on the event feed is the reactive version of the same
thing, and is how the TUI and the mobile app do it.
A complete script
Putting it together: open a session, ask it to do something, approve whatever it asks for, and print the resulting diff.
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"
const client = createNikcliClient({
baseUrl: process.env.NIKCLI_URL ?? "http://127.0.0.1:4096",
directory: process.cwd(),
headers: process.env.NIKCLI_TOKEN
? { Authorization: `Bearer ${process.env.NIKCLI_TOKEN}` }
: undefined,
})
const { data: session, error } = await client.session.create({ title: "automated review" })
if (!session) throw error
// approve anything this session asks for while it works
const approve = setInterval(async () => {
const { data: pending } = await client.permission.list()
for (const request of pending ?? []) {
await client.permission.reply({ requestID: request.id, reply: "once" })
}
}, 1000)
try {
const { data: reply } = await client.session.prompt({
sessionID: session.id,
parts: [{ type: "text", text: "Review the staged changes and fix anything obviously broken." }],
})
for (const part of reply?.parts ?? []) {
if (part.type === "text") console.log(part.text)
}
const { data: diff } = await client.session.diff({ sessionID: session.id })
console.log(diff)
} finally {
clearInterval(approve)
}
The loop above says yes to every request. That is reasonable in a sandbox or a throwaway CI
container, and not reasonable on a machine that holds credentials. Filter on
request.permission if the script runs anywhere real.
Where to go next
| You want | Go to |
|---|---|
| To see what these actually return | A guided tour with real responses |
| Output as it is produced | Streaming a response |
| The exact shape of a request or response | API Reference |
| Client options, errors and method naming | Client |
| To run nikcli in your own process | Embedded SDK |
| A client in another language | The OpenAPI document |