MU-TH-UR 6000: Building a durable AI agent in .NET.
Temporal workflows, M.E.AI, pgvector, and .NET Aspire - a paint-by-numbers guide to a durable AI agent with PDF extraction, document storage, vector search, and child workflows.
The Setup
An agent that survives the crash, retries the tool, and keeps going.
MU-TH-UR 6000 (Mother) is the ship's AI mainframe in Alien. It runs 24 hours, logs everything, and has perfect recall. The name does most of the work here.
The companion repo at screaming-in-space/mu-th-ur-6000 is an eighteen-project
.NET solution that's deliberately too much for what it does. Two tools - PDF extraction
and document storage. The rest is structure: Temporal workflows, Aspire
orchestration, typed clients, integration tests, telemetry, vector search. The
architecture is the lesson.
Clone the repo, add AI:ApiKey to user secrets or run LM Studio locally, run
dotnet run --project src/
and have a working durable agent with PDF extraction, document storage, and vector search in under five minutes.
Every layer is visible. Nothing is behind a magic abstraction you can't read.
Following Along
▶
The article is basically a stick figure while the repo is a painted canvas.
The code snippets here are simplified. The companion repo has eighteen projects,
integration tests, custom Aspire resources, telemetry, typed HTTP clients, OpenAPI,
and Central Package Management. Enterprise FizzBuzz for Agents.
It's entirely over-engineered for "extract PDF text, store it, let the agent call those tools."
Project map, class landmarks, how to cross-reference
. . .
Following Along
▶When a snippet diverges from the companion repo at screaming-in-space/mu-th-ur-6000 - simplified parameters, omitted logging, shorter descriptions - that's intentional. The article is an approximation and it should go a bit stale over time. The repo will be the source of truth for longer.
Aspire & Infrastructure
Hosts - Runnable Services
Apps - User-Facing
Core - Shared Libraries
Class Landmarks - Article → Repo
Muthur.Bishop.Worker/Workflows/AgentWorkflow.cs Muthur.Bishop.Worker/Workflows/DocumentIngestionWorkflow.cs Muthur.Bishop.Worker/Activities/LlmActivities.cs Muthur.Bishop.Worker/Activities/ToolActivities.cs Muthur.Tools/ToolRegistry.cs Muthur.Tools/Handlers/PdfHandler.cs Muthur.Tools/Handlers/DocumentStoreHandler.cs Muthur.Agents/AiClientExtensions.cs Muthur.PostgreSql/Scripts/00_init/000_init-schema.sql Muthur.AppHost/Program.cs The Architecture - Eighteen Projects, Three Containers, Two Request Paths
Muthur.AppHost is the Aspire orchestration host.
It starts three containers - Temporal (temporalio/admin-tools),
Postgres with pgvector (pgvector/pgvector:pg17), and Redis - registers
LM Studio as an external resource with a health check, then launches the API,
Worker, and Console after health checks pass. If Docker isn't running,
EnsureDockerAsync() launches Docker Desktop and polls until it's ready.
Muthur.Api is a minimal ASP.NET Core host with three route groups:
POST/v1/agent/sessions- start a new agent workflowPOST/v1/agent/sessions/{agentId}/prompt- send a prompt via Temporal signalGET/v1/agent/sessions/{agentId}- query agent stateGET/v1/documents/{id}/{id}/content/search- document access + vector searchPOST/v1/relay/{agentId}/events- Worker publishes relay events to the SignalR hub at/v1/relay
Agent routes use untyped Temporal handles - no Worker dependency. Document routes serve from Postgres and Redis directly. The API reads documents but never writes them - only the Worker's ingestion workflow writes.
Muthur.Bishop.Worker hosts two workflows.
AgentWorkflow is the agentic loop: signal-driven, stateless,
ContinueAsNew after 50 turns. DocumentIngestionWorkflow
is a child workflow that chunks text, generates embeddings via
IEmbeddingGenerator, and bulk-inserts to Postgres with pgvector.
The agent fires the child workflow after a store_document tool call -
ingestion runs independently with its own retry policy.
Muthur.Tools owns all tool handlers, isolated
from the Worker. PdfHandler extracts text via PdfPig.
DocumentStoreHandler persists documents to Postgres. The
ToolRegistry maps names to handlers and generates M.E.AI tool schemas
for the LLM. Adding a tool means one handler file and two DI registrations in
AddMuthurTools - no workflow changes.
Muthur.Data owns Postgres and Redis. The
DocumentRepository handles CRUD and pgvector cosine similarity search
via Dapper. CachedDocumentRepository wraps it with
IDistributedCache for document lookups. DatabaseMigrationService
in Muthur.PostgreSql runs the DbUp schema scripts on startup - two tables, one HNSW index.
Muthur.Agents owns the M.E.AI pipeline:
AddAgentChatClient for LLM access, AddAgentEmbeddingGenerator
for vector embeddings, plus the Temporal client registration.
Both the API and Worker reference this project - it's the only place
provider-switching logic lives. Serilog, OpenTelemetry, and Aspire service
defaults stay in Muthur.ServiceDefaults.
The request path: Console → API → Temporal signal → LLM decides
pdf_extract_text → PdfPig extracts → summary back to LLM →
LLM decides store_document → workflow enriches with cached text →
Postgres INSERT → LLM produces final response. Two tool calls, three LLM
calls, each a Temporal checkpoint.
Three relay events flow backward through the SignalR hub:
DocumentStored after the INSERT,
AgentResponseReady after the final LLM call,
IngestionCompleted after the background vectorize pipeline
finishes chunking, embedding, and pgvector storage. The console reacts to
each event without polling.
The Workflow
The agentic loop is a while(true) that Temporal makes durable.
AgentWorkflow is a signal-driven Temporal workflow. It waits for
PromptSignal messages via WaitConditionAsync, processes
each one in a loop, and never holds LLM conversation history in workflow state.
Each turn builds its conversation from the single prompt in the signal -
history is local to ProcessPromptAsync, not carried across turns.
The inner loop in ProcessPromptAsync is the agent. It calls the LLM
as a Temporal activity, inspects the response for tool calls, dispatches each tool
call as its own activity, appends the result to the message list, and loops back
to the LLM. It stops when the LLM returns a response with no tool calls.
When pdf_extract_text returns 126K characters, the workflow
caches the full result and sends the LLM a summary - title, page count,
metadata. The LLM never sees the raw text. When it calls
store_document, it provides metadata only (title, source path,
page count). The workflow enriches the arguments with cached extraction text
before dispatching to the tool activity. The LLM is the router. The workflow
is the data plane.
// ProcessPromptAsync - the agentic loop
while (true)
{
var llmInput = new LlmActivityInput(messages, systemPrompt);
var llmOutput = await Workflow.ExecuteActivityAsync(
(LlmActivities act) => act.CallLlmAsync(llmInput),
new ActivityOptions { StartToCloseTimeout = TimeSpan.FromMinutes(2) });
// No tool calls - the LLM produced a final response.
if (llmOutput.ToolCalls.Length == 0)
return llmOutput.Content ?? "(no response)";
// Append the assistant's tool-call message to history.
messages.Add(new ConversationMessage(
AgentConstants.RoleAssistant, llmOutput.Content ?? "", llmOutput.ToolCalls));
// Execute each tool call as a separate Temporal activity.
foreach (var toolCall in llmOutput.ToolCalls)
{
var toolResult = await Workflow.ExecuteActivityAsync(
(ToolActivities act) => act.ExecuteToolAsync(toolCall.Name, toolCall.Arguments),
new ActivityOptions
{
StartToCloseTimeout = TimeSpan.FromMinutes(5),
RetryPolicy = new RetryPolicy { MaximumAttempts = 3, InitialInterval = TimeSpan.FromSeconds(2) }
});
// Append tool result to conversation history.
messages.Add(new ConversationMessage(
AgentConstants.RoleTool, toolResult, ToolCallId: toolCall.Id));
}
// Loop: send tool results back to the LLM for the next turn.
}
Each Workflow.ExecuteActivityAsync call is a Temporal checkpoint.
If the process crashes mid-loop, Temporal replays the workflow from the event history.
Completed activities replay instantly from their recorded results - the LLM is not
called again, the tool is not re-run. Only the in-flight activity re-executes.
ContinueAsNew fires after 50 turns. Temporal event histories grow
unbounded otherwise. ContinueAsNew carries the agent ID and system
prompt into a fresh workflow execution, dropping the old history while the agent
continues running. Conversation history lives in a local variable per turn,
never in workflow fields.
The Activities
Each LLM call and each tool call is its own checkpoint.
LlmActivities is the boundary between Temporal and the LLM provider.
It takes the conversation as workflow-safe records, translates to M.E.AI types,
calls GetResponseAsync with the tool schemas from the registry,
and returns structured output. Scoped activity DI gives each call a fresh
LlmActivities instance; the IChatClient it wraps is a
singleton, so every call runs through one pipeline.
[Activity]
public async Task<LlmActivityOutput> CallLlmAsync(LlmActivityInput input)
{
var options = new ChatOptions { Tools = [.. toolRegistry.GetTools()] };
var response = await chatClient.GetResponseAsync(chatMessages, options);
var toolCalls = response.Messages
.SelectMany(m => m.Contents.OfType<FunctionCallContent>())
.Select(fc => new ToolCallRequest(
fc.CallId,
fc.Name,
new Dictionary<string, object?>(fc.Arguments ?? new Dictionary<string, object?>())))
.ToArray();
var text = string.Join("", response.Messages
.SelectMany(m => m.Contents.OfType<TextContent>())
.Select(t => t.Text));
return new LlmActivityOutput(text, toolCalls);
}
ToolActivities is three lines. It hands the tool name and arguments to
the registry and returns the JSON result. The registry owns the routing; the activity owns the retry policy.
The separation is intentional - adding a tool means writing a handler, not touching the
activity dispatch logic.
[Activity]
public async Task<string> ExecuteToolAsync(string toolName, Dictionary<string, object?> arguments)
{
var cancellationToken = ActivityExecutionContext.Current.CancellationToken;
var result = await toolRegistry.ExecuteAsync(toolName, arguments, cancellationToken);
return result.Json;
}
The M.E.AI Pipeline
IChatClient is the only LLM abstraction needed here.
Microsoft.Extensions.AI's IChatClient is the .NET equivalent of the
LangChain provider interface - except it's a first-party dotnet/extensions abstraction
with real provider implementations for OpenAI, Anthropic (via OpenAI-compatible
endpoint), Ollama, Azure OpenAI, and LM Studio.[3] The abstraction cost
is real (you lose provider-specific features) but for standard tool-calling workflows
it's the right call.
The ChatClientBuilder pipeline adds cross-cutting concerns without
touching the provider. Telemetry and logging wrap every call through the same
middleware chain - the code below is the entire provider configuration for the project.
// AiClientExtensions.cs
builder.Services.AddChatClient(services =>
{
IChatClient inner = provider.ToLowerInvariant() switch
{
"anthropic" => CreateAnthropicClient(model, apiKey),
_ => CreateOpenAiClient(model, apiKey, endpoint)
};
return new ChatClientBuilder(inner)
.UseOpenTelemetry(configure: t => t.EnableSensitiveData = true)
.UseLogging()
.Build(services);
});
Tool registration uses AIFunctionFactory.Create. Each handler implements
IToolHandler and passes a method with [Description] attributes
on each parameter - M.E.AI generates the JSON schema the LLM uses to understand the tool.
store_document hides its text and metadata
parameters from the schema with ExcludeFromSchema - the LLM provides metadata
(title, source path, page count) and the workflow enriches the call with
cached extraction text before dispatching. The workflow dispatches tools by name
string, and the registry executes that name through the same AIFunction
via InvokeAsync, so the schema and the execution path are one method.
This is the Microsoft Agent Framework pattern at its simplest.[4]
The framework builds on M.E.AI - Agent Framework's graph-based multi-agent
orchestration uses the same AIFunction abstraction under the hood.
Semantic Kernel plugins are AIFunction instances now too. The
ecosystem is converging on M.E.AI as the foundation - SK, Agent Framework,
and standalone code like this repo all build on the same types.
// Handlers/PdfHandler.cs
public ToolRegistration Register() => new(
AgentConstants.ToolPdfExtractText,
AIFunctionFactory.Create(ExtractPdfTextAsync, AgentConstants.ToolPdfExtractText));
[Description("Extract text content and metadata from a PDF file.")]
public Task<PdfExtractionResult> ExtractPdfTextAsync(
[Description("Absolute path to the PDF file")] string filePath,
CancellationToken cancellationToken)
=> Task.FromResult(PdfExtractor.Extract(filePath, cancellationToken));
// Handlers/DocumentStoreHandler.cs - text and metadata are workflow-injected
private static readonly HashSet<string> WorkflowInjectedParams = ["text", "metadata"];
public ToolRegistration Register() => new(
AgentConstants.ToolStoreDocument,
AIFunctionFactory.Create(StoreDocumentAsync, new AIFunctionFactoryOptions
{
Name = AgentConstants.ToolStoreDocument,
ConfigureParameterBinding = param =>
param.Name is not null && WorkflowInjectedParams.Contains(param.Name)
? new() { ExcludeFromSchema = true }
: default
}));
The Aspire Wiring
dotnet run --project src/Aspire/Muthur.AppHost starts everything.
Aspire is the reason this works with one command. Temporal and Redis run with
WithLifetime( and survive restarts;
Postgres does not, so the database starts clean each run. LM Studio registers as an external
resource with a health check. Nothing launches until health checks pass.
The alternative is a README with twelve setup steps that nobody follows
in order.
With AddScopedActivities, Temporal creates
a fresh DI scope per activity execution - each LLM call and each tool call gets
its own activity instance. IChatClient, ToolRegistry, and
IDocumentRepository are singletons shared across every scope.
// Muthur.Bishop.Worker/Program.cs
builder.AddMuthurData("muthur-db", "muthur-cache", runMigrations: false);
builder.AddMuthurTemporalClient();
builder.AddAgentChatClient();
builder.AddAgentEmbeddingGenerator();
builder.AddMuthurTools();
builder.Services.AddHttpClient(NotificationActivities.HttpClientName, c =>
c.BaseAddress = new Uri("http://muthur-api"));
builder.Services
.AddHostedTemporalWorker(AgentConstants.TaskQueue)
.AddWorkflow<AgentWorkflow>()
.AddWorkflow<DocumentIngestionWorkflow>()
.AddScopedActivities<LlmActivities>()
.AddScopedActivities<ToolActivities>()
.AddScopedActivities<IngestionActivities>()
.AddScopedActivities<NotificationActivities>();
The AppHost manages Temporal as a container resource using a custom Aspire
extension (Aspire.Hosting.Temporal). The
AddTemporalDevServer method runs temporalio/admin-tools
with embedded SQLite, health-checks via the UI endpoint, and exposes the gRPC
frontend through Aspire service discovery.[1] In production, replace
the container with Temporal:Address pointing at your cluster.
The Tools
The LLM connects a PDF extractor to a document store.
pdf_extract_text uses PdfPig - pure
managed .NET, no native dependencies, no interop headaches in containers.
PdfDocument.Open(path) gives you pages, text, and document metadata.
A hundred lines of extraction code, zero external processes.
store_document persists the extracted
text to Postgres via IDocumentRepository. It's a fast synchronous INSERT -
title, source path, content, page count, metadata. Returns the document ID. The heavy
work (chunking, embedding, vector indexing) happens in the child workflow that fires
after this tool completes. The agent doesn't wait for it.
Both tools live in Muthur.Tools, isolated from the Worker project.
The PDF extractor is a static method. The document store handler takes DI.
The ToolRegistry collects every IToolHandler from DI and
uses each one's AIFunction as both the LLM schema and the dispatch target.
Adding a third tool means one handler file and two DI registrations in
AddMuthurTools.
// Pdf/PdfExtractor.cs
using var document = PdfDocument.Open(filePath);
var metadata = new Dictionary<string, string>();
var info = document.Information;
if (info.Title is not null) metadata["title"] = info.Title;
if (info.Author is not null) metadata["author"] = info.Author;
var text = new StringBuilder();
foreach (Page page in document.GetPages())
{
text.AppendLine($"--- Page {page.Number} ---");
text.AppendLine(page.Text);
}
return new PdfExtractionResult(
text.ToString().Trim(),
document.NumberOfPages,
metadata);
This is the same extraction path Cortex, the context service in Continuum Engine, will use for research paper ingestion. A PDF tool call gives the agent access to every paper in the knowledge base as structured text. The LLM decides which pages are relevant, extracts the key claims, and can produce context entries at whatever confidence the evidence warrants.
The Vectorize Pipeline
A child workflow that chunks, embeds, and stores - without blocking the conversation.
When the agent calls store_document, the workflow checks the tool
call's name and fires DocumentIngestionWorkflow as a child
workflow. ParentClosePolicy.Abandon means the child survives the
parent's ContinueAsNew - ingestion runs on its own timeline with
its own retry policy. The agent conversation continues immediately.
The child workflow runs four activities in sequence. ChunkTextAsync
splits the document into ~500-token chunks with 50-token overlap, breaking at
sentence boundaries when possible. GenerateEmbeddingsAsync batch-calls
IEmbeddingGenerator (nomic-embed-text-v1.5 via LM Studio, 768
dimensions) in batches of 10. StoreChunksAsync bulk-inserts the chunks
and their embeddings into the document_chunks table using
NpgsqlBinaryImporter COPY - one command for all rows - with a pgvector
vector(768) column and an HNSW index for approximate nearest neighbor
search. Finally, NotifyAsync emits an IngestionCompleted
event through the SignalR relay hub - the console receives it and runs a
vector search to confirm the document landed.
Each step is a Temporal activity checkpoint. If embedding generation fails (rate limit, network error, provider outage), Temporal retries only that step - chunking doesn't re-run, the document record is already persisted. Three attempts, starting at a 5-second backoff. If all three fail, the child workflow fails independently. The agent conversation and the document record in Postgres are unaffected.
The API exposes the results:
GET/v1/documents- lists all stored documentsGET/v1/documents/{id}- document metadata (Redis-cached)GET/v1/documents/{id}/content- full document textGET/v1/documents/search?q=...- generates a query embedding, runs pgvector cosine similarity across all indexed chunks
The search path doesn't touch Temporal - it's a direct Postgres query with
1 - (embedding <=> @Embedding) for scoring and an HNSW index for speed.
// DocumentIngestionWorkflow.cs - child workflow
[Workflow]
public class DocumentIngestionWorkflow
{
[WorkflowRun]
public async Task RunAsync(DocumentIngestionInput input)
{
var chunks = await Workflow.ExecuteActivityAsync(
(IngestionActivities act) => act.ChunkTextAsync(input.Text),
new ActivityOptions { StartToCloseTimeout = TimeSpan.FromMinutes(1) });
var embeddings = await Workflow.ExecuteActivityAsync(
(IngestionActivities act) => act.GenerateEmbeddingsAsync(chunks),
new ActivityOptions
{
StartToCloseTimeout = TimeSpan.FromMinutes(5),
RetryPolicy = new() { MaximumAttempts = 3, InitialInterval = TimeSpan.FromSeconds(5) }
});
await Workflow.ExecuteActivityAsync(
(IngestionActivities act) =>
act.StoreChunksAsync(input.DocumentId, chunks, embeddings),
new ActivityOptions { StartToCloseTimeout = TimeSpan.FromMinutes(2) });
var relay = new RelayEvent(input.AgentId, input.DocumentId,
RelayEventType.IngestionCompleted, "Embeddings complete. Search available.",
Workflow.UtcNow);
await Workflow.ExecuteActivityAsync(
(NotificationActivities act) => act.NotifyAsync(relay),
new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) });
}
}
The Tradeoffs
Temporal buys per-step durability at the cost of operational weight.
Homegrown vs. Temporal
The middle column is what most teams reach for first. The right column is what you're signing up for without either.
| Concern | Temporal | LangChain | Homegrown |
|---|---|---|---|
| Per-step durability | Each [Activity] is individually checkpointed and retried. Steps 1–3 don't re-execute when step 4 fails. |
No built-in checkpointing. A failed tool call restarts the chain. LangGraph adds persistence, but you wire it yourself. | Build a state machine. Checkpoint each step. Write compensation logic. If step 4 fails, you decide what reruns. |
| Orchestration | A while(true) with await calls. The control flow is the orchestration - C#, Python, or Go, not a workflow DSL. |
Chain/graph abstractions with callbacks. A DSL on top of the language you already know. Control flow is implicit. | Custom loop with flags, queues, and state transitions. Works until you need to recover mid-loop. |
| Background work | Child workflows with their own retry policy, history, and lifecycle. Document ingestion runs parallel to conversation without queues. | No native background execution model. Long-running tasks block the chain or require external job infrastructure. | Thread pool, message queue, or a separate job system. Wire lifecycle, error handling, and delivery guarantees yourself. |
| Retry logic | Declarative retry policies per activity. Backoff, max attempts, timeouts - in the activity options, not in application code. | Per-chain max_retries. No backoff configuration, no partial recovery. Retries restart the full call. |
Polly policies, exponential backoff, dead letter queues, idempotency keys. Scattered across the codebase. |
| Observability | Every activity is a Temporal event. Every LLM call has an OTel span. Every tool call has a retry count. | LangSmith or custom callbacks. Tracing is opt-in, per-chain, and requires a separate service to collect. | Instrument every call. Build dashboards. Correlate traces across services. Hope coverage is complete. |
| Provider swap | IChatClient swaps providers with a config change. The workflow doesn't know or care who's on the other end. |
Abstracts providers but couples you to the LangChain ecosystem. Swapping means swapping within their wrapper layer. | Adapter interfaces, factory methods, configuration plumbing. Every provider gets its own integration path. |
| Polyglot | The task queue doesn't care what language the worker speaks. .NET and Python workers on the same queue, different activity types. | JS and Python SDKs exist but they're separate ecosystems. No shared task queue or cross-language activity dispatch. | gRPC contracts, API gateways, or shared message schemas. Cross-language coordination is an infrastructure project. |
What you pay
Temporal is operational weight. The dev server is one CLI command. Production is a cluster with persistence (Cassandra, MySQL, or Postgres), history shards, and either Temporal Cloud or self-hosting ownership. Cloud pricing is tiered per-action with the first 1M actions/month included on Essentials - at moderate agent volume the annual cost is tens of dollars, not hundreds.[2] But the operational complexity of understanding Temporal itself is the real cost. For a platform where durability is the core value, that investment is front-loaded and worth it. For a chatbot that can retry from scratch, it's overhead you don't need.
Determinism constraints are a footgun.
Workflow code must be deterministic across replays - no DateTime.Now,
no Guid.NewGuid(), no HTTP calls. Everything non-deterministic goes
in an activity. The .NET SDK doesn't enforce this at compile time. You find out
at replay time, which is the worst time to find out.
M.E.AI hides provider features.
Streaming thinking tokens, extended context headers, model-specific sampling
controls, structured output schemas - all provider-specific, all invisible
behind IChatClient. The pipeline middleware adds a thin overhead
per call. For tool-calling workflows where each activity is already a network
round-trip, you won't measure it. But you'll feel the missing features when
you need them.
Event histories are dense.
A 30-turn agent conversation with tool calls produces 100+ events in the
Temporal history. Debugging means reading a serialized execution trace in the
Temporal UI, not stepping through code in a debugger. ContinueAsNew
keeps histories bounded, but the complexity per-turn is inherent.
The KISS/YAGNI tension is real. Once Temporal is running, every background job looks like a workflow. Most of them don't need durable execution. A hosted service with a retry loop is fine for 90% of background work. Temporal earns its complexity when failures are expensive - LLM calls at $0.01–0.10 each, multi-step pipelines where re-running steps 1–4 to retry step 5 is wasteful. The daily email sender is not a workflow.
Untyped handles are fragile.
When the API and Worker live in separate projects (as they should), you
signal workflows by string name. The Temporal .NET SDK silently strips the
Async suffix from signal method names - so
SendPromptAsync registers as "SendPrompt", not
"SendPromptAsync". Call the wrong name and the signal is
delivered, accepted by the server, visible in the event history - and
silently dropped because no handler matches.
The workflow just sits there. This cost us hours. If you use untyped
handles, put the signal and query names in constants alongside the task
queue name in your Contracts project.
Scheduling overhead is real but scoped. On Temporal's dev server as it ships, every hand-off through a task queue waited about 50 ms, which was the history service's transfer queue reader polling 20 times a second. With that and three other settings tuned, a workflow of one remote activity takes 27 ms end to end at p95, and durability's own time around a model call is 24 to 28 ms at p95, spent in the hand-offs outside the call. The measurements are in Continuum's durable inference benchmark.
Continuous streams don't fit. Live audio transcription, real-time sensor ingestion - anything where durability needs to wrap individual frames or packets. There's no great durability solution for that pattern. The honest answer is queue-and-stream-back, not workflow-per-chunk.
What's still not solved
Stateless history hits a wall.
Every PromptSignal carries a single prompt, and each turn starts a
fresh conversation, so the agent has no memory of earlier turns. That is enough
for a request that finishes in one turn, such as extracting and storing a PDF.
A multi-turn session needs external state - a conversation store that loads and
trims history per turn.
ContinueAsNew is the natural boundary to wire that in. It's not
in this repo.
Workflow versioning.
Change the workflow logic while executions are running and you need a versioning
strategy - Workflow.Patched(), worker build IDs, or task queue
routing. For long-running agent workflows with ContinueAsNew at
50 turns, the window is manageable. For workflows that run for days, it's a
real operational concern.
Event-driven, not token-streaming.
A SignalR relay hub with a Redis backplane delivers coarse-grained events -
DocumentStored, AgentResponseReady,
IngestionCompleted. The console subscribes before sending work
and uses a channel-based event loop to react. Token-by-token streaming is a
different pattern: pipe tokens from the activity to the relay over a
parallel channel. The workflow owns durability. The stream is a UX concern.
mu-th-ur-6000 is a working skeleton with eighteen projects, two workflows, two tools, vector search, integration tests, and seven Aspire-managed resources. Production infrastructure would add auth, rate limiting, context persistence, and monitoring dashboards. The agent loop itself is production-ready.
Sources [ − ] [ + ]
Documents
Repos