Skip to content
sonapersonal assistant

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 reply

The 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 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; 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 via project_dir — see Project context below.
  • Per-session agent — within a workspace, every (channelId, senderId) pair gets its own session and agent instance. The Session records user and assistant messages; SessionStore saves that chat history. It does not store automatically generated beliefs, desires or intentions.
  • 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.

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 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
SessionJSON 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.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, 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 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: Agents → Capabilities → Channels.
  • The dogfood "why" perspective is at Sona — Dogfood Personal Assistant.