Skip to content
nikcli
Docs
Documentation

Building a custom interface

Everything a real nikcli client has to handle — streaming parts, permission prompts, questions, todos and reconnects — assembled once.

What an interface owes the user

Streaming text is the easy half. What separates a demo from something usable is the rest: an agent that asks permission must be answerable, a question must be answerable, and a reload must not lose the conversation.

ObligationEventAnswered with
Show output as it arrivesmessage.part.updated—
Say whether it is workingsession.status, session.idle—
Let the user approve an actionpermission.askedpermission.reply
Let the user answer a questionquestion.askedquestion.reply / reject
Surface progress on long worktodo.updated—
Show what changed on disksession.diff, file.edited—
Report failuresession.error—
Survive a reload—session.messages
An interface that ignores permission.asked hangs

Nothing times out on the user's behalf. Drop that event and the session sits waiting forever while your UI shows a spinner — the single most common bug in a first client.

State from one feed

Do not poll. Load the transcript once, then let the event feed drive every subsequent change — that is how the TUI, the desktop app and the mobile app all work, and it is why they agree with each other.

ts
type State = {
  parts: Map<string, Part>
  status: "idle" | "busy" | "retry"
  permissions: PermissionRequest[]
  questions: QuestionRequest[]
  todos: Todo[]
}

// 1. snapshot
const { data: messages } = await client.session.messages({ sessionID })
for (const message of messages ?? []) {
  for (const part of message.parts) state.parts.set(part.id, part)
}

// 2. every change after that arrives on the feed
const { stream } = await client.event.subscribe()

Keying parts by id rather than appending is what makes updates idempotent: the same part arrives many times as it grows, and a map absorbs that for free.

Rendering parts

A message is an array of parts, and text is only one kind. Handle the others or a tool call renders as a blank gap.

ts
for await (const event of stream) {
  switch (event.type) {
    case "message.part.updated": {
      const part = event.properties.part
      state.parts.set(part.id, part)

      // append when we can, re-render when we cannot
      if (part.type === "text" && event.properties.delta) {
        appendText(part.id, event.properties.delta)
      } else {
        rerender(part.id)
      }
      break
    }

    case "message.part.removed":
      state.parts.delete(event.properties.partID)
      break

    case "session.status":
      state.status = event.properties.status.type
      break
  }
}
delta is an optimisation, not the source of truth

A part can be replaced wholesale — a tool call completing, a text part being rewritten — and then there is no delta. Keep part authoritative and treat delta as the fast path for appending.

Permissions

A permission request names what is being asked for and what it applies to. The reply is one of three values, and always is what the request itself says it can be remembered as.

ts
case "permission.asked": {
  const request = event.properties
  // { id, sessionID, permission, patterns, metadata, always, tool? }
  state.permissions.push(request)
  render()
  break
}

case "permission.replied":
  state.permissions = state.permissions.filter(
    (entry) => entry.id !== event.properties.requestID,
  )
  render()
  break
ts
async function reply(request: PermissionRequest, reply: "once" | "always" | "reject") {
  await client.permission.reply({ requestID: request.id, reply })
}

Show permission and patterns in the prompt — "run bash" is not enough information to approve; "run bash: rm -rf build" is. Offer always only when the request carries matching entries in always.

On reconnect, replay the backlog with client.permission.list(): requests raised while you were disconnected are still waiting.

Questions

A question is the agent asking the user to choose. One request carries several questions, each with its own options — so the answer is an array of arrays, one inner array per question.

ts
case "question.asked":
  // { id, sessionID, questions: [{ header, question, options, multiple?, custom? }] }
  state.questions.push(event.properties)
  render()
  break
ts
// one answer per question, in order; inner arrays allow multi-select
await client.question.reply({
  requestID: request.id,
  answers: [["postgres"], ["redis", "s3"]],
})

// the user dismissed it
await client.question.reject({ requestID: request.id })
The nesting is the part people get wrong

answers is string[][], not string[]. The outer array lines up with questions by position; the inner one holds the selected values, which is more than one only when the question sets multiple.

A question with custom accepts a value that is not in options, so render a free-text field alongside the choices.

Todos and diffs

For anything longer than a single answer, the todo list is the honest progress indicator — it is what the agent is actually tracking, not a spinner.

ts
case "todo.updated":
  state.todos = event.properties.todos
  render()
  break

case "file.edited":
  markDirty(event.properties.path)
  break
