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.
npm install @nikcli-ai/sdk You also need a server to talk to:
nikcli serve --hostname 127.0.0.1 --port 4096 Create a client
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",
}) | Option | Type | Effect |
|---|---|---|
baseUrl | string | Required. Where the server is listening. |
directory | string | Sent as x-nikcli-directory on every call. Non-ASCII paths are percent-encoded for you. |
workspace | string | Sent as x-nikcli-workspace on every call. |
headers | HeadersInit | Merged into every request — this is where an Authorization header goes. |
signal | AbortSignal | Applied to every request, combined with any per-call signal. |
fetch | typeof fetch | Swap the transport — a proxy, a mock, or an in-memory handler. |
throwOnError | boolean | Reject 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.
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:
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 })
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.
// 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.
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.
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.
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.
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.
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.
// 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.
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.
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.