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.
| Obligation | Event | Answered with |
|---|---|---|
| Show output as it arrives | message.part.updated | — |
| Say whether it is working | session.status, session.idle | — |
| Let the user approve an action | permission.asked | permission.reply |
| Let the user answer a question | question.asked | question.reply / reject |
| Surface progress on long work | todo.updated | — |
| Show what changed on disk | session.diff, file.edited | — |
| Report failure | session.error | — |
| Survive a reload | — | session.messages |
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.
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.
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
}
}
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.
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 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.
case "question.asked":
// { id, sessionID, questions: [{ header, question, options, multiple?, custom? }] }
state.questions.push(event.properties)
render()
break // 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 }) 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.
case "todo.updated":
state.todos = event.properties.todos
render()
break
case "file.edited":
markDirty(event.properties.path)
break 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.
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()
} 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.
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() 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
| Symptom | Cause |
|---|---|
| Spinner forever, no output | An unanswered permission.asked or question.asked. |
| Text duplicates | Appending part.text as well as delta. |
| Parts appear twice after reconnect | Appending to a list instead of keying by part.id. |
| Missed the first tokens | Subscribed after promptAsync. |
| A change is silently lost | Snapshotted before subscribing. |
question.reply rejected | answers passed as string[] instead of string[][]. |
| UI answers someone else's prompt | Not filtering events by sessionID. |
| Tool calls render as blanks | Only handling part.type === "text". |