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 workspace and (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 sona chat --json — swap TelegramAdapter for CliChannel. The interactive sona chat REPL does not use CliChannel or the gateway session path; it calls the workspace Agent directly. Channel-specific quirks (Telegram threading, Telegram media, the ndJSON wire format of sona chat --json) all happen inside the adapter; the gateway never sees them. The REPL's prompt, colour and chunk rendering live in Sona's own chat session code instead.
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; Sona registers it in ndJSON mode for sona chat --json |
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 — within a workspace, every
(channelId, senderId)pair gets its own session and agent instance. TheSessionrecords user and assistant messages;SessionStoresaves that chat history. It does not store automatically generated beliefs, desires or intentions. - 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.
Role, LLM and tool execution (tnsai-core)
The agent itself is a tnsai-core Agent — Sona doesn't subclass it. AgentFactory supplies an AssistantRole containing the workspace prompt, an LLM client and the configured built-in or MCP tools. The Gateway sends the message through Agent.streamChatWithTools:
flowchart TD
I[Incoming text] --> A[Agent prompt<br/>Role + agent memory + tool definitions]
A --> L[LLM request]
L --> T{Tool call requested?}
T -->|yes| X[Dispatch registered tool]
X --> R[Tool result]
R --> L
T -->|final response| O[Stream reply]
O --> S[Gateway records chat history]The LLM can request registered tools; the framework executes those calls and returns their results to the conversation. A reply can also complete without a tool call. This chat path does not run an automatic perception-to-belief BDI cycle or a planner selected by tool-match score. Role metadata guides the prompt; it is not an automatically maintained belief store.
Memory model
The Agent maintains conversation memory for its LLM requests. Separately, Sona records a bounded user/assistant history in each Session and saves it through SessionStore after a completed turn. JSON files under ~/.sona/sessions/ contain the workspace name, chat history and last-touched timestamp; their filenames encode the workspace and scoped identity. Session creation loads saved history into the Session, subject to configured history and retention limits. It is not replayed into the Agent's LLM memory, so after a restart the LLM does not see earlier turns.
These Sona history files are not serialized framework AgentCheckpoint objects and do not persist a complete cognitive state or generated BDI beliefs.
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
Within a workspace, 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 | JSON files under ~/.sona/sessions/ | Per workspace and (channel, sender). Saves bounded chat history, loaded into the Session but not replayed into the Agent's LLM memory. Subject to retention limits. |
| Profile | ~/.sona/profiles/<encoded scopedId>.json (URL-safe Base64 of the scoped ID) | Per (channel, sender), shared across workspaces. Holds name, language, timezone, role, mode (the /mode prompt preset; auto turns on multi-agent routing), digest settings, free-form preferences, facts, todos and personality. 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, built-in 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.