Building LLM Agents in Akka.NET — Porting Akka.io's Agent SDK to the Actor Model

Date: 2026-05-17
Audience: IT developers working with .NET + actor models + LLM integration
Related memorizer notes (new window):

TL;DR

notion image
Akka.io (on the JVM) ships an Agent component that bundles LLM calls + session memory + tool calls into a single first-class abstraction. Akka.NET does not yet provide this abstraction natively, but you can build the same shape using ReceiveActor + Become transitions + supervised child actors + an external LLM client. This article walks through the design and runnable samples.

Background — Why isn't there an Agent in Akka.NET?

Dimension
Akka.io (JVM / Java SDK)
:---
:---
:---
Actor model
✅ Same Hewitt-1973 lineage
Akka.io ported to .NET, identical
Agent component
First-class citizen (Akka SDK 2024+)
❌ Missing — must be assembled by hand
Workflow / Entity / View
✅ Annotations + SDK runtime
❌ Library level (Persistence·Streams separately)
Memory management
Built-in Event Sourced Entity
Akka.Persistence (equivalent)
Tool calling integration
LLM response → automatic dispatch
Hand-rolled (Newtonsoft + reflection)
Akka.io's Agent SDK wraps a Context Gathering → Reasoning → Action Taking → Progress Evaluation lifecycle into one abstract class (see Akka Agents Platform note). On the .NET side you have to build the same shape with explicit Become transitions + PersistentActor checkpoints.

1. Akka.io Agent in one line

Minimal form from the official Hello World tutorial:
@Component(id = "hello-world-agent") public class HelloWorldAgent extends Agent { private static final String SYSTEM_MESSAGE = "You are a cheerful AI..."; public Effect<String> greet(String userGreeting) { return effects() .systemMessage(SYSTEM_MESSAGE) .userMessage(userGreeting) .thenReply(); } }
Methods provided by the effects() builder:
Method
Role
:---
:---
model(ModelProvider)
Pick the LLM (OpenAI / Anthropic / MCP / local)
systemMessage(String)
System prompt
userMessage(String)
User input
tools(...)
Expose Java methods as tools to the LLM
memoryProvider(...)
Session memory source
thenReply()
Return the response
The key insight: session is the memory key. Calling componentClient.forAgent().inSession(userId).method(HelloWorldAgent::greet).invoke(text) automatically prepends prior history for that same userId into the system prompt.

2. .NET mapping table — what becomes what

Akka.NET / .NET ecosystem
Notes
:---
:---
:---
Agent component
LlmAgentActor : ReceiveActor (custom)
Designed in this article
Effect<T> builder
AgentEffectBuilder<T> (POCO)
Fluent API
@Component(id="...")
Props.Create<LlmAgentActor>(...) • named child path
Explicit path
componentClient.forAgent().inSession(id)
agents.GetOrCreate(sessionId) (router actor)
Or Akka.Cluster.Sharding
Session memory (automatic)
SessionMemoryActor : PersistentActor • snapshots
Akka.Persistence
tools(...)
IAgentTool interface + [AgentFunction] attribute
Reflection-generated JSON Schema
model(ModelProvider)
ILlmClient (OpenAI/Anthropic/Ollama SDK wrapper)
DI-injected
Effect<String> return
Receive<AgentRequest>Sender.Tell(AgentResponse)
Ask pattern or one-way
Workflow component
WorkflowActor : ReceiveActor (FSM / Behavior-based)
Transition via Become(...)
Event Sourced Entity
PersistentActor
Same semantics
View (CQRS read side)
Akka.Streams • projection actors
Or Akka.Persistence.Query
memorizer-v1 already implements this mapping in production — see Memorizer Actor Collaboration chapter.

3. AkkaAgent abstraction design

3-1. Message protocol

public abstract record AgentMessage; public sealed record AgentRequest( string SessionId, string UserMessage, Dictionary<string, object>? Context = null ) : AgentMessage; public sealed record AgentResponse( string SessionId, string Reply, IReadOnlyList<ToolInvocation> ToolCalls, int PromptTokens, int CompletionTokens ) : AgentMessage; public sealed record AgentError( string SessionId, string Reason, Exception? Cause = null ) : AgentMessage; public sealed record ToolInvocation(string Name, string ArgsJson, string ResultJson);

