Skip to content

Message Flow

This page traces the lifecycle of a message from an external channel through the Komand system and back, including tool execution, real-time updates, and error handling.

1. User sends message on Telegram
2. Telegram webhook hits Gateway API
3. Channel adapter normalizes to InboundMessage
4. Gateway resolves SessionGrain key:
"Telegram:{botId}:{userId}"
5. SessionGrain.HandleMessageAsync()
├── Lazy-initializes if new session (sets channel, sender, timestamps)
├── Validates channel matches existing session
├── Retrieves conversation history from session state
└── Routes to bound AgentGrain with history
6. AgentGrain.ProcessMessageAsync(message, sessionId, history)
├── Sends system prompt + history to LLM via ChatCompletionProviderFactory
├── If LLM returns tool calls → enters tool loop (max 10 iterations, 2-min timeout)
│ ├── Executes each tool via ToolGrain
│ ├── Feeds tool results back to LLM
│ └── Repeats until LLM returns a text response or limits reached
├── Returns AgentProcessResult with response + new conversation turns
└── SessionGrain appends new turns to history
7. Gateway sends response back via channel adapter
8. User receives reply on Telegram

Channel adapters are thin ASP.NET webhook endpoints that normalise platform-specific payloads into a common message format and route them to the appropriate SessionGrain.

ChannelTransportStatus
WebChatREST + SignalRImplemented
SlackWebhookPlanned
Microsoft TeamsWebhookPlanned
DiscordWebhookPlanned
TelegramWebhookPlanned
WhatsAppWebhookPlanned
SignalWebhookPlanned
SMSWebhookPlanned

All channels normalise to the same InboundMessage/OutboundMessage format, so grains are completely channel-agnostic. Adding a new channel means writing a thin adapter — the grain layer requires no changes.

All channels normalize messages to a common InboundMessage format:

{
"messageId": "msg-abc-123",
"channel": "Telegram",
"channelAccountId": "bot-456",
"senderId": "user-789",
"senderDisplayName": "Alice",
"text": "Book a meeting with Bob tomorrow at 2pm",
"timestamp": "2026-02-23T10:30:00Z",
"attachments": [],
"metadata": {
"telegramChatId": "12345"
}
}

Responses use a corresponding OutboundMessage format:

{
"sessionId": "Telegram:bot-456:user-789",
"channel": "Telegram",
"recipientId": "user-789",
"text": "I've booked a meeting with Bob for tomorrow at 2:00 PM.",
"timestamp": "2026-02-23T10:30:02Z",
"attachments": []
}

Inside the SessionGrain, messages are stored as conversation turns:

{
"role": "user",
"content": "Book a meeting with Bob tomorrow at 2pm",
"timestamp": "2026-02-23T10:30:00Z",
"toolCallIds": null
}
{
"role": "assistant",
"content": "I've booked a meeting with Bob for tomorrow at 2:00 PM.",
"timestamp": "2026-02-23T10:30:02Z",
"toolCallIds": ["exec-abc-123"]
}

When the LLM decides to use a tool, the AgentGrain enters a tool-calling loop:

AgentGrain receives LLM response with tool_calls
For each tool call in the response:
├── Resolves handler from ToolHandlerRegistry (built-in tools)
│ or validates against SkillRegistryGrain (installed skills)
├── Creates ToolGrain with unique executionId
├── ToolGrain.ExecuteAsync(request)
│ ├── Status: Pending → Running
│ ├── Executes with enforced timeout (capped at MaxToolExecutionTimeoutMinutes)
│ ├── Status: Running → Completed | Failed | TimedOut | Cancelled
│ └── Returns ToolExecutionResult
└── Stores tool result as a conversation turn
Feeds all tool results back to LLM
If LLM returns more tool calls → loop continues (max 10 iterations, 2-min timeout)
If LLM returns text → loop ends, final response returned
ToolDescription
echoEchoes input text — used for testing
calculatorEvaluates mathematical expressions with safe validation
date_timeReturns current date/time and time zone operations
web_fetchFetches web content (with DNS rebinding protection via NetworkGuard)
{
"executionId": "exec-abc-123",
"toolName": "calendar-booking",
"agentId": "default",
"sessionId": "session-xyz",
"parameters": {
"attendee": "Bob",
"date": "2026-02-24",
"time": "14:00"
},
"timeout": "00:05:00",
"requestedAt": "2026-02-23T10:30:01Z"
}
{
"executionId": "exec-abc-123",
"status": "Completed",
"output": "{\"eventId\":\"cal-789\",\"confirmed\":true,\"time\":\"2026-02-24T14:00:00Z\"}",
"error": null,
"completedAt": "2026-02-23T10:30:02Z",
"duration": "00:00:01.234"
}

