How Sona Works
Sona is mostly routing. The LLM, the tool calls, the streaming — all of that is tnsai-core doing its job. Sona's contribution is deciding which agent should hear an inbound message, keeping a session for each (channel, sender) pair, and enforcing a pairing policy so strangers can't burn your LLM budget.
This page traces one Telegram message end-to-end, then zooms in on the pieces.
The round-trip
sequenceDiagram
participant U as User
participant TG as Telegram
participant TA as TelegramAdapter
participant GW as Gateway
participant WS as Workspace
participant SE as Session
participant AG as Agent
participant LLM as LLM Provider
U->>TG: "summarise this article"
TG->>TA: long-poll update
TA->>GW: UnifiedMessage (channelId="telegram")
GW->>GW: pairingPolicy.check(scopedId)
GW->>WS: route by channel (default: "default")
WS->>SE: getOrCreate(scopedId)
SE->>AG: streamChatWithTools(text, chunkSink)
AG->>LLM: chat(systemPrompt + history + tools)
LLM-->>AG: stream chunks
AG-->>SE: chunks via chunkSink
SE->>SE: append to response
AG->>SE: complete
SE->>GW: send UnifiedResponse
GW->>TA: adapter.send(response)
TA->>TG: sendMessage
TG->>U: rendered replyThe same diagram applies to the CLI channel (sona chat) — swap TelegramAdapter for CliChannel. Channel-specific quirks (Telegram threading, Telegram media, CLI ANSI colour) all happen inside the adapter; the gateway never sees them.
The pieces
Channel adapter (tnsai-channels)
The framework defines a small SPI in tnsai-channels:
ChannelAdapter ← every adapter implements this
└── StreamingChannelAdapter ← adapters that want per-token delivery add this mixinA channel adapter wraps a platform-specific transport and produces UnifiedMessage records on the way in / consumes UnifiedResponse records on the way out. The TnsAI framework owns the SPI; Sona is a consumer that registers the channels it cares about with the gateway:
| Adapter | Lives in | Status |
|---|---|---|
TelegramAdapter | tnsai-sona | Production — long-polling Bot API |
CliChannel | tnsai-channels (framework) | Production — REPL + ndJSON modes, streaming-capable |
Adapters for Slack, Discord, WhatsApp, Email are tracked in Linear under the public-alpha epic.
Gateway (com.tnsai.sona.gateway.Gateway)
The Gateway is the fan-in point — every adapter hands UnifiedMessage to the same onMessage(...) callback. From there:
flowchart LR
M[UnifiedMessage] --> P{Pairing<br/>policy}
P -->|approved| R[Workspace router]
P -->|unknown| C[Pairing code reply]
R --> S[Session getOrCreate]
S --> A[Agent<br/>streamChatWithTools]
A --> L[LLM call]
A --> SC[Streaming chunks]
SC -->|adapter is StreamingChannelAdapter| AD[adapter.sendChunk]
A --> RP[Final UnifiedResponse]
RP --> SX[adapter.send]Key behaviours:
- Pairing policy — first message from an unknown sender gets a 6-digit code. The owner approves via
sona pair approve(or by scoped ID). Unapproved senders never reach the LLM. - Workspace router — a workspace is a named LLM + system-prompt + tool-allowlist bundle. A workspace can declare
channels: [telegram]to bind itself to a subset of channels; the default workspace catches everything not claimed. A workspace can also point at a project directory viaproject_dir— see Project context below. - Per-session agent — every
(channelId, senderId)pair gets its own agent instance. State (chat history, BDI beliefs) lives in theSession; the agent reads it before each turn and writes it back after. This is what lets two users on the same workspace have completely independent conversations. - Project context — when a workspace declares
project_dir: "<path>", Sona readsAGENTS.md(falling back toCLAUDE.md/README.mdwith lowercase + dotfile variants) from that directory at session boot and augments the configuredsystem_promptwith the parsed intro + sections under a stable## Project context (auto-loaded from AGENTS.md)anchor. The operator's prompt always leads. Missing or malformed file → silent fallback (DEBUG log; session still boots). Emptyproject_dirkeeps pre-feature behaviour byte-for-byte: same role body, sameAgentSpecdigest, same principal id. - Streaming forwarding — when the registered adapter is a
StreamingChannelAdapter(currently justCliChannel), each token fromAgent.streamChatWithToolsis forwarded throughsendChunk(UnifiedChunk.text(...))as it arrives, terminated by oneUnifiedChunk.done(...). The finalUnifiedResponsestill fires throughsend(...)for audit / non-streaming downstreams; the frameworkCliChannelsuppresses the duplicate render.
BDI loop (tnsai-core)
The agent itself is a tnsai-core Agent — Sona doesn't subclass it. Each turn it runs a Believe-Desire-Intend cycle:
flowchart TD
I[Incoming text] --> B[Believe<br/>read session memory]
B --> D[Desire<br/>derive goals from role + context]
D --> P[Plan<br/>pick actions or LLM-call]
P --> X[eXecute<br/>tool call or LLM stream]
X --> R[Reflect<br/>update beliefs]
R --> O[Outbound text]For Sona's "single-role assistant" use case this collapses to read history → call LLM → stream reply, with the planner short-circuiting to "just call the LLM" when no tool match scores high enough.
Memory model
Sona inherits the framework's memory backends. Currently the daemon uses per-session in-memory history with periodic checkpoint — when the daemon shuts down cleanly via sona stop, sessions persist to ~/.sona/sessions/<scopedId>.json. The next start rehydrates them.
The framework also ships sliding-window, summary, hybrid and tiered memory strategies (see Intelligence → Context). They're not yet wired into Sona by default; the dogfood path is the one above. Tracked as a follow-up.
Skill router (com.tnsai.sona.skill)
Before a message reaches the LLM, the Gateway checks the SkillRegistry:
flowchart LR
M[Inbound text] --> R[SkillRegistry.match]
R -->|match score > threshold| S[Skill.execute]
S --> RP[Direct reply]
R -->|no match| A[Agent path<br/>LLM call]A Skill is a small named capability — an inbound regex / classifier matches, the skill runs, the reply goes back without an LLM call (or with a constrained one). Skills are useful for high-frequency intents (/help, weather <city>, define <word>) where you want deterministic latency and no token spend.
The built-in skill catalog is currently small (/help, owner admin commands). The full dogfood catalog is tracked in the Linear backlog and intentionally not bundled until the SPI is more settled.
Session, sender, scoped identity
Sessions are keyed by a ChannelScopedId — channelId:senderId. This means:
- The same Telegram user talking to Sona via two different bot tokens is two separate sessions (different
channelIdif you ever ran two bots). - The same person on Telegram and CLI is two separate sessions — different
channelId, even if profile preferences live elsewhere. - Pairing approvals are scoped — approving
telegram:12345does not approvediscord:12345.
The intent is "channel-as-tenant"; the scoped ID is the framework's primitive for this.
Sessions vs profiles
| Concept | Lives in | Lifetime |
|---|---|---|
| Session | ~/.sona/sessions/<scopedId>.json | Per (channel, sender). Holds chat history + BDI state. Survives daemon restarts. |
| Profile | ~/.sona/profiles/<scopedId>.json | Per (channel, sender). Holds user preferences (preferred model, tool allowlist, language). Survives daemon restarts. |
| Config | ~/.sona/sona.yml | One file. Workspaces, LLM, channels, MCP, skills. Loaded at start. |
sona uninstall deletes all three. sona init only touches the config.
Where the framework ends and Sona begins
If you're reading the source to mine patterns, the boundary is clean:
| Sona code does | Framework code does |
|---|---|
| Channel registration order, per-channel auth | ChannelAdapter SPI + Gateway callbacks |
| Pairing policy + pairing codes | ChannelScopedId + ChannelContext |
| Workspace ↔ channel routing | Agent.streamChatWithTools |
| Skill matching + executing | Tool execution, ToolCallListener, MCP plumbing |
Sessions on disk (SessionStore) | Memory backends, message rendering |
sona.yml parsing + sona init wizard | AgentBuilder, LLMClient, all 62 BuiltInTool toolkits |
If any line in "Sona code" looks reusable for your own project, lift the pattern — none of it depends on Sona-specific internals.
Going further
- The Sona repo has source you can read end-to-end in an hour — start with
SonaMainandGateway. CLAUDE.md in the repo root is the entrypoint for AI coding sessions and doubles as a fast tour. - For framework details: Agents → Capabilities → Channels.
- The dogfood "why" perspective is at Sona — Dogfood Personal Assistant.