3-2. Abstract actor — lifecycle as Behavior transitions

public abstract class LlmAgentActor : ReceiveActor, IWithUnboundedStash { public IStash Stash { get; set; } = null!; private readonly ILlmClient _llm; private readonly IActorRef _memory; // SessionMemoryActor (child) private readonly IReadOnlyList<IAgentTool> _tools; private readonly string _agentId; protected LlmAgentActor( string agentId, ILlmClient llm, IReadOnlyList<IAgentTool> tools) { _agentId = agentId; _llm = llm; _tools = tools; _memory = Context.ActorOf(SessionMemoryActor.Props(), "memory"); Become(Idle); } // ---- Behavior 1: Idle (Akka.io "waiting for request") ---- private void Idle() { Receive<AgentRequest>(req => { Stash.Stash(); _memory.Tell(new LoadSessionMessages(req.SessionId), Self); Become(() => GatheringContext(req, Sender)); }); } // ---- Behavior 2: GatheringContext (Context Gathering) ---- private void GatheringContext(AgentRequest req, IActorRef caller) => Receive<SessionMessages>(loaded => { Stash.UnstashAll(); var prompt = BuildPrompt(req, loaded.Messages); Become(() => Reasoning(req, caller, prompt)); Self.Tell(prompt); }); // ---- Behavior 3: Reasoning (LLM call) ---- private void Reasoning(AgentRequest req, IActorRef caller, AgentPrompt prompt) => Receive<AgentPrompt>(async _ => { try { var raw = await _llm.CompleteAsync(prompt, _tools.Select(t => t.Schema)); Become(() => Acting(req, caller, raw)); Self.Tell(raw); } catch (Exception ex) { caller.Tell(new AgentError(req.SessionId, ex.Message, ex)); Become(Idle); } }); // ---- Behavior 4: Acting (tool dispatch) ---- private void Acting(AgentRequest req, IActorRef caller, LlmCompletion raw) => Receive<LlmCompletion>(async _ => { if (raw.ToolCalls.Count == 0) { _memory.Tell(new AppendMessages(req.SessionId, raw.AssistantMessage)); caller.Tell(new AgentResponse(req.SessionId, raw.Content, Array.Empty<ToolInvocation>(), raw.PromptTokens, raw.CompletionTokens)); Become(Idle); return; } var invocations = new List<ToolInvocation>(); foreach (var call in raw.ToolCalls) { var tool = _tools.First(t => t.Name == call.Name); var result = await tool.InvokeAsync(call.ArgsJson); invocations.Add(new ToolInvocation(call.Name, call.ArgsJson, result)); } // re-inject tool results into the prompt → fall back to Reasoning var nextPrompt = AppendToolResults(BuildPromptFromCompletion(raw), invocations); Become(() => Reasoning(req, caller, nextPrompt)); Self.Tell(nextPrompt); }); protected abstract AgentPrompt BuildPrompt(AgentRequest req, IReadOnlyList<ChatMessage> history); }
notion image
Key: Akka.io's four-stage lifecycle (Context Gathering → Reasoning → Action → Progress Evaluation) becomes four Behaviors transitioned via Become(...) in .NET. The state machine is now visible in the code, instead of being hidden inside SDK lifecycle annotations — so you can debug transitions with your eyes.

3-3. Tool abstraction

public interface IAgentTool { string Name { get; } string Description { get; } JsonSchema Schema { get; } Task<string> InvokeAsync(string argsJson); } [AttributeUsage(AttributeTargets.Method)] public sealed class AgentFunctionAttribute : Attribute { public string? Description { get; init; } } // Usage — reflection auto-generates IAgentTool public sealed class WeatherTool { [AgentFunction(Description = "Get current weather for a city")] public Task<WeatherResult> GetWeather(string city) => /* HTTP call */; }

3-4. Session memory (PersistentActor)

public sealed class SessionMemoryActor : ReceivePersistentActor { public override string PersistenceId => $"session-memory-{_sessionId}"; private readonly string _sessionId; private readonly LinkedList<ChatMessage> _history = new(); private const int MaxHistory = 20; public SessionMemoryActor(string sessionId) { _sessionId = sessionId; Recover<MessageAppended>(evt => Apply(evt)); Recover<SnapshotOffer>(offer => { if (offer.Snapshot is List<ChatMessage> snap) foreach (var m in snap) _history.AddLast(m); }); Command<LoadSessionMessages>(_ => Sender.Tell(new SessionMessages(_history.ToList()))); Command<AppendMessages>(cmd => Persist(new MessageAppended(cmd.Message), evt => { Apply(evt); if (_history.Count % 50 == 0) SaveSnapshot(_history.ToList()); })); } private void Apply(MessageAppended evt) { _history.AddLast(evt.Message); while (_history.Count > MaxHistory) _history.RemoveFirst(); } }

4. Runnable sample — HelloAgent (Q&A)

4-1. Concrete actor

public sealed class HelloAgentActor : LlmAgentActor { private const string SystemPrompt = "You are a cheerful AI assistant. Greet users in a new language each turn."; public HelloAgentActor(ILlmClient llm) : base("hello-agent", llm, Array.Empty<IAgentTool>()) { } protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) { var msgs = new List<ChatMessage> { ChatMessage.System(SystemPrompt) }; msgs.AddRange(history); msgs.Add(ChatMessage.User(req.UserMessage)); return new AgentPrompt(msgs); } public static Props Props(ILlmClient llm) => Akka.Actor.Props.Create(() => new HelloAgentActor(llm)); }

