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.
| Step | Call | Why |
|---|---|---|
| 1 | client.event.subscribe() | Before submitting, or you miss the first tokens. |
| 2 | client.session.promptAsync(…) | Accepts the work and returns immediately. |
| 3 | message.part.updated | Carries each incremental delta. |
| 4 | session.idle | The turn is finished. |
Subscribe first
Streaming methods resolve to an object holding an async iterable: you await the connection, then iterate the feed.
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.
Subscribing after submitting is a race you will lose on short answers. Open the feed, then submit.
Submit without waiting
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.
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 ?? "")
}
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.
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
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.
| Event | Carries | Use for |
|---|---|---|
message.part.updated | part, delta? | Streaming output. |
message.updated | The message | Role, model and token counts. |
message.part.removed | ids | Dropping a part from the view. |
session.status | idle / busy / retry | A spinner that tells the truth. |
session.idle | sessionID | Turn finished. |
session.error | The error | Turn failed. |
permission.asked | The request | Prompting, or auto-replying. |
question.asked | The questions | The agent needs an answer to continue. |
todo.updated | The todo list | Progress for multi-step work. |
file.edited | The path | Refreshing an editor view. |
session.diff | Diff summary | A 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 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
| Symptom | Cause |
|---|---|
| First tokens missing | Subscribed after submitting. |
| Text repeats and grows | Printing part.text instead of delta. |
| Loop never ends | Not handling session.idle, or not filtering it by sessionID. |
| Hangs mid-turn | A permission request nobody answered. Handle permission.asked. |
| Events from other work | Using global.event(), which spans every instance, instead of event.subscribe(). |
| Nothing at all | Wrong directory, so you are watching a different instance's feed. |