Code Modules in This Guide
Core/Memory
IMemoryStore.cs InMemoryMemoryStore.cs MemoryConsolidator.cs MemoryEntry.cs
Core/Conversation
IConversationStore.cs InMemoryConversationStore.cs SqliteConversationStore.cs
Core/Workflows
WorkflowEngine.cs IWorkflow.cs ReorderWorkflow.cs SlaBreachWorkflow.cs

1 The Memory Problem — LLMs Are Stateless

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:

The problem, made concrete — a 5-turn conversation without memory

Imagine a user has this conversation with the agent (no memory management). Notice what happens:

TurnUser saysAgent repliesWhat 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.

IMemoryStore — Agent Memory

  • Stores facts the agent learns over time
  • Semantically searchable
  • Survives beyond one conversation
  • Example: "Alice prefers FedEx for international shipping"

IConversationStore — Chat History

  • Stores the turn-by-turn conversation
  • Retrieved by session ID
  • Used to re-hydrate history for the next agent call
  • Example: all user messages and agent replies in session XYZ

2 IMemoryStore — Storing Agent Knowledge

src/NexaFlowDemo.Core/Memory/IMemoryStore.cs
src/NexaFlowDemo.Core/Memory/InMemoryMemoryStore.cs

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.

3 MemoryConsolidator — Deduplicating Memories

src/NexaFlowDemo.Core/Memory/MemoryConsolidator.cs

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;
}
C# analogy — like a DISTINCT query with fuzzy matching: Jaccard is a simple set-overlap calculation. If two keyword arrays share 3 out of 5 unique words, the similarity is 3/5 = 0.6, which is above the 0.5 threshold → merge. This is much simpler than full semantic similarity but works well for keyword-heavy facts.

Step-by-step Jaccard calculation — worked example

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.
Why not use cosine similarity for memory deduplication? Cosine similarity on embeddings would be more accurate but requires an embedding model call for every comparison. Jaccard on keywords is computed instantly with basic set operations — no API call, no latency, no cost. For a keyword-based memory store like InMemoryMemoryStore, it is the right trade-off.

4 IConversationStore — Multi-Turn Chat History

src/NexaFlowDemo.Core/Conversation/IConversationStore.cs
src/NexaFlowDemo.Core/Persistence/SqliteConversationStore.cs

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, ...);
}

Two implementations

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.

When does switching to SQLite actually matter?

ScenarioInMemory (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
C# analogy — like 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.

5 Workflows — Event-Driven Automation

src/NexaFlowDemo.Core/Workflows/IWorkflow.cs
src/NexaFlowDemo.Core/Workflows/WorkflowEngine.cs

What is event-driven automation? — plain English first

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:

C# analogy — like a message bus subscriber: Think of it like a pattern where you publish an event name + payload to a bus, and any subscriber whose topic matches runs. WorkflowEngine is the bus, IWorkflow instances are the subscribers. No actual message broker is involved — it is all in-process.

The IWorkflow interface

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; }
}

6 WorkflowEngine — Dispatching Events

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:

  1. Create a class that implements IWorkflow.
  2. Register it in Program.cs: builder.Services.AddSingleton<IWorkflow, MyWorkflow>();

7 The Two Built-In Workflows

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).

ReorderWorkflow

src/NexaFlowDemo.Core/Workflows/ReorderWorkflow.cs
// 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.

SlaBreachWorkflow

src/NexaFlowDemo.Core/Workflows/SlaBreachWorkflow.cs

8 Triggering Workflows — Two Ways

  1. Automatically via webhook: POST /api/webhooks/ordersWebhookController → checks order quantity → WorkflowEngine.TriggerAsync("inventory_low", ...).
  2. Manually via UI: The Workflows page (/Workflow) shows a button per trigger. Clicking it posts to WorkflowController.Trigger(), which calls WorkflowEngine.TriggerAsync().

9 Summary