4-2. Invocation — session-keyed routing

public sealed class AgentRouter : ReceiveActor { private readonly Func<string, Props> _propsFactory; private readonly Dictionary<string, IActorRef> _bySession = new(); public AgentRouter(Func<string, Props> propsFactory) { _propsFactory = propsFactory; Receive<AgentRequest>(req => { if (!_bySession.TryGetValue(req.SessionId, out var agent)) { agent = Context.ActorOf(_propsFactory(req.SessionId), $"agent-{req.SessionId}"); _bySession[req.SessionId] = agent; } agent.Forward(req); }); } } // Wire it up var system = ActorSystem.Create("agent-host"); var llm = new OpenAiClient(apiKey: ...); var router = system.ActorOf( Props.Create(() => new AgentRouter(_ => HelloAgentActor.Props(llm))), "agents"); var reply = await router.Ask<AgentResponse>( new AgentRequest("alice", "Hi, I'm Alice"), TimeSpan.FromSeconds(30)); // reply.Reply: "Hello, Alice! (English)"

4-3. Second call — memory applied automatically

reply = await router.Ask<AgentResponse>( new AgentRequest("alice", "I live in New York"), TimeSpan.FromSeconds(30)); // reply.Reply: "Bonjour, New York! Ah, la ville qui ne dort jamais. (French)"
Same SessionId="alice" → same child actor → same SessionMemoryActor → prior history auto-prepended.

5. Runnable sample — WeatherAgent (tool calls)

public sealed class WeatherAgentActor : LlmAgentActor { private const string SystemPrompt = "You are an activity planner. Use the weather tool before recommending outdoor activities."; public WeatherAgentActor(ILlmClient llm, WeatherTool weather) : base("weather-agent", llm, new IAgentTool[] { AgentToolFactory.Wrap(weather) }) { } protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) => /* same as above */; }
When the LLM returns tool_calls: [{name: "GetWeather", args: {city: "Madrid"}}], the Acting Behavior dispatches WeatherTool.GetWeather, appends the result to the next prompt, falls back to Reasoning, and produces the final answer. From the caller's perspective there is only one AgentResponse, but two LLM round-trips happen internally.

6. End-to-end sequence