ts
const { data: todos } = await client.session.todo({ sessionID })
const { data: diff } = await client.session.diff({ sessionID })

Reconnecting

The feed will drop — laptops sleep, networks change. Recovery is: resubscribe, re-fetch the snapshot, and replay the pending queues. Because parts are keyed by id, re-applying the snapshot over existing state is safe.

ts
async function connect() {
  while (true) {
    try {
      const { stream } = await client.event.subscribe()

      // anything that happened while we were away
      await resync()

      for await (const event of stream) apply(event)
    } catch (error) {
      console.warn("feed dropped, reconnecting", error)
    }
    await new Promise((resolve) => setTimeout(resolve, 1000))
  }
}

async function resync() {
  const [messages, permissions, questions] = await Promise.all([
    client.session.messages({ sessionID }),
    client.permission.list(),
    client.question.list(),
  ])

  for (const message of messages.data ?? []) {
    for (const part of message.parts) state.parts.set(part.id, part)
  }
  state.permissions = permissions.data ?? []
  state.questions = questions.data ?? []
  render()
}
Resync after subscribing, not before

Subscribe first, then snapshot. The other order leaves a window where an event fires after your snapshot and before your subscription, and that change is lost until something else touches the same part.

A working client

A terminal client, complete enough to use: streams output, prompts for permissions, answers questions, and survives a dropped feed.

client.ts
ts
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"
import * as readline from "node:readline/promises"

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

const rl = readline.createInterface({ input: process.stdin, output: process.stdout })

const { data: session, error } = await client.session.create({ title: "custom client" })
if (!session) throw error

async function handlePermission(request: { id: string; permission: string; patterns: string[] }) {
  const answer = await rl.question(
    `\n[permission] ${request.permission} ${request.patterns.join(" ")} — (y)es / (a)lways / (n)o: `,
  )
  await client.permission.reply({
    requestID: request.id,
    reply: answer.startsWith("a") ? "always" : answer.startsWith("y") ? "once" : "reject",
  })
}

async function handleQuestion(request: {
  id: string
  questions: Array<{ header: string; question: string; options: Array<{ value: string }> }>
}) {
  const answers: string[][] = []
  for (const question of request.questions) {
    console.log(`\n[${question.header}] ${question.question}`)
    question.options.forEach((option, index) => console.log(`  ${index + 1}. ${option.value}`))
    const picked = await rl.question("choose: ")
    const option = question.options[Number(picked) - 1]
    answers.push([option?.value ?? picked])
  }
  await client.question.reply({ requestID: request.id, answers })
}

async function run(prompt: string) {
  // subscribe before submitting, or the first tokens are lost
  const { stream } = await client.event.subscribe()

  await client.session.promptAsync({
    sessionID: session!.id,
    parts: [{ type: "text", text: prompt }],
  })

  for await (const event of stream) {
    switch (event.type) {
      case "message.part.updated":
        if (event.properties.part.type === "text") {
          process.stdout.write(event.properties.delta ?? "")
        }
        break

      case "permission.asked":
        if (event.properties.sessionID === session!.id) {
          await handlePermission(event.properties)
        }
        break

      case "question.asked":
        if (event.properties.sessionID === session!.id) {
          await handleQuestion(event.properties)
        }
        break

      case "todo.updated":
        // a real UI would render this; a terminal one can stay quiet
        break

      case "session.error":
        console.error("\n[error]", event.properties.error)
        return

      case "session.idle":
        if (event.properties.sessionID === session!.id) {
          const { data: diff } = await client.session.diff({ sessionID: session!.id })
          if (diff?.length) console.log("\n\n[changed]\n", diff)
          return
        }
    }
  }
}

while (true) {
  const prompt = await rl.question("\n> ")
  if (!prompt || prompt === "exit") break
  await run(prompt)
}
rl.close()
Filter every event by sessionID

Even the instance-scoped feed carries other sessions on the same project — a loop, a background delegation, another window. Answering another session's permission prompt from your UI is a confusing bug to track down.

Failure modes

SymptomCause
Spinner forever, no outputAn unanswered permission.asked or question.asked.
Text duplicatesAppending part.text as well as delta.
Parts appear twice after reconnectAppending to a list instead of keying by part.id.
Missed the first tokensSubscribed after promptAsync.
A change is silently lostSnapshotted before subscribing.
question.reply rejectedanswers passed as string[] instead of string[][].
UI answers someone else's promptNot filtering events by sessionID.
Tool calls render as blanksOnly handling part.type === "text".