Grain Model
Every entity in Komand is an Orleans grain — a virtual actor with isolated state, single-threaded execution, and automatic lifecycle management. Grains are never explicitly created or destroyed; Orleans activates them on first access and deactivates them when idle, persisting state to PostgreSQL automatically.
This page describes each grain type, its key, state, interface, and behaviour.
Grain Overview
Section titled “Grain Overview”| Grain | Key Pattern | Responsibility |
|---|---|---|
AgentGrain | {agentId} | AI personality, LLM conversation loop, memory |
SessionGrain | {Channel}:{AccountId}:{SenderId} | Conversation thread, history, agent binding |
ToolGrain | {executionId} | Skill execution lifecycle |
CronGrain | {AgentId}:{TaskId} | Scheduled task execution |
SkillRegistryGrain | "global" (singleton) | Skill catalog and permission validation |
CredentialVaultGrain | {userId} | Per-user credential storage |
AuditGrain | "global" (singleton) | Audit log recording and querying |
AgentGrain
Section titled “AgentGrain”The core grain that represents an AI agent. Each agent has its own configuration, LLM conversation loop with tool calling, and long-term memory.
Key: agentId (e.g., "default", "sales-bot")
State:
Config—AgentConfigwith name, system prompt, model provider, model ID, enabled skills, max context tokensMemories— dictionary of key to value for long-term recall (max 1,000 entries, 100KB per value)UpdatedAt— last modification timestampOwnerId— user who owns this agent
Conversation history is stored on the SessionGrain, not the AgentGrain. The agent receives history as a parameter when processing messages.
Interface:
public interface IAgentGrain : IGrainWithStringKey{ Task ConfigureAsync(AgentConfig config); Task<AgentConfig?> GetConfigAsync(); Task<string?> GetOwnerIdAsync(); Task<AgentProcessResult> ProcessMessageAsync( InboundMessage message, string sessionId, IReadOnlyList<ConversationTurn> history); Task<StreamingPrepareResult> PrepareStreamingResponseAsync( InboundMessage message, string sessionId, IReadOnlyList<ConversationTurn> history); Task StoreMemoryAsync(string key, string value); Task<string?> RecallMemoryAsync(string key);}Default Configuration:
| Setting | Default |
|---|---|
ModelProvider | "anthropic" |
ModelId | "claude-sonnet-4-20250514" |
MaxContextTokens | 100,000 |
Behaviour:
ProcessMessageAsyncruns a full LLM conversation loop with tool calling (see Message Flow)- The agent calls the configured LLM provider via
ChatCompletionProviderFactory, passing the system prompt and conversation history - If the LLM returns tool calls, the agent executes each tool via
ToolGrainand feeds results back to the LLM — up to 10 iterations with a 2-minute timeout PrepareStreamingResponseAsyncenables token-by-token streaming via SignalR- Returns
AgentProcessResultcontaining the response and all new conversation turns (user + tool loop + assistant)
SessionGrain
Section titled “SessionGrain”Manages a conversation thread between a user and an agent on a specific channel.
Key: Compound key {Channel}:{ChannelAccountId}:{SenderId}
This key structure ensures that the same user on different channels gets separate sessions, and the same channel with different bot accounts gets separate sessions.
State:
SessionId— unique identifierChannel,ChannelAccountId,SenderId— routing metadataBoundAgentId— which agent handles this session (default:"default")MessageCount,CreatedAt,LastActivityAt— metricsHistory— list ofConversationTurnentries for this sessionOwnerId— user who owns this sessionLastActorUserId,LastActorUserName— last user who interacted
Interface:
public interface ISessionGrain : IGrainWithStringKey{ Task BindAgentAsync(string agentId); Task<OutboundMessage> HandleMessageAsync(InboundMessage message); Task<SessionInfo> GetInfoAsync(); Task<IReadOnlyList<ConversationTurn>> GetHistoryAsync(int maxTurns = 50); Task ClearHistoryAsync(); Task AppendTurnsAsync(IReadOnlyList<ConversationTurn> turns); Task<string?> GetOwnerIdAsync(); Task EndSessionAsync();}Behaviour:
- Lazy initialization: On the first message, the session initializes its state from the inbound message metadata (channel, sender, timestamps)
- Default binding: New sessions automatically bind to the
"default"agent - Channel mismatch detection: If a message arrives with a different channel than the session was initialized with, it is rejected
- Routing:
HandleMessageAsyncdelegates to the boundAgentGrain.ProcessMessageAsync
ToolGrain
Section titled “ToolGrain”Executes a skill action with lifecycle tracking. Each execution gets its own grain, providing natural isolation — a misbehaving skill cannot affect other executions.
Key: executionId (string)
State:
ToolExecutionRequest— tool name, agent ID, session ID, parameters, timeout, requested timestampToolExecutionStatus— status enum tracking the execution lifecycleToolExecutionResult— output, error, completion timestamp, duration
Interface:
public interface IToolGrain : IGrainWithStringKey{ Task<ToolExecutionResult> ExecuteAsync(ToolExecutionRequest request); Task<ToolExecutionStatus> GetStatusAsync(); Task CancelAsync();}Status Lifecycle:
Pending → Running → Completed → Failed → TimedOut → CancelledBehaviour:
- Timeout is enforced per-execution and capped at
MaxToolExecutionTimeoutMinutes(default: 30) - If the execution exceeds its timeout, the status transitions to
TimedOut CancelAsynccan be called at any point to abort a running execution
CronGrain
Section titled “CronGrain”Manages scheduled recurring tasks using Orleans reminders — a durable scheduling mechanism that survives silo restarts and grain deactivation.
Key: {AgentId}:{TaskId}
State:
CronTaskDefinition— name, description, action type, action parameters, interval, stagger offset, paused flag, created timestampExecutionCount,LastExecutedAt
Interface:
public interface ICronGrain : IGrainWithStringKey, IRemindable{ Task ScheduleAsync(CronTaskDefinition definition); Task<CronTaskDefinition?> GetDefinitionAsync(); Task PauseAsync(); Task ResumeAsync(); Task UnscheduleAsync();}Behaviour:
- Implements
IRemindableto receive Orleans reminder callbacks (reminder name:"cron-tick") - Stagger support: A configurable offset prevents all cron tasks from firing at the same instant
- Pause/Resume: Tasks can be paused without losing their schedule; resuming re-registers the reminder
- Unschedule: Removes the reminder entirely and resets state
- Orleans reminders are persisted to PostgreSQL, so they survive silo restarts
Action dispatch is implemented for two action types:
send_message— creates anInboundMessagewithChannel.SystemandSenderId="cron:{TaskId}", then callsAgentGrain.ProcessMessageAsync()with the configured messageexecute_tool— creates aToolExecutionRequestfrom the action parameters and callsToolGrain.ExecuteAsync()
Both actions log results and record audit events (CronExecuted or CronFailed).
SkillRegistryGrain
Section titled “SkillRegistryGrain”Singleton grain managing the global skill catalog. There is exactly one instance of this grain in the entire cluster.
Key: "global" (always a single instance)
State:
- Dictionary of
skillId→SkillDefinition - Capacity: max 10,000 skills
Interface:
public interface ISkillRegistryGrain : IGrainWithStringKey{ Task RegisterSkillAsync(SkillDefinition skill); Task<SkillDefinition?> GetSkillAsync(string skillId); Task<SkillListResult> ListSkillsAsync( string? publisherId = null, bool? verifiedOnly = null, int page = 1, int pageSize = 50); Task UnregisterSkillAsync(string skillId); Task<bool> ValidateSkillPermissionsAsync(string skillId, IReadOnlyList<SkillPermission> grantedPermissions);}Behaviour:
RegisterSkillAsyncrejects registration if the registry is at capacityListSkillsAsyncsupports filtering by publisher ID and verified status with pagination (returnsSkillListResultcontainingSkillslist andTotalcount)ValidateSkillPermissionsAsyncchecks whether a set of granted permissions satisfies all of a skill’s required permissions — returnstrueonly if every required permission is covered
SkillPermission Model
Section titled “SkillPermission Model”Permissions are granular, not just string labels:
public record SkillPermission( string Resource, // e.g., "network", "filesystem", "api:salesforce" string Access, // "read", "write", "execute" string? Scope // optional: specific URLs, paths, or resource IDs);CredentialVaultGrain
Section titled “CredentialVaultGrain”Per-user credential storage backed by the ISecretStore abstraction. Provides secure storage for API keys and secrets without exposing them in agent configuration or logs.
Key: userId (one vault per user)
Interface:
public interface ICredentialVaultGrain : IGrainWithStringKey{ Task<string?> GetCredentialAsync(string key); Task SetCredentialAsync(string key, string value); Task DeleteCredentialAsync(string key); Task<IReadOnlyList<string>> ListCredentialKeysAsync();}Behaviour:
- Credentials are stored via the configured
ISecretStorebackend (DPAPI, DataProtection, Keychain, or libsecret) - Keys are prefixed with
user:{userId}:{key}for isolation between users - Read access is rate-limited to
MaxCredentialReadsPerMinute(default: 60) per user - Maximum
MaxCredentialsPerUser(default: 100) credentials per user - Credential access is audited (
CredentialAccessed,CredentialStored,CredentialDeleted)
AuditGrain
Section titled “AuditGrain”Singleton grain that records and queries audit log entries. All significant platform actions are recorded through this grain.
Key: "global" (singleton)
Interface:
public interface IAuditGrain : IGrainWithStringKey{ Task LogAsync(AuditLogEntry entry); Task<AuditQueryResult> QueryAsync(AuditQuery query);}Behaviour:
LogAsyncappends an audit entry with action, actor ID, agent ID, session ID, skill ID, details, and timestampQueryAsyncsupports filtering and pagination over the audit log- Maximum
MaxAuditEntries(default: 10,000) entries retained
Configurable Limits
Section titled “Configurable Limits”All grain limits are configurable via the GrainLimits configuration section:
| Setting | Default | Description |
|---|---|---|
MaxTurnsPerSession | 100 | Max conversation turns per session before trimming |
MaxSessionsPerAgent | 100 | Max active sessions per agent before eviction |
MaxMemoryEntries | 1,000 | Max memory key-value pairs per agent |
MaxMemoryValueLength | 100,000 | Max characters per memory value |
MaxSkills | 10,000 | Max skills in the global registry |
MaxToolExecutionTimeoutMinutes | 30 | Max tool execution timeout |
MaxAuditEntries | 10,000 | Max audit log entries retained |
MaxCredentialsPerUser | 100 | Max credentials per user vault |
MaxCredentialReadsPerMinute | 60 | Rate limit on credential reads per user |
Override in appsettings.json:
{ "GrainLimits": { "MaxTurnsPerSession": 200, "MaxSessionsPerAgent": 500 }}Persistence
Section titled “Persistence”All grain state is persisted to PostgreSQL via Orleans ADO.NET providers. The storage provider is named "komandStore".
| Concern | Provider |
|---|---|
| Grain state | OrleansSqlUtils with AdoNetGrainStorage |
| Clustering | AdoNetClustering (silo membership) |
| Reminders | AdoNetReminderTable (for CronGrain) |
In development mode, all three use in-memory providers — no PostgreSQL required.
Why Orleans?
Section titled “Why Orleans?”Orleans was chosen over alternatives because its grain model maps directly to Komand’s domain:
| Domain Concept | Orleans Primitive |
|---|---|
| An AI agent with memory | AgentGrain with persistent state |
| A conversation thread | SessionGrain keyed by channel + user |
| A running skill | ToolGrain with timeout and cancellation |
| A scheduled task | CronGrain with Orleans reminders |
| A singleton registry | SkillRegistryGrain with "global" key |
| A user’s secrets | CredentialVaultGrain with per-user key |
| An audit trail | AuditGrain singleton for logging |
Each grain processes messages one at a time (single-threaded), which eliminates concurrency bugs. State is automatically persisted and restored. The silo handles activation, deactivation, and distribution across cluster nodes.