sequenceDiagram autonumber participant U as User participant R as AgentRouter participant A as LlmAgentActor<br>WeatherAgent participant M as SessionMemoryActor participant LLM as ILlmClient participant T as WeatherTool U->>R: AgentRequest alice "Madrid recs?" R->>A: Forward (session-keyed) Note over A: Behavior=Idle A->>M: LoadSessionMessages M-->>A: SessionMessages history Note over A: Behavior=GatheringContext to Reasoning A->>LLM: complete prompt + tools LLM-->>A: tool_call GetWeather Madrid Note over A: Behavior=Acting A->>T: GetWeather Madrid T-->>A: rain 18C Note over A: Behavior=Reasoning (reentry) A->>LLM: complete prompt + tool_result LLM-->>A: "Rainy. Try Prado Museum." A->>M: AppendMessages A-->>R: AgentResponse R-->>U: AgentResponse Note over A: Behavior=Idle

7. Operational considerations

Concern
Akka.io (automatic)
Akka.NET (explicit)
:---
:---
:---
Session memory compaction (old messages)
SDK
Add MaxHistory • summarisation logic in SessionMemoryActor
Truncating on token overflow
SDK
Pre-compute with a tokenizer in BuildPrompt
Tool-call timeout / retry
SDK
AskTimeout • Polly + Supervisor OneForOneStrategy
Distributed routing (session → node)
Cluster Sharding automatic
Apply Akka.Cluster.Sharding explicitly
Observability
OpenTelemetry integration
Akka.Logger.Serilog • Phobos, or Petabridge.Cmd
Streaming LLM responses
StreamEffect
Akka.Streams Source.Single(...).MapAsync(...) or SSE
Fault isolation
Supervisor automatic
Declare SupervisorStrategy — Resume / Restart / Escalate
As noted in Akka.NET vs other concurrency models, .NET trades some automation for explicit control.

8. Anti-patterns

Anti-pattern
Outcome
Right way
:---
:---
:---
New ActorSystem per session
Memory/thread blow-up
One ActorSystem • session-keyed child actors
Synchronous LLM call (Task.Wait)
Mailbox stall — everything queues
Always async Receive + PipeTo / await
Reply to Sender from inside Acting with tool results
Skips Acting → Reasoning re-entry, single round-trip only
Send to Self, keep the Become chain alive
Make every actor a PersistentActor
High snapshot frequency, disk IO bloat
Persist session memory only, keep routers/agents in-memory
Inline tool functions inside the Agent actor
Bloats the agent's responsibility, hard to test
Inject IAgentTool separately, Agent just composes

9. Porting matrix — Akka.io official samples → Akka.NET

Mapping the 9 official Akka samples into the abstraction (LlmAgentActor + AgentRouter + SessionMemoryActor) introduced above:
Akka.io sample
Core components
Akka.NET mapping
Difficulty
:---
:---
:---
:---:
Agent • session memory
HelloAgentActor (§4)
Agent × 3 + WorkflowEntity
§9-1 TripPlannerWorkflowActor • 3 agents
⭐⭐⭐
RAG + StreamingWorkflow
§9-2 RagChatAgentActor • Indexer Workflow
⭐⭐⭐
Agent • 2 tools + TimersEventSourcedEntity
§9-3 CustomerServiceAgentActor
⭐⭐⭐
Agent Memory + WorkflowEntity
Multi-agent variant — same pattern
⭐⭐
Summarisation + WorkflowEntity
RAG + classification variant of §9-2
⭐⭐⭐
IoT trend + anomaly + agent
Akka.Streams + Agent composition
⭐⭐⭐⭐
EntityTimed Action • Anthropic
Cron scheduler + Agent
⭐⭐
Tools + Spring AI
§5 WeatherAgent extension
⭐⭐

9-1. Multi-Agent Planner (workflow orchestration)

