Skip to content
nikcli
Docs
Documentation

Streaming a response

Subscribe to the event feed, submit a prompt asynchronously, print tokens as they arrive — the pattern every nikcli interface is built on.

The shape of it

session.prompt returns the finished message. That is fine for a script and wrong for an interface — nobody wants a blank screen for forty seconds.

Streaming is a different arrangement of the same endpoints: subscribe to the event feed first, then submit the prompt with promptAsync, which returns as soon as the work is accepted. Output arrives on the feed.

StepCallWhy
1client.event.subscribe()Before submitting, or you miss the first tokens.
2client.session.promptAsync(…)Accepts the work and returns immediately.
3message.part.updatedCarries each incremental delta.
4session.idleThe turn is finished.

Subscribe first

Streaming methods resolve to an object holding an async iterable: you await the connection, then iterate the feed.

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

for await (const event of stream) {
  console.log(event.type)
}

event.subscribe() is scoped to the resolved instance. client.global.event() is the same feed across every instance the server hosts — use it when one process watches several projects.

Order matters

Subscribing after submitting is a race you will lose on short answers. Open the feed, then submit.

Submit without waiting

ts
await client.session.promptAsync({
  sessionID: session.id,
  parts: [{ type: "text", text: "Explain what this repository does." }],
})

It takes the same input as session.prompt — including agent, model and tools — and differs only in returning before the work is done.

Print the deltas

message.part.updated fires as a part changes. It carries the whole part plus an optional delta — the text added since the last event. Print the delta; printing part.text reprints the message from the start every time.

ts
for await (const event of stream) {
  if (event.type !== "message.part.updated") continue
  if (event.properties.part.type !== "text") continue

  process.stdout.write(event.properties.delta ?? "")
}
delta is optional on purpose

A part can be replaced wholesale rather than appended to — a tool call completing, say — and then there is no delta. Fall back to re-rendering from part when you are painting a UI rather than appending to a terminal.

Know when it is done

session.idle carries the sessionID that finished. Filter on your own session — on a shared feed, other sessions go idle too.

ts
if (event.type === "session.idle" && event.properties.sessionID === session.id) {
  break
}

Watch session.error as well, or a failed turn looks identical to one that is still thinking.

The whole thing

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

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

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

// 1. open the feed before submitting anything
const { stream } = await client.event.subscribe()

// 2. submit without waiting for the turn to finish
await client.session.promptAsync({
  sessionID: session.id,
  parts: [{ type: "text", text: "Explain what this repository does." }],
})

// 3. print as it arrives, stop when the session goes idle
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 "session.error":
      console.error("\n", event.properties.error)
      process.exit(1)

    case "session.idle":
      if (event.properties.sessionID === session.id) process.exit(0)
  }
}

Useful events

The feed carries everything the server does, not just assistant text. These are the ones an interface usually handles.

EventCarriesUse for
message.part.updatedpart, delta?Streaming output.
message.updatedThe messageRole, model and token counts.
message.part.removedidsDropping a part from the view.
session.statusidle / busy / retryA spinner that tells the truth.
session.idlesessionIDTurn finished.
session.errorThe errorTurn failed.
permission.askedThe requestPrompting, or auto-replying.
question.askedThe questionsThe agent needs an answer to continue.
todo.updatedThe todo listProgress for multi-step work.
file.editedThe pathRefreshing an editor view.
session.diffDiff summaryA live change count.

The full list is the Event union exported by the SDK, so your editor will enumerate it and narrow properties per type.

Streaming with curl

The feed is plain server-sent events, so nothing about this requires the SDK. Note the ?token= form — an EventSource cannot set headers, so the API accepts the token as a query parameter.

Terminal
bash
# terminal 1 — watch the feed
curl -N "http://127.0.0.1:4096/event?directory=$PWD"

# terminal 2 — submit a prompt
curl -s -X POST "http://127.0.0.1:4096/session/$SESSION/prompt_async?directory=$PWD" \
  -H 'content-type: application/json' \
  -d '{"parts":[{"type":"text","text":"hello"}]}'

Failure modes

SymptomCause
First tokens missingSubscribed after submitting.
Text repeats and growsPrinting part.text instead of delta.
Loop never endsNot handling session.idle, or not filtering it by sessionID.
Hangs mid-turnA permission request nobody answered. Handle permission.asked.
Events from other workUsing global.event(), which spans every instance, instead of event.subscribe().
Nothing at allWrong directory, so you are watching a different instance's feed.