TnsAI
Sona

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 reply

The 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 mixin

A 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:

AdapterLives inStatus
TelegramAdaptertnsai-sonaProduction — long-polling Bot API
CliChanneltnsai-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 via project_dir — see Project context below.
  • Per-session agent — every (channelId, senderId) pair gets its own agent instance. State (chat history, BDI beliefs) lives in the Session; 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 reads AGENTS.md (falling back to CLAUDE.md / README.md with lowercase + dotfile variants) from that directory at session boot and augments the configured system_prompt with 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). Empty project_dir keeps pre-feature behaviour byte-for-byte: same role body, same AgentSpec digest, same principal id.
  • Streaming forwarding — when the registered adapter is a StreamingChannelAdapter (currently just CliChannel), each token from Agent.streamChatWithTools is forwarded through sendChunk(UnifiedChunk.text(...)) as it arrives, terminated by one UnifiedChunk.done(...). The final UnifiedResponse still fires through send(...) for audit / non-streaming downstreams; the framework CliChannel suppresses 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 ChannelScopedIdchannelId:senderId. This means:

  • The same Telegram user talking to Sona via two different bot tokens is two separate sessions (different channelId if 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:12345 does not approve discord:12345.

The intent is "channel-as-tenant"; the scoped ID is the framework's primitive for this.

Sessions vs profiles

ConceptLives inLifetime
Session~/.sona/sessions/<scopedId>.jsonPer (channel, sender). Holds chat history + BDI state. Survives daemon restarts.
Profile~/.sona/profiles/<scopedId>.jsonPer (channel, sender). Holds user preferences (preferred model, tool allowlist, language). Survives daemon restarts.
Config~/.sona/sona.ymlOne 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 doesFramework code does
Channel registration order, per-channel authChannelAdapter SPI + Gateway callbacks
Pairing policy + pairing codesChannelScopedId + ChannelContext
Workspace ↔ channel routingAgent.streamChatWithTools
Skill matching + executingTool execution, ToolCallListener, MCP plumbing
Sessions on disk (SessionStore)Memory backends, message rendering
sona.yml parsing + sona init wizardAgentBuilder, 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 SonaMain and Gateway. CLAUDE.md in the repo root is the entrypoint for AI coding sessions and doubles as a fast tour.
  • For framework details: AgentsCapabilitiesChannels.
  • The dogfood "why" perspective is at Sona — Dogfood Personal Assistant.

On this page