# A Telegram bot is a state machine pretending not to be

> telek models a conversation as pure transitions with effects on the side — which is what makes a wizard resumable, testable and portable between two transports.

Page: https://kotlin.website/blog/bots-as-state-machines · Published: 2026-01-25

Every Telegram bot starts as a `when` over the incoming message and ends as a pile of
callbacks with a nullable `awaitingCity` field somewhere in a map. The reason is not
laziness. It is that the framework hands you an event and no place to put the fact that
this user is halfway through something.

[telek](https://github.com/youndie/telek) starts from the shape that was there all along:

> state + input → newState + effects

## Pure transitions, effects on the side

A transition is a function. It takes the current state and the input, and returns the next
state plus a list of things to do — send this message, edit that one, call an API. It does
none of them.

That split is the whole design, and everything useful falls out of it:

- A transition is testable with no bot, no network and no mocks. It is a function over
  data.
- A conversation can be replayed from its inputs, because nothing was hidden in a closure.
- Persisting a wizard is persisting a value, not a call stack — which is what makes it
  survive a restart.

A `StateDispatcher` describes one flow: a multi-step form, a checkout, an onboarding
sequence. Flows compose rather than nest, so adding a step is adding a transition rather
than another level of indentation.

## Two transports, same shape

telek supports both [kotlin-telegram-bot](https://github.com/kotlin-telegram-bot/kotlin-telegram-bot)
and [ktgbotapi](https://github.com/InsanusMokrassar/TelegramBotAPI), as separate modules
with the same API shape. You pick the client you already use:

```kotlin
implementation("io.github.youndie.telek:core:<VERSION>")

// pick one transport:
implementation("io.github.youndie.telek:telegram:<VERSION>") // kotlin-telegram-bot
// implementation("io.github.youndie.telek:ktg:<VERSION>")   // ktgbotapi
```

This is only possible because the core never touches the network. If transitions performed
their own sends, the transport would be baked into every one of them and there would be no
seam to swap.

## Native, because the core is pure

`core`, `ktg`, `router`, `router-ktg`, `persistence` and `testing` are published for
**JVM, linuxX64 and linuxArm64**. A bot can ship as a single native Linux binary — no JRE
on the box, a few tens of megabytes of RSS, and a start measured in milliseconds.

`telegram` and `router-telegram` stay JVM-only, because kotlin-telegram-bot is JVM-only.
That is not a limitation to work around; it is the honest shape of the dependency, and a
plain JVM project resolves the right variant from Gradle module metadata without noticing
any of this.

The reason the split lands where it does is the same as before: the pure part has nothing
platform-specific in it, so it goes everywhere. The part that speaks HTTP through a
JVM library does not.

---

Source: [github.com/youndie/telek](https://github.com/youndie/telek) ·
[API docs](https://youndie.github.io/telek/)