Sessions are automatically created on first contact. By default, all sessions bind to the "default" agent. The session key is derived from the channel, account, and sender:

Key: "{Channel}:{ChannelAccountId}:{SenderId}"
Example: "Telegram:bot-456:user-789"

This key structure means:

  • The same user on Telegram and Slack gets separate sessions
  • The same Telegram user talking to different bots gets separate sessions
  • The same Telegram user talking to the same bot always gets the same session

You can rebind a session to a different agent:

Terminal window
curl -X POST http://localhost:5000/api/sessions/{sessionId}/bind \
-H "Content-Type: application/json" \
-d '{ "agentId": "sales-bot" }'

After rebinding, all future messages in that session are routed to the new agent.

All grain calls from the Gateway have a 30-second timeout. If a grain doesn’t respond within this window, the API returns 504 Gateway Timeout:

{
"success": false,
"error": "Request timed out after 30 seconds"
}

Tool executions have a separate, configurable timeout managed by the ToolGrain. The timeout is specified per-request and capped at MaxToolExecutionTimeoutMinutes (default: 30 minutes).

If a tool exceeds its timeout:

  1. The ToolGrain transitions to TimedOut status
  2. The AgentGrain receives an error result
  3. The LLM is informed the tool timed out and generates an appropriate response

The WebChat channel uses SignalR for bi-directional real-time communication. The web dashboard connects to the SignalR hub for live message streaming.

MethodDirectionPurpose
SendMessageClient → ServerSend a message to the agent
ReceiveMessageServer → ClientReceive the agent’s complete response
ReceiveTokenServer → ClientReceive a streaming token (agentId, token, isComplete)
AgentTypingServer → ClientIndicate the agent is generating a response

The connection uses exponential backoff for reconnection and gracefully handles disconnects.

The SignalR hub supports token-by-token streaming for responsive chat experiences:

1. Client calls SendMessage(agentId, content)
2. Hub sends AgentTyping(agentId, true)
3. Hub calls AgentGrain.PrepareStreamingResponseAsync()
4. As LLM generates tokens → ReceiveToken(agentId, token, false)
5. When complete → ReceiveToken(agentId, null, true)
6. Hub sends ReceiveMessage with full response
7. Hub sends AgentTyping(agentId, false)

The React frontend uses the @microsoft/signalr package to manage the connection:

  • Auth store provides the Bearer token for authenticated connections
  • Chat store manages message state and real-time updates via Zustand
  • Correlation ID is sent as X-Request-Id on every API call for end-to-end tracing

Every significant action in the message flow generates an audit log entry. These are persisted to a dedicated komand_audit_log table in PostgreSQL.

For a typical message exchange, the audit trail captures:

  1. MessageReceived — inbound message arrives at the Gateway
  2. SessionCreated — if this is the first message (new session)
  3. ToolExecutionStarted — if the agent invokes a tool
  4. ToolExecutionCompleted or ToolExecutionFailed — tool result
  5. MessageSent — outbound response dispatched

Additional audit actions for other subsystems: CronExecuted, CronFailed, CredentialAccessed, CredentialStored, CredentialDeleted.

Each entry includes the actor ID, agent ID, session ID, and timestamp, enabling full reconstruction of any conversation.

ErrorResponseRecovery
Unknown agent404 Not FoundCheck agent ID exists
Session channel mismatch400 Bad RequestUse correct session key
Skill permission denied403 ForbiddenGrant required permissions
Grain timeout504 Gateway TimeoutRetry or check silo health
Tool execution failedError in LLM contextLLM generates error-aware response
Tool execution timed outTimeout in LLM contextLLM informs user of timeout
Validation failure400 Bad RequestFix input per error message