# 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` |

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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

```
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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

```
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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

```
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 

1

2

3

4

5

```
case "question.asked":
  // { id, sessionID, questions: [{ header, question, options, multiple?, custom? }] }
  state.questions.push(event.properties)
  render()
  break
```

ts 

1

2

3

4

5

6

7

8

```
// 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 

1

2

3

4

5

6

7

8

```
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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

```
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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

```
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

| 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"`. |

## Related

-   [Streaming a response The feed mechanics in isolation.](/docs/guides/streaming)
-   [Using the API The endpoints this guide composes.](/docs/guides/api)
-   [Permissions What the user is actually approving.](/docs/permissions)
-   [API Reference Every session endpoint and payload.](/docs/api/session)

---

Source: https://nikcli-ai.dev/docs/guides/custom-interface
