Core concepts
You type a Chat SDK–shaped API. That desugars into a small core algebra. Interpreters run it. Developers never have to import the algebra.
Two layers, one program
B (what you type) cx.on_message({ channel: "telegram", kind: "dm" }, fn)
│ desugar
▼
A (the program) App([ Rule(pred, overlap, handler) ])
│ interpret
▼
world Memory | your process | hosted gatewayB (what you type) cx.onMessage({ channel: "telegram", kind: "dm" }, fn)
│ desugar
▼
A (the program) App([ Rule(pred, overlap, handler) ])
│ interpret
▼
world Memory | your process | hosted gatewaycx.app is the current program as inspectable data. You can test it without Telegram: feed a fake message in, look at what the bot would do.
Caspian
The public entry point. It builds rules from on_message / onMessage and on_action / onAction, holds channels, and drives inbound (handle, poll, listen, run).
from caspian import Caspian
cx = Caspian() # self-host; no key
cx = Caspian(api_key=KEY) # hosted: needed for via="hosted" and run()import { Caspian } from "caspian-sdk"
const cx = new Caspian() // self-host; no key
await cx.run({ apiKey: KEY }) // hosted poll; add() still needs via: "hosted"Adapters
One driver per channel. An adapter is not the language, the facade, OAuth, or the overlap queue. It is:
parse— platform bytes → core Eventsexecute— Command → platform HTTP- thread id encode/decode, overlap key, capabilities
Adding WhatsApp is a new adapter, not a new handler type. TypeScript packs live at caspian-sdk/telegram, caspian/discord, caspian/slack, …
Thread
The handle passed to handlers. Methods enqueue commands; they are not an HTTP client. thread.post answers the inbound turn. thread.send starts a message that is not a reply. Thread ids look like telegram:… / slack:… — never a raw platform chat id in tools.
Relationship memory lives with the runner. TypeScript: thread.recent() / thread.state. Python: thread.history / thread.set_state. A crash or a serverless invocation does not wipe the relationship.
Overlap
When three messages arrive while the bot is still answering the first, the rule names the policy; the runner owns the queue. Default for text is queue (hold extras). Buttons, voice, and typing should use drop (run now or skip). See Overlap for debounce, parallel, ctx.skipped, and how adapters pick the key.
Hosted vs self-host
| Hosted | Self-host | |
|---|---|---|
| Identity | Caspian (or shared bot / Meet room) | Your token |
| Inbound owner | Gateway | Your URL, poll, or socket |
| Your loop | run() or handle("gateway", …) | handle(channel, …) / poll / listen |
| API key | Required | Not required |
One inbound owner per connection — never both. Send-only: inbound: false.
Three different POSTs
| Kind | Who → whom |
|---|---|
| Provisioning callback | Discord/Slack → Caspian (flips connection to active) |
| Platform webhook | Telegram → Caspian or your URL |
| Event delivery | Caspian → your app (hosted: the only URL you expose) |