The assistant
The main of the assistant only wires. It gives the layers of the JVM and runs the shared UI:
def ui: UI < (Async & Scope) =
val mode =
if args.contains("--fake") then StartMode.FakeOnly
else StartMode.Detect
Tui.size.map(size =>
Memo.run(
Env.runLayer(
AssistantJvm.storage,
AssistantJvm.secrets,
Wiring.conversations,
Wiring.log,
Wiring.providers(AssistantJvm.live, FakeProvider.make(), mode),
Layer(Surface.Terminal),
)(
AssistantUi.run(
mode,
Tui.quit,
size.map(t => ScreenSize(t.width, t.height)),
),
),
),
)
end ui
The assistant is a chat with a language model in the terminal: Anthropic, OpenAI, or a scripted fake provider. Start it with no network and no key:
examples/target/bin/assistant --fake
With --fake, the app reads no key. Send /fail to see an error, and push Esc while a reply streams to stop it. With no flag, the app uses a real provider when it finds a key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or the key file of the app). The examples/README.md file of the repository tells how to give a key.
The assistant is also the blueprint of a real app. Follow its shape for a new app.
The domain is pure
The types are values: Conversation, Message, ChatEvent, ChatError and Settings. The fold of a streamed reply is a pure state machine with laws. DomainPropertyTest checks the laws on the JVM and on Node.js.
/** The message after `event`. */
def apply(message: Message, event: ChatEvent): Message =
message.status match
case MessageStatus.Streaming =>
event match
case ChatEvent.TextDelta(t) =>
message.copy(content = message.content + t)
case ChatEvent.Stop(r, u) =>
message.copy(status = MessageStatus.Done(r), usage = u)
case ChatEvent.Error(e) =>
message.copy(status = MessageStatus.Failed(e))
case _ => message
The services are traits
A service is a trait: a capability. Its errors are values (Abort[ChatError]):
/** A chat provider: the capability to stream the reply to a request. The stream
* gives the pieces of the text, then one [[ChatEvent.Stop]] or one
* [[ChatEvent.Error]]; or it fails with a [[ChatError]] (an HTTP status, no
* connection). An interrupt of the fiber that runs it stops it, and its
* connection closes with it.
*
* The interpreters: [[FakeProvider]] (shared, scripted), and on the JVM the
* Anthropic and OpenAI providers ([[SseProvider]] over the HTTP client of
* kyo-http).
*/
// claude 2026-10-19: created (L3)
trait ChatProvider:
/** The events of the reply to `request`. */
def stream(request: ChatRequest)(using
Frame,
): Stream[ChatEvent, Async & Abort[ChatError]]
An interpreter is in the make of the companion. Each platform has its interpreters, and the tests have interpreters in memory:
/** A log that keeps the last `limit` lines; each line starts with its time
* (UTC, `HH:MM:SS`) and its level.
*/
def make(limit: Int = 500)(using Frame): AppLog < Sync =
Signal
.initRef(Chunk.empty[String])
.map(ref =>
new AppLog:
val lines: Signal[Chunk[String]] = ref
private def add(level: String, message: String): Unit < Sync =
Clock.now.map(now =>
ref
.updateAndGet(ls =>
(ls :+ s"${now.show.slice(11, 19)} $level $message")
.takeRight(limit.max(1)),
)
.unit,
)
def info(message: String)(using Frame): Unit < Sync =
add("info", message).andThen(Log.info(message))
def warn(message: String)(using Frame): Unit < Sync =
add("warn", message).andThen(Log.warn(message)),
)
One store
Assistant is the one store of the app. It groups the state, the derived values and the actions. A reply runs on a fiber of its own:
/** Starts the fiber of the reply to `request`. The fiber waits for a gate,
* so the run state names it before it can end.
*/
private def start(
id: ConversationId,
request: ChatRequest,
s: Settings,
): Unit < Sync =
providers.get(s.provider) match
case Absent =>
fold(id, ChatEvent.Error(ChatError.Auth))
.andThen(save(id))
.andThen(log.warn(s"${s.provider.label} has no key"))
case Present(provider) =>
for
gate <- Latch.init(1)
gen <- generation.incrementAndGet
fiber <- Fiber.initUnscoped(
gate.await.andThen(reply(id, gen, request, provider)),
)
_ <- run.set(Run.Busy(id, gen, fiber))
_ <- streamingRef.set(Streaming.Running(id))
_ <- log.info(s"${s.provider.label}: a request to ${s.model}")
_ <- gate.release
yield ()
A cancel interrupts that fiber. The stream and its HTTP connection stop with it:
/** Stops the reply that streams: its fiber ends, the message is marked
* stopped (its text stays), and the conversation is saved.
*/
def cancel: Unit < Async =
run.getAndSet(Run.Idle).map {
case Run.Idle => Kyo.unit
case Run.Busy(id, _, fiber) =>
for
_ <- fiber.interrupt
_ <- fold(id, ChatEvent.Error(ChatError.Cancelled))
_ <- streamingRef.set(Streaming.Idle)
_ <- save(id)
_ <- log.info("the reply was stopped")
yield ()
}
The wiring is Kyo layers
The shared part gives the layers that build on the services of a platform:
/** The providers: with [[StartMode.Detect]], each provider whose key
* `Secrets` finds (`live` makes it); always the fake `fake`.
*/
def providers(
live: (ProviderChoice, ApiKey) => ChatProvider,
fake: ChatProvider,
mode: StartMode,
)(using Frame): Layer[Providers, Env[Secrets & AppLog] & Sync] =
Layer.from[Secrets, AppLog, Providers, Sync]((s, log) =>
mode match
case StartMode.Detect => Providers.make(s, live, fake, log)
case StartMode.FakeOnly =>
Providers.fakeOnly(fake): Providers < Sync,
)
A test wires the same app with the storage and the keys in memory and the fake provider:
/** The app on a `width` x `height` screen: the storage and the keys in
* memory, the scripted provider `fake` (also as the real providers).
*/
private def start(
fake: ChatProvider = FakeProvider.make(delay = 5.millis),
keys: Map[ProviderChoice, String] = Map.empty,
width: Int = 100,
height: Int = 30,
)(using Frame): Headless < (Async & Scope) =
Storage.inMemory.map { storage =>
val (files, secrets) = Memory.wiring(storage, keys)
Headless.start(width, height, stylesheet = Look.palette)(quit =>
Tui.size.map(size =>
Memo.run(
Env.runLayer(
files,
secrets,
Wiring.conversations,
Wiring.log,
Wiring.providers((_, _) => fake, fake, StartMode.Detect),
Layer(Surface.Terminal),
)(
AssistantUi.run(
StartMode.Detect,
quit,
size.map(t => ScreenSize(t.width, t.height)),
),
),
),
),
)
}
Where the code is
| Part | Where |
|---|---|
| The domain, the services, the store and the UI (plain kyo-ui with tui4s-use and tui4s-widgets) | modules/assistant/shared |
| The interpreters of the JVM (kyo-http, the keys, the files) | modules/assistant/jvm |
The interpreters of the browser (fetch, localStorage) | modules/assistant/js |
| The terminal main | examples/src/main/scala/examples/assistant/Assistant.scala |
| The web main | examples/web |