Skip to content
nikcli
Docs
Documentation

Client

Drive a running nikcli server from your own program with the generated, typed HTTP client.

Overview

@nikcli-ai/sdk wraps the server's HTTP API in a typed, namespaced client. It is generated from the same OpenAPI document as the API reference by @nikcli-ai/httpapi-codegen, so every endpoint is a method and a contract change breaks generation rather than becoming a runtime 404.

This is the path the web app, the desktop app and the mobile app already take. Nothing about them is privileged — they call the endpoints you can call.

Terminal
bash
npm install @nikcli-ai/sdk

You also need a server to talk to:

Terminal
bash
nikcli serve --hostname 127.0.0.1 --port 4096

Create a client

ts
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const client = createNikcliClient({
  baseUrl: "http://127.0.0.1:4096",
  // binds every call to one project instance
  directory: "/Users/me/project",
})
OptionTypeEffect
baseUrlstringRequired. Where the server is listening.
directorystringSent as x-nikcli-directory on every call. Non-ASCII paths are percent-encoded for you.
workspacestringSent as x-nikcli-workspace on every call.
headersHeadersInitMerged into every request — this is where an Authorization header goes.
signalAbortSignalApplied to every request, combined with any per-call signal.
fetchtypeof fetchSwap the transport — a proxy, a mock, or an in-memory handler.
throwOnErrorbooleanReject on failure instead of resolving with error. See below.

The result envelope

By default calls resolve with an envelope rather than rejecting. Transport failures and declared HTTP errors both arrive as error, so a failed call in a UI event handler cannot become an unhandled rejection.

ts
const { data, error } = await client.session.list()
// guard on `data`, not on `error`: `error` is `unknown`, so testing it
// does not narrow `data` away from `undefined`
if (!data) throw error

data.forEach((session) => console.log(session.id, session.title))

In a script, where an unhandled rejection is exactly what you want, opt back into throwing — per call or once for the whole client:

ts
const client = createNikcliClient({ baseUrl, throwOnError: true })

// rejects on failure, so `data` is always present at runtime
const { data } = await client.session.list()

// or per call, on a client that does not throw by default
const one = await client.session.get({ sessionID }, { throwOnError: true })
throwOnError does not change the type

It changes runtime behaviour only. The call still returns { data, error } and data is still typed as possibly undefined, so you keep destructuring it — with data!, or a guard, or a one-line unwrap helper of your own.

Choosing an instance

One server hosts many projects. The client's directory is the default for every call, and any call can override it — useful when one process drives several checkouts.

ts
// the client default
await client.session.list()

// inline, for one call
await client.session.list({ directory: "/Users/me/other-project" })

// a workspace within the instance
await client.session.list({ workspace: workspaceID })

The server resolves this exactly as described in Choosing an instance: the header the client sets is the same one curl would send.

Method names

Methods are namespaced by resource and follow the operation ids listed throughout the API reference: session.prompt is client.session.prompt, mobile.git.commit is client.mobile.git.commit. A handful are renamed where the dotted id would collide with a JavaScript keyword or an existing namespace — session.messageRemove is client.session.messageDelete, for instance — so let the types lead rather than transcribing ids by hand.

ts
await client.session.create({ title: "triage" })
await client.session.messages({ sessionID })
await client.file.read({ path: "src/index.ts" })
await client.find.text({ pattern: "TODO" })
await client.project.current()
await client.config.get()
await client.doctor.run()

Running a session

The common shape: create a session, send a prompt, read what came back. Everything else — sharing, forking, reverting, todos, diffs — hangs off the same session id.

triage.ts
ts
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const client = createNikcliClient({
  baseUrl: "http://127.0.0.1:4096",
  directory: process.cwd(),
  throwOnError: true,
})

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 currently failing, and why?" }],
})

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)

session.prompt waits for the assistant to finish. For fire-and-forget submission — queue the work and watch the event feed instead — use client.session.promptAsync.

Pick the model at the session, not the call

session.prompt accepts an explicit model and agent, but leaving them unset inherits the launching session's model, which is usually what you want for background work.

Streams and events

Streaming endpoints resolve to an object holding an async iterable, so you await the connection and then iterate the feed.

ts
const { stream } = await client.event.subscribe()

for await (const event of stream) {
  if (event.type === "message.part.updated" && event.properties.part.type === "text") {
    // `delta` is the text added since the last event; `part.text` is cumulative
    process.stdout.write(event.properties.delta ?? "")
  }
}

client.event.subscribe() is scoped to the resolved instance; client.global.event() spans every instance the server hosts, and client.sync.event.stream() carries sync traffic. All three are the SSE feeds listed under Streaming.

Pair promptAsync with the feed

session.promptAsync returns as soon as the work is accepted. Subscribe first, then submit, and you see the assistant's output as it is produced instead of waiting for the whole turn. Streaming a response walks the whole flow.

Authentication

On loopback with no password set, nothing is needed. Anything else goes through headers — the client does not manage credentials for you.

ts
// bearer token (mobile pairing token or an nku_ account token)
const client = createNikcliClient({
  baseUrl,
  headers: { Authorization: `Bearer ${process.env.NIKCLI_TOKEN}` },
})

// basic auth, for a server started with NIKCLI_SERVER_PASSWORD
const basic = btoa(`nikcli:${process.env.NIKCLI_SERVER_PASSWORD}`)
const client2 = createNikcliClient({
  baseUrl,
  headers: { Authorization: `Basic ${basic}` },
})

The full scheme table is in Authentication.

Errors

ClientError is what a transport or protocol failure resolves to. It folds in its cause, so a server that died mid-request reports why rather than a bare Transport.

ts
import { ClientError, createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const { data, error, response } = await client.session.get({ sessionID })

if (error instanceof ClientError) {
  console.error(error.reason, error.message, error.cause)
} else if (error) {
  // a declared HTTP error — the schema named on the endpoint in the API reference
  console.error(response?.status, error)
}

Declared failures are typed on the endpoint, so a ConfigUpdateError or a ProviderAuthError arrives as that shape rather than collapsing into a generic 500. The endpoint pages list which errors each operation can return.

Other languages

There is no official client beyond JavaScript, but the contract is public. Point any OpenAPI generator at the document and you get the same surface.

Terminal
bash
curl -s https://nikcli-ai.dev/docs/api/openapi.json -o nikcli-openapi.json

Operation ids are stable and dotted (session.prompt, experimental.workspace.warp), so most generators produce sensibly grouped method names without extra configuration.