Java code from Akka official Step 4:
@ComponentId("plan-trip") public class PlanTripWorkflow extends Workflow<State> { @StepName("get-weather") private StepEffect getWeatherStep() { var weather = componentClient .forAgent().method(WeatherAgent::query) .invoke(currentState().query()); return stepEffects() .updateState(currentState().withWeather(weather)) .thenTransitionTo(PlanTripWorkflow::suggestActivityStep); } @StepName("suggest-activity") private StepEffect suggestActivityStep() { var context = "Weather: " + currentState().weather() + "\nPreferences: " + componentClient .forEntity(currentState().userId()) .method(PreferencesEntity::getPreferences) .invoke(); var suggestion = componentClient .forAgent().method(ActivityAgent::query).invoke(context); return stepEffects() .updateState(currentState().withSuggestion(suggestion)) .thenPause(); } }
Akka.NET port — no Workflow component, so combine FSM actor + Akka.Persistence:
public sealed record PlanTripStart(string UserId, string Query); public sealed record PlanTripState( string UserId, string Query, string Weather = "", string Suggestion = ""); public sealed record WeatherReady(string Weather); public sealed record SuggestionReady(string Suggestion); public sealed class TripPlannerWorkflowActor : ReceivePersistentActor { public override string PersistenceId => $"trip-planner-{_userId}"; private readonly string _userId; private readonly IActorRef _weatherAgent; private readonly IActorRef _activityAgent; private readonly IActorRef _prefsEntity; private PlanTripState _state = null!; public TripPlannerWorkflowActor( string userId, IActorRef weather, IActorRef activity, IActorRef prefs) { _userId = userId; _weatherAgent = weather; _activityAgent = activity; _prefsEntity = prefs; Become(Idle); // Persistence recovery omitted } private void Idle() { Command<PlanTripStart>(cmd => { _state = new PlanTripState(cmd.UserId, cmd.Query); // Step 1: Get Weather _weatherAgent.Tell(new AgentRequest(cmd.UserId, cmd.Query)); Become(GettingWeather); }); } private void GettingWeather() => Command<AgentResponse>(resp => { Persist(new WeatherReady(resp.Reply), evt => { _state = _state with { Weather = evt.Weather }; _prefsEntity.Tell(new GetPreferences(_userId), Self); Become(GettingPreferences); }); }); private string _preferences = ""; private void GettingPreferences() => Command<PreferencesResponse>(resp => { _preferences = resp.Value; var ctx = $"Weather: {_state.Weather}\nPreferences: {_preferences}"; _activityAgent.Tell(new AgentRequest(_userId, ctx)); Become(GettingSuggestion); }); private void GettingSuggestion() => Command<AgentResponse>(resp => { Persist(new SuggestionReady(resp.Reply), evt => { _state = _state with { Suggestion = evt.Suggestion }; Context.Parent.Tell(_state); Become(Idle); }); }); }
notion image
Key mapping:
  • componentClient.forAgent().method(...).invoke(...) = agentActorRef.Tell(...) (async, result via Receive<AgentResponse>)
  • transitionTo(NextStep) = Become(NextStep)
  • Automatic checkpointing = PersistentActor.Persist(evt, handler)
  • thenPause() = Become(WaitingForExternalInput) (e.g. wait for user approval)

9-2. RAG Chat Agent (streaming + vector search)

@Component(id = "ask-akka-agent") public class AskAkkaAgent extends Agent { public Effect<StreamingResponse<String>> chat(String question) { return effects() .systemMessage("You are a helpful Akka assistant.") .userMessage(question) .thenReplyStreaming(); } }
Akka.NET portAkka.Streams.Source + a RAG helper actor:
public sealed record RagQueryRequest(string SessionId, string Question); public sealed record RagChunk(string SessionId, string Token); public sealed record RagDone(string SessionId, int TokenCount); public sealed class RagChatAgentActor : LlmAgentActor { private readonly IActorRef _ragHelper; // vector-search actor private readonly IStreamingLlmClient _llmStream; protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) { // 1) Embed the question and fetch top-5 similar docs (Ask pattern) var docs = _ragHelper.Ask<TopKDocs>( new SimilaritySearch(req.UserMessage, K: 5), TimeSpan.FromSeconds(3)).Result; // 2) Prepend retrieved docs as system context var ctx = string.Join("\n\n", docs.Items.Select(d => $"[doc:{d.Id}] {d.Text}")); var msgs = new List<ChatMessage> { ChatMessage.System("Answer based on the docs below.\n\n" + ctx) }; msgs.AddRange(history); msgs.Add(ChatMessage.User(req.UserMessage)); return new AgentPrompt(msgs); } // Streaming-only entry point (Reasoning Behavior splits off) public IAsyncEnumerable<RagChunk> ChatStreaming(RagQueryRequest req) => Source.From(_llmStream.StreamComplete(BuildPrompt( new AgentRequest(req.SessionId, req.Question), Array.Empty<ChatMessage>()))) .Select(token => new RagChunk(req.SessionId, token)) .RunAsAsyncEnumerable(materializer); }
notion image
The Indexer Workflow uses the same pattern — IndexerWorkflowActor walks a directory, then chunks → embeds → writes into the vector DB using Akka.Streams Source.From(files).MapAsync(8, ...).

9-3. Real Estate CS Agent (2 tools + Timer + EventSourced)

The core of the official Real Estate CS Agent: 2 tools + Workflow + Timer + EventSourced Entity.
public sealed class CustomerServiceAgentActor : LlmAgentActor { public CustomerServiceAgentActor( ILlmClient llm, IActorRef emailGateway, // Tool 1: send email IActorRef customerEntity) // Tool 2 + Entity: persist customer info : base("cs-agent", llm, new IAgentTool[] { AgentToolFactory.Wrap(new SendEmailTool(emailGateway)), AgentToolFactory.Wrap(new SaveCustomerTool(customerEntity)), }) { } protected override AgentPrompt BuildPrompt(...) => /* "If customer info is incomplete, call SendEmail to follow up; if complete, call SaveCustomer." */; } // Timer: send a reminder 24h later if no follow-up arrives public sealed class CustomerInquiryWorkflowActor : ReceivePersistentActor { public CustomerInquiryWorkflowActor(IActorRef agent, ITimerScheduler timers) { Command<EmailReceived>(email => { agent.Tell(new AgentRequest(email.Sender, email.Body)); Become(WaitingForFollowUp); timers.StartSingleTimer( "reminder", new SendReminder(email.Sender), TimeSpan.FromHours(24)); }); } }
The crucial detail: a tool isn't just a function — it wraps another actor's ActorRef. When the LLM calls SendEmail, it dispatches _emailGateway.Tell(new SendEmail(...)), so even the email send happens inside the same supervision tree.

9-4. Common porting rules

:---
:---
@Component(id="x")
Context.ActorOf(Props, "x") (child path)
@StepName("get-weather")
Become(GetWeather) function name
componentClient.forAgent().method(M::f).invoke(x)
agentRef.Ask<Resp>(req, timeout) or TellReceive<Resp>
componentClient.forEventSourcedEntity(id).method(M::f).invoke()
Cluster.Sharding.Get(system).ShardRegion("...").Tell(env)
effects().thenReplyStreaming()
Source.From(stream).RunAsAsyncEnumerable(mat)
@Tool(description="...")
[AgentFunction(Description="...")] • reflection
Workflow thenPause()
Become(WaitingExternal) • wake on external message
Workflow thenTransitionTo(X::step)
Call Become(Step)
TL;DR: Akka.io's annotation-driven lifecycle maps 1:1 to .NET's explicit Become transitions + PersistentActor checkpoints. You lose a touch of automation, but the state machine becomes visible in code — which pays off massively during debugging and operations.

10. Next steps

  1. Fork a sample repo: refactor memorizer-v1's ChatBotActor into the 4-Behavior pattern from this article
  1. Multi-Agent synergy: replicate the Activity/Weather/Summarizer 3-agent collaboration from the Akka Multi-Agent Planner using an Akka.NET router + child agents
  1. Akka.Cluster.Sharding integration: route sessionId → ShardId to scale across multiple nodes
  1. Wire in MCP tools: wrap an MCP server call inside an IAgentTool implementation and expose it to the LLM

11. References

Tags

#AkkaNET #ActorModel #LLMAgent #DotNet #AkkaIO #AgenticAI #DistributedSystems #PersistentActor #ToolCalling #2026-05