Keeping state across calls, persisting conversations, and triggering automated actions · Folders: Memory/ Conversation/ Workflows/
As noted in Guide 01: an LLM has no memory between calls. Every call to CompleteAsync() is independent. If a user tells the agent "My name is Alice" in turn 1, and then asks "What is my name?" in turn 5, Claude will not know the answer — unless the earlier turns are included in the prompt.
This project handles state in two separate ways:
Imagine a user has this conversation with the agent (no memory management). Notice what happens:
| Turn | User says | Agent replies | What Claude actually received |
|---|---|---|---|
| 1 | "My preferred carrier is FedEx for anything over 10kg." | "Got it. I'll keep that in mind." | System prompt + "My preferred carrier is FedEx..." |
| 2 | "What's the lead time for supplier SUP-2?" | "5 business days via standard shipping." | System prompt + "What's the lead time for SUP-2?" — the FedEx preference is NOT here |
| 3 | "Which carrier should I use for that order?" | "I'd recommend choosing based on your usual preferences." (vague — Claude doesn't know) | System prompt + "Which carrier should I use?" — still no FedEx preference in sight |
Problem: Every call to CompleteAsync() is independent. Without IConversationStore re-injecting history and IMemoryStore surfacing remembered facts, each call starts blank. |
|||
With memory + conversation history: Turn 3's prompt includes: the FedEx preference (from IMemoryStore.SearchAsync("carrier preference")) AND the full conversation (from IConversationStore.GetTurnsAsync(sessionId)). Claude now knows exactly which carrier to recommend. |
|||
What kind of "knowledge" does an agent store? Not the full conversation — that is IConversationStore's job. IMemoryStore stores distilled facts that the agent has learned and wants to remember across many conversations. Think of it as the agent's notebook: short bullet points like "Alice prefers FedEx for international orders", "SKU-B often runs low in Q4", "This tenant's SLA allows 7 days, not 5".
Before the agent answers a question, it can call SearchAsync to pull the most relevant facts from its notebook and include them in the prompt. The agent gets smarter over time without retraining.
public interface IMemoryStore
{
Task AddAsync(MemoryEntry entry, CancellationToken ct = default);
Task<IReadOnlyList<MemoryEntry>> SearchAsync(string query, int topK = 5, ...);
Task<IReadOnlyList<MemoryEntry>> GetAllAsync(...);
Task<bool> DeleteAsync(string id, ...);
Task<string> SummarizeAsync(...);
}
public class MemoryEntry
{
public string Id { get; set; } // unique ID (auto-generated)
public string Content { get; set; } // the fact being stored
public string Source { get; set; } // "agent", "user", "system"
public string[] Keywords { get; set; } // used for similarity matching
public DateTime CreatedAt { get; set; }
}
SearchAsync returns the most relevant memories for a given query, sorted by keyword overlap. An agent can call this before reasoning to pull in relevant context from past interactions.
If an agent stores many related facts ("SKU-A is low", "Stock for SKU-A is at 30 units", "SKU-A needs reorder"), the memory store fills up with near-duplicates. MemoryConsolidator merges entries that are too similar.
It uses Jaccard similarity on keywords: two entries with 50%+ keyword overlap are considered duplicates. The pivot entry absorbs the content of the duplicate:
// Jaccard similarity: size of intersection / size of union
public static double JaccardSimilarity(string[] a, string[] b)
{
var setA = new HashSet<string>(a, StringComparer.OrdinalIgnoreCase);
var setB = new HashSet<string>(b, StringComparer.OrdinalIgnoreCase);
var intersection = setA.Intersect(setB).Count();
var union = setA.Union(setB).Count();
return union == 0 ? 0 : (double)intersection / union;
}
Say the agent has stored these two memory entries about the same situation:
// Entry A — stored from iteration 2:
Keywords: ["SKU-A", "low", "stock", "reorder"]
Content: "SKU-A is running low and may need to be reordered"
// Entry B — stored from iteration 5:
Keywords: ["SKU-A", "stock", "threshold", "reorder"]
Content: "SKU-A stock has fallen below the reorder threshold"
// Jaccard similarity:
setA = { SKU-A, low, stock, reorder }
setB = { SKU-A, stock, threshold, reorder }
intersection = { SKU-A, stock, reorder } // 3 words in common
union = { SKU-A, low, stock, reorder, threshold } // 5 unique words total
score = 3 / 5 = 0.60 // > 0.5 threshold → entries are merged
// After merge: Entry A absorbs B's content. Entry B is deleted.
// Result: one entry instead of two near-identical ones, saving memory and tokens.
InMemoryMemoryStore, it is the right trade-off.
When a user has a multi-turn conversation with the agent, each turn must be stored so that on the next request, the agent can reload the history and send it all to Claude (remember: LLMs are stateless).
public interface IConversationStore
{
// Start a new session (returns a GUID session ID)
Task<string> CreateSessionAsync(string userId, string tenantId, ...);
// Append a turn (user message or agent reply)
Task AddTurnAsync(string sessionId, string role, string content, ...);
// Reload the full history for a session
Task<IReadOnlyList<ConversationTurn>> GetTurnsAsync(string sessionId, ...);
}
InMemoryConversationStore — fast, no setup, data lost on restart. Used by default (Persistence.UseInMemory = true).SqliteConversationStore — persists to a SQLite file (nexaflow.db). Switch by setting Persistence.UseInMemory = false in appsettings.json. Data survives restarts.SqliteConversationStore uses raw ADO.NET (not EF Core) and creates its own schema at startup if the tables do not exist. Two tables: Sessions and Turns.
| Scenario | InMemory (default) | SQLite |
|---|---|---|
| Single developer testing the demo | Fine — restarts are frequent, history doesn't need to persist | Unnecessary overhead |
| User starts a conversation, closes browser, returns tomorrow | History is gone — user starts from scratch | History reloads from disk, conversation continues seamlessly |
| Running behind IIS or a process monitor that recycles the app pool | Every recycle wipes all conversation history | Data persists across restarts — safe to deploy |
| Multiple users with long-running investigations (supply chain audits) | High risk of losing work on any crash | Durable — each user's session survives |
IDistributedCache vs IMemoryCache:
InMemoryConversationStore is like IMemoryCache — fast, process-local, gone on restart. SqliteConversationStore is like a file-backed distributed cache — slightly slower on writes, but durable and survives restarts. Switching is one line in Program.cs, exactly like swapping cache implementations.
Imagine a warehouse coordinator who sits at a desk all day watching a dashboard. Whenever stock for SKU-A drops below 50 units, they pick up the phone and call the supplier to raise a purchase order. Whenever a delivery is late, they send an email to the account manager and raise a credit note.
Now imagine you replace that coordinator with a system: "Whenever event X happens, automatically run these steps." That is event-driven automation. No human needed to watch and react — the system detects the event and acts.
In this project:
"inventory_low" or "sla_breach", fired from anywhere in the codeIWorkflow and says "when you hear inventory_low, run my ExecuteAsync method"WorkflowEngine, which holds a list of all workflows and routes events to the matching onesWorkflowEngine is the bus, IWorkflow instances are the subscribers. No actual message broker is involved — it is all in-process.
public interface IWorkflow
{
string Name { get; } // human-readable, e.g. "ReorderWorkflow"
string Trigger { get; } // event that activates this workflow, e.g. "inventory_low"
Task<WorkflowResult> ExecuteAsync(WorkflowContext context, CancellationToken ct = default);
}
public class WorkflowContext
{
public string TriggerName { get; set; }
public Dictionary<string,string> Payload { get; set; } // event data (sku, quantity, ...)
public string? TenantId { get; set; }
public string? UserId { get; set; }
public DateTime TriggeredAt { get; set; }
}
WorkflowEngine receives all registered IWorkflow instances via DI. When TriggerAsync is called, it runs every workflow whose Trigger matches the event name (case-insensitive):
public async Task<List<WorkflowResult>> TriggerAsync(string trigger, WorkflowContext context, ...)
{
var matching = _workflows
.Where(w => string.Equals(w.Trigger, trigger, StringComparison.OrdinalIgnoreCase))
.ToList();
var results = new List<WorkflowResult>();
foreach (var workflow in matching)
{
try
{
var result = await workflow.ExecuteAsync(context, ct);
results.Add(result);
}
catch (Exception ex)
{
results.Add(new WorkflowResult { Success = false, Message = ex.Message });
}
}
return results;
}
Adding a new workflow is two steps:
IWorkflow.Program.cs: builder.Services.AddSingleton<IWorkflow, MyWorkflow>();Why these two specifically? They represent the two most common patterns in supply chain automation:
Together they show the two directions: catching problems before they happen (reorder) and responding to problems after they happen (SLA breach).
inventory_lowAUTO-PO-XXXX) for the SKU specified in the event payload.WebhookController when an order arrives with quantity > 100 (to simulate a large order draining inventory).// WebhookController fires the trigger:
if (order.TotalQuantity > 100)
{
await _workflowEngine.TriggerAsync("inventory_low", new WorkflowContext
{
TriggerName = "inventory_low",
Payload = new() { ["sku"] = firstSku, ["quantity"] = order.TotalQuantity.ToString() }
});
}
// ReorderWorkflow creates AUTO-PO-0001, AUTO-PO-0002, etc.
sla_breachESC-XXXX) and a credit memo (CR-ESC-XXXX).WorkflowController via the manual-trigger UI button.POST /api/webhooks/orders → WebhookController → checks order quantity → WorkflowEngine.TriggerAsync("inventory_low", ...).
/Workflow) shows a button per trigger. Clicking it posts to WorkflowController.Trigger(), which calls WorkflowEngine.TriggerAsync().
MemoryConsolidator deduplicates near-duplicate entries using Jaccard similarity.inventory_low; SlaBreachWorkflow triggers on sla_breach.AddSingleton<IWorkflow, YourWorkflow>() line.