Shahzad Bhatti Welcome to my ramblings and rants!

July 9, 2026

Building an Agent Harness and Eval Pipeline with Durable Actors

Filed under: Agentic AI,Computing — admin @ 1:09 pm

You may start with a simple agent for demo that calls a tool, gets an answer, prints it. But building a production ready agentic system requires a full agent harness so that it doesn’t crash halfway through a task. For example, you might have an AI coding assistant that debugs production incidents by searching logs, isolating a root cause, creating a pull request for the fix. It might take several minutes with dozens of tool calls. The AI agent may crash mid-run, fail to call a tool reliably or the test suite for eval fails. These are not model problems that you can solve with a better model or a better prompt. Instead, you need a reliable infrastructure that an agent harness provides. This post shows how to build an agent harness and eval pipeline using PlexSpaces, a polyglot actor framework that treats agent infrastructure as a first-class problem instead of an afterthought.


Agent = model + harness

You can think of an agent as model + harness. The harness is everything that isn’t the model.

The harness is the loop that decides when to stop, the tool calling that connects the model to the world, the state that survives a crash and resumes where it left off, the coordination that lets multiple agents share work, and the eval plumbing that tells you whether any of it actually worked. Most teams spend their time on the model like a different temperature here, a different prompt there, a bigger model if budget allows. I have seen teams build a prototype agentic system and then ship it to an entire organization without proper harness resulting in unexpected failures. The harness stays invisible until it breaks, and when it breaks, it looks exactly like a model problem.

There are three levers that move agent quality. Model changes are the most expensive like fine-tuning, RL, moving to a bigger model. Harness changes are nearly free like loop logic, tool schemas, retry policies, agent topology. Memory changes are the cheapest like context window management, retrieval strategy. Teams reach for the model first. They should usually start with the harness.


What the harness actually has to do

Eight responsibilities show up in every serious agent deployment, in every framework, in every language. The only question is whether you build them on purpose or accumulate them by accident after the third production incident.

Harness propertyWhat it doesPlexSpaces primitive
Loop controlIteration limits, token budget, stop conditionsAgentLoop (max_iterations, token_budget)
Tool callingDispatch, schema validation, error captureToolRegistryActor + SchemaValidationFacet
State managementSurvives crashes, resumes from checkpointDurabilityFacet (journal replay)
MemoryPrior context per agent, per runKV store (host.kv_get / host.kv_put)
Multi-agent coordinationFan out work, collect results without tight couplingTupleSpace (write tuple, match pattern)
SupervisionA subagent crash doesn’t take down the orchestratorSupervision tree (one_for_one)
ObservabilityEvery step captured and queryable mid-runExecutionTraceFacet
Eval plumbingTrajectories –> scores –> regression detectionEvalRunnerActor, ScorerActor

Every one of these is a solved problem in actor frameworks generally. PlexSpaces just wires them together for agent workloads specifically.


Why the actor model fits this problem

The actor model was designed for systems that keep running when individual components fail, which happens to be exactly the property a multi-agent pipeline needs. Each actor is an isolated unit of state and behavior. Actors talk only through messages without sharing memory. There’s no global state, and no way for one actor to corrupt another actor’s state directly.

This lines up with what distributed systems theory already tells us. The FLP theorem says that in a distributed system where even one failure is possible, you cannot guarantee both safety and liveness without explicit coordination. The actor model handles this by making failure a first-class citizen: actors crash, supervisors restart them, the system keeps running. It’s the same design that let Erlang run telecom systems for five nines of uptime.

For agent systems, three consequences follow directly:

  • Crash isolation. When an AgentActor fails mid-eval, only that actor restarts. EvalRunnerActor and every other running agent keep going. In thread-per-agent or future-based systems, a crash in one agent typically propagates up and takes the rest down with it.
  • No shared-state corruption. Agents talk through messages and TupleSpace, not shared memory, so they can’t overwrite each other’s context. A hallucinating agent writing garbage stays contained to itself.
  • Journal replay without application code. The durability journal lives at the framework level, below your actor’s code. You don’t implement checkpointing yourself and the framework journals every message before the actor runs it.

The building blocks

Following PlexSpaces primitives do all the work in this harness.

  • Actors are the basic unit. Each one owns its state and handles messages one at a time. Actors talk by sending messages and you never reach into another actor’s state directly.
  • GenServer is a request-reply actor: send it a message, it processes and replies. LLMGatewayActor, ScorerActor, and DashboardActor are all GenServers.
  • WorkflowActor is a durable workflow. It checkpoints its state before each step, and if the process crashes, it replays from the last checkpoint on restart without application code. EvalRunnerActor and BenchmarkActor are WorkflowActors.
  • GenFSM is a state machine actor: you define states and transitions, and the state persists across crashes. ApprovalGateActor (human-in-the-loop) is a GenFSM.
  • Facets are cross-cutting behaviors you attach to any actor without touching its code. You can think them as middleware, but declared in app-config.toml instead of written in application code. Three facets carry the harness:
    • SchemaValidationFacet validates tool call arguments against JSON Schema before the actor ever sees the message.
    • DurabilityFacet journals every message before your actor’s code runs, so a crash-and-restart wakes up the actor with exactly the state it had.
    • ExecutionTraceFacet records every step in order and exports the full trace to KV storage when a workflow completes, which is what feeds eval.
  • Supervision trees enforce fault isolation. You declare a tree of actors and a restart strategy; one_for_one means one crash restarts only that actor, while the orchestrator and every sibling agent keep running. In LangGraph or CrewAI, a crash typically takes down the whole graph.
  • TupleSpace is a shared blackboard for multi-agent coordination, built on the Linda coordination model. Actors write tuples (["trajectory", run_id, data]) and read them by pattern (["trajectory", run_id, nil]). Producer and consumer stay decoupled without polling or sharing state.

Two loops, two owners

There’s a useful mental model for agent systems: two concentric loops, with different owners.

The inner loop is the agent trying to accomplish the task: investigate, implement, test, report. The outer loop is the engineer deciding whether the agent’s output deserves trust: decide, verify, approve, own. The harness sits at the boundary. It’s where agent output turns into evidence like diffs, test results, trajectories, scores that the engineer can actually inspect before deciding to approve, redirect, or block.

This framing matters for eval specifically because eval tooling that scores only the final answer misses most of what’s happening. An agent that stumbles onto the right answer through a wrong path scores fine on outcome-only eval, then fails the moment the task shifts slightly. What you actually want to evaluate is the trajectory or the path, not just the destination.


Why pass@k beats pass/fail

Agents are non-deterministic. For example, the same task, same model, same harness will succeed sometimes and fail other times. A single binary pass/fail on one run gives you noise, not signal. The right metric is pass@k: run the same scenario k times and count how many succeed. A score of 0.9 means the agent solved it 9 out of 10 tries. This is well established in code-generation benchmarks like SWE-bench, HumanEval, and MBPP. The same logic carries over to agent harnesses: you need pass@k across your own task distribution, not a one-shot score you happened to get lucky on. Comparing pass@k between two harness configurations gives you real evidence about which one is more reliable, without touching the model at all.

ScorerActor produces the 0–1 signal pass@k needs, using rubric-based scoring:

Step 9: ScorerActor — score trajectory
  score task_completion
  Score: 0.85  (rubric: task_completion)
  score tool_use
  Score: 0.80  (rubric: tool_use)

Two rubrics run against the same trajectory. task_completion asks whether the agent reached the goal. tool_use asks whether it used the right tools in the right order. These can diverge, e.g., an agent might complete a task through a lucky shortcut that would fail on a harder variant. The trajectory rubric catches that divergence; the outcome-only rubric never sees it.


In-runtime eval versus post-hoc eval tools

The popular eval tools like LangSmith, DeepEval, Braintrust, Phoenix/Arize work the same way: run the agent, export traces or logs, evaluate afterward. That model has a structural flaw: eval doesn’t run in the same environment as production. Agent configuration, tool schemas, retry logic, and context management can all drift between the eval harness and the production deploy. When eval passes and production fails, there’s no way to tell whether the failure is in the model or just in the mismatch between the two setups.

MiniPi’s eval runs inside the same PlexSpaces node, against the same actors, with the same tool schemas, under the same supervision tree as production. EvalRunnerActor isn’t a separate process logging to an external service, instead it’s an actor in the same supervision tree as the AgentActor it’s testing. If you change the schema in production, and eval will pick it up automatically, because it’s the same schema.

That also makes eval a first-class durable workflow instead of a batch job. EvalRunnerActor is a WorkflowActor with DurabilityFacet attached. Kill it mid-suite, restart it, and it resumes from the last checkpoint, skipping every scenario already scored. Long eval suites survive node restarts, which is a property that no external eval tool offers.


MiniPi: the example

MiniPi is a 12-actor agent eval pipeline, ported five ways: Go, Python, TypeScript, Rust WASM, and Rust embedded. They all produce the same output against the same PlexSpaces node:

examples/go/apps/minipi/          # 1.5M WASM
examples/python/apps/minipi/      # 47M WASM
examples/typescript/apps/minipi/  # 13M WASM
examples/rust/apps/minipi/        # 6.3M WASM
examples/rust/embedded/minipi/    # native Rust, no WASM

The 12 actors cover the full harness stack:

ActorTypeWhat it does
LLMGatewayActorGenServerOllama integration with KV response cache and mock fallback
ToolRegistryActorGenServer4 built-in tools with JSON Schema validation
AgentActorWorkflowActorOODA loop (Observe, Orient, Decide, Act)
EvalRunnerActorWorkflowActorRuns scenarios in parallel, collects trajectories
ScenarioStoreActorGenServer10 built-in test scenarios
ScorerActorGenServerScores trajectories against rubrics
TrajectoryStoreActorGenServerPersists agent trajectories for eval
RegressionDetectorActorGenServerCompares scores across runs, flags drops over 5%
BenchmarkActorWorkflowActorRuns the same scenarios against different harness configs
AdvisorActorGenServerTwo-tier LLM: cheap model plus expensive advisor on demand
ApprovalGateActorGenFSMHuman-in-the-loop: idle –> awaiting_approval –> idle
DashboardActorGenServerAggregate view across all eval runs

All 12 are declared in app-config.toml under a one_for_one supervision strategy. The framework starts them, watches them, and restarts individual actors on crash. Here’s how they connect. Notice there’s no separate “eval environment” bolted on the side and EvalRunnerActor calls the exact same AgentActor that production traffic calls:

You can swap the debugging assistant for a support-ticket triager, a claims processor, or a code-review bot, and the same 12 actors still apply, only ScenarioStoreActor‘s scenarios and the tool schemas change.


The OODA loop

The agent itself is an AgentActor, a WorkflowActor running an OODA loop (Observe, Orient, Decide, Act).

Here’s the core loop, from the Go implementation:

// agent.go — the OODA loop
// DurabilityFacet (priority 90) journals every message before this code runs.
// Kill the process mid-loop. Restart. It picks up from the last checkpoint.

for !loop.IterationLimitReached() {
    if loop.BudgetExceeded() {
        // Over token budget — finalize trajectory and return cleanly
        traj := loop.FinalizeTrajectory("budget_exceeded", iterations)
        a.exportTrajectory(traj)
        return result("budget_exceeded", traj)
    }

    // OBSERVE: load prior context from KV memory
    observations := a.doObserve(loop, task)

    // ORIENT: ask LLM gateway what to do next
    // LLM gateway tries Ollama first, falls back to mock, caches in KV
    plan := a.doOrient(loop, observations)

    // DECIDE: pick action. Does it need human approval?
    action := a.doDecide(loop, plan)
    if needsApproval(action) {
        loop.Suspend("action_needs_approval")
        return result("suspended", nil)
    }

    // ACT: run the tool through ToolRegistryActor
    // SchemaValidationFacet (priority 95) validates the call before the tool runs
    a.doAct(loop, action)
    loop.IncrementIteration()
}

traj := loop.FinalizeTrajectory("completed", iterations)
a.exportTrajectory(traj) // writes to KV + posts TupleSpace tuple for eval collection

Four things happen here that you’d otherwise have to build by hand:

  • Crash recovery is automatic. DurabilityFacet journals each message before the actor runs. Kill the node at iteration 7, restart it, and the loop resumes at iteration 8 without re-burning tokens.
  • Budget enforcement lives in AgentLoop. It counts tokens across every LLM call and stops the loop before you overspend.
  • Trajectory capture happens in exportTrajectory. Every Observe/Orient/Decide/Act step gets recorded with timing and token counts, written to KV storage, and posted as a TupleSpace tuple so EvalRunnerActor can find it.
  • Human approval is a durable suspend, not a poll. The agent serializes its full state and returns. ApprovalGateActor holds the request. When a human approves, the signal resumes the agent exactly where it paused even in the middle of a multi-hour run.

Real output from test.sh against a running node:

Step 5: AgentActor — OODA loop run
  workflow_run
  Status: completed  Outcome: completed
  Steps: 27  Trajectory: traj-01K... (27 steps in KV + TupleSpace index)

Crash recovery: replay at the system level

The durability property is worth slowing down on, because it’s categorically different from checkpointing you write yourself. When DurabilityFacet journals a message, it does so at the actor framework level, below your code. On restart, the journal replays those messages and your actor’s state comes back exactly as it was without application code to handle “resume from crash”.

There are a few differences compared to Temporal, which also relies on replay. First, Temporal requires you to write workflow code as a deterministic function that can be safely replayed; PlexSpaces lets the actor’s message handling look like ordinary code, because the framework journals at the message boundary instead. Second, Temporal has no supervision tree so a crashing activity gets retried by the workflow, but nothing independently restarts just that piece while the rest keeps running. In PlexSpaces, one_for_one means a crashed AgentActor on scenario 3 restarts in isolation while EvalRunnerActor, ScorerActor, and every other scenario agent keep going.

[supervisor]
strategy = "one_for_one"
max_restarts = 10
max_restart_window_seconds = 60

In LangGraph or CrewAI, one agent crashing typically kills the whole graph. Temporal can be made to handle this, but it takes explicit error-handling code. In PlexSpaces, independent crash isolation is just the default. For example, you might have have 20 tool calls into a host that gets OOM-killed. With DurabilityFacet, the node restarts, the journal replays, and the agent picks up at tool call 21.


Validating tool calls without touching agent code

Agents call tools with malformed arguments, which is unavoidable because models make mistakes. The real question is where you catch it. Catching it inside the tool handler is too late; execution has already started, and now you’re cleaning up a half-run call.

SchemaValidationFacet catches it before the actor sees the message at all. An empty web_search query never reaches the tool registry because the facet returns a structured error, the agent corrects the call, and retries. The schema itself lives in app-config.toml, not in code:

[[supervisor.children]]
name = "tool_registry"
actor_type = "minipi_wasm"
behavior_kind = "GenServer"

[[supervisor.children.facets]]
type = "schema_validation"
priority = 95

[supervisor.children.facets.config]
validation_mode = "strict"

[supervisor.children.facets.config.method_schemas]
web_search  = '{"type":"object","required":["query"],"properties":{"query":{"type":"string","minLength":1}}}'
calculator  = '{"type":"object","required":["expression"],"properties":{"expression":{"type":"string"}}}'
kv_read     = '{"type":"object","required":["key"],"properties":{"key":{"type":"string"}}}'
kv_write    = '{"type":"object","required":["key","value"]}'

Nothing about the tool actor’s code changes. The guardrail lives entirely in configuration.

Step 6: SchemaValidationFacet — reject invalid method input
  reject empty query
  Schema validation: REJECTED (before actor sees it)
  Error contains: validation or schema
  valid call still works
  Valid call accepted: "web_search" executed successfully

For example, you might have a billing support agent with a refund_customer tool. The model occasionally hallucinates a negative amount, a missing currency code, or an order ID that isn’t a string. Without a facet catching this, that call reaches your payments system and either throws an ugly stack trace or, worse, silently coerces bad input. With the schema in app-config.toml, the malformed call never leaves the tool registry. Instead, it bounces back to the agent as a structured error it can correct on the next turn, and your payments code never has to defend against it.


Eval, running in the same runtime as production

EvalRunnerActor is a WorkflowActor. It fans out one AgentActor per scenario, collects trajectories through TupleSpace, and scores them.

// eval_runner.go — fan out and collect
for i, scenario := range scenarios {
    // Spawn a fresh AgentActor for each scenario
    agentID := fmt.Sprintf("eval-agent-%s-%d", evalRunID, i)
    spawnedID, _ := host.Spawn("minipi_wasm", agentID, "agent_runner", map[string]string{
        "eval_run_id": evalRunID,
        "scenario_id": scenario.ID,
    })

    // Run the agent — same OODA loop as production
    resp, _ := host.Ask(spawnedID, "workflow_run", map[string]any{
        "task":        scenario.Input,
        "eval_run_id": evalRunID,
    }, 60000)

    // Collect trajectory directly from response
    if traj, ok := resp["trajectory"]; ok {
        trajectories = append(trajectories, traj)
    }
}

// Score each trajectory against the scenario's rubric
for _, traj := range trajectories {
    score, _ := host.Ask("scorer", "score", map[string]any{
        "trajectory": traj,
        "rubric":     scenario.Rubric,
    }, 10000)
    scores = append(scores, score)
}

Because EvalRunnerActor is a WorkflowActor, killing it mid-eval is safe. Restart it and it skips scenarios that already finished. Long eval suites survive node restarts without losing progress. Real output from a 5-scenario run (Go):

Step 10: EvalRunnerActor — 5-scenario standard suite
  eval smoke suite
  Pass rate: 0.833  Avg score: 0.775  Completed: 5 / 5
  Harness metrics: total_ms=125  coord_overhead=92.8%  speedup=5x  scenarios/sec=48

  sc-math-01     [XXXXXXXX--] 0.85
  sc-search-01   [XXXX------] 0.40
  sc-calc-01     [XXXXXXXX--] 0.85
  sc-reason-01   [XXXXXXXX--] 0.85
  sc-budget-01   [XXXXXXXX--] 0.85

The TypeScript port tracks real token cost per scenario:

Step 10: EvalRunnerActor — 5-scenario standard suite
  Pass rate: 0.4  Avg score: 0.818  Completed: 5 / 5
  Tokens: 311 in / 223 out  (est. cost: $0.00018)

  sc-math-01     0.92  (53 in / 41 out)
  sc-search-01   0.92  (63 in / 45 out)
  sc-calc-01     0.76  (60 in / 44 out)
  sc-reason-01   0.79  (62 in / 44 out)
  sc-budget-01   0.70  (73 in / 49 out)

coord_overhead is the harness’s own overhead like spawning agents, collecting via TupleSpace, scoring. Across a 5-agent parallel run, it stays flat while compute scales, which is exactly the property you want: harness cost shouldn’t grow with the number of agents. For example, a legal team may need to run a contract review nightly across 200 incoming documents. Each document gets its own AgentActor, spawned by EvalRunnerActor the same way scenarios are spawned here. Because coordination happens through TupleSpace instead of a shared in-memory queue, one document’s agent hanging on a malformed PDF doesn’t block the other 199 and the batch survives a restart if the node needs to redeploy halfway through the night.


Regression detection

A single eval score doesn’t tell you much on its own. What matters is whether it’s better or worse than last time. RegressionDetectorActor stores baseline scores and flags any scenario that drops more than 5%:

# regression_detector.py
@handler("compare")
def compare(self, eval_run_id: str = "", baseline_run_id: str = "") -> dict:
    baseline = self._load_scores(baseline_run_id)
    current  = self._load_scores(eval_run_id)

    regressions  = []
    improvements = []

    for scenario_id, base_score in baseline.items():
        curr_score = current.get(scenario_id, base_score)
        delta = curr_score - base_score
        if delta < -0.05:   # more than 5% drop
            regressions.append({"scenario_id": scenario_id, "delta": delta})
        elif delta > 0.05:
            improvements.append({"scenario_id": scenario_id, "delta": delta})

    return {
        "regressions":  len(regressions),
        "improvements": len(improvements),
        "details":      regressions + improvements,
    }
Step 11: RegressionDetectorActor
  set_baseline
  Baseline set from eval-smoke-001 actual scores
  compare
  Regressions: 1  (sc-search-01 degraded by 0.20)
  Improvements: 1  (sc-reason-01 improved by 0.05)
  Regression detector caught degradation in search scenario

The search scenario dropped by 20-point regression that would be invisible if the only thing you watched was the aggregate pass rate.


Benchmarking harness configs, not just models

The most underused insight in agent engineering: harness changes are often cheaper and more impactful than model changes. BenchmarkActor runs the same scenarios against multiple harness configurations. Here’s the Python implementation:

# benchmark.py
@handler("run_benchmark")
def run_benchmark(self, scenario_suite: str = "smoke", configs: list = None) -> dict:
    results = []
    for config in (configs or self._default_configs()):
        # Run a full eval with this config
        eval_result = host.ask("eval_runner", {
            "action":       "run_suite",
            "suite":        scenario_suite,
            "eval_run_id":  f"bench-{config['name']}",
            "agent_config": config,
        }, timeout_ms=120000)

        results.append({
            "config":    config["name"],
            "score":     eval_result.get("avg_score", 0),
            "pass_rate": eval_result.get("pass_rate", 0),
            "tokens":    config.get("token_budget", 0),
            "max_iter":  config.get("max_iterations", 0),
        })

    winner = max(results, key=lambda r: r["score"])
    return {"configs": results, "winner": winner["config"]}

Output from the Rust WASM port, on harder multi-step scenarios:

Step 12: BenchmarkActor — 3-config comparison
  Configs tested: 3  Winner: aggressive  Best score: 0.83  Worst: 0.73

  aggressive    [XXXXXXXX--] score=0.830  budget=8192tok  max_iter=20
  balanced      [XXXXXXXX--] score=0.800  budget=4096tok  max_iter=10
  conservative  [XXXXXXX---] score=0.730  budget=1024tok  max_iter=3

Same model, same scenarios, same prompt but the harness config alone moves quality by 14%. That’s the case for measuring this before reaching for a bigger model. The Python port, on simpler scenarios, tells a different story:

Step 12: BenchmarkActor — 3-config comparison
  Configs tested: 3  Winner: conservative  Best score: 0.7

  conservative  [XXXXXXX---] score=0.700  budget=1024tok  max_iter=3
  balanced      [XXXXXXX---] score=0.700  budget=4096tok  max_iter=10
  aggressive    [XXXXXXX---] score=0.700  budget=8192tok  max_iter=20
  (on simple arithmetic tasks, all configs tie — benchmark your actual tasks)

On simple arithmetic, budget doesn’t matter, the task fits in 3 iterations no matter what you give it. On multi-step research tasks, the loop limit starts to bite. Benchmark your own workload rather than trusting either result blindly. For example, a content-moderation team may need to decide how much iteration budget to give a policy-review agent. A conservative config (low budget, few iterations) is cheap but might miss nuance in a borderline post. An aggressive config catches more edge cases but costs more per review. BenchmarkActor runs last month’s flagged-content scenarios against both configs and reports the actual quality delta.


Two-tier LLM: the advisor pattern

Not every turn of the OODA loop needs the expensive model. Most turns are routine; only a handful demand deep reasoning. AdvisorActor implements a two-tier pattern: a fast, cheap model handles everything by default, and escalates to the expensive model only when its own confidence drops below a threshold.

# advisor.py
@handler("advise")
def advise(self, prompt: str = "", context: dict = None) -> dict:
    # Always try the cheap model first
    fast_result = self._call_executor(prompt, context)
    self.total_requests += 1

    if fast_result.get("confidence", 1.0) >= self.confidence_threshold:
        # Confident enough — return fast result
        return fast_result

    # Low confidence — escalate to expensive advisor
    self.escalated += 1
    self.advisor_tokens += fast_result.get("tokens", 0)
    advisor_result = self._call_advisor(prompt, context, fast_result)
    self.advisor_tokens += advisor_result.get("tokens", 0)
    return advisor_result

Rust, with harder prompts and a 60% escalation rate:

Step 14: AdvisorActor — two-tier LLM
  Confidence threshold: 0.8
  Total requests: 5  Escalated: 3 / 5
  Escalation rate: 60.0%
  Advisor token share: 57.3%

Python, with simpler prompts and a 40% escalation rate:

Step 14: AdvisorActor — two-tier LLM
  Escalation rate: 40.0%
  Advisor token share: 33.6%
  Two-tier routing working — advisor escalated high-complexity prompts

The two metrics that matter are escalation rate and advisor token share. Run your eval suite at threshold 0.9, then 0.7, then 0.5, and feed each result into BenchmarkActor. That’s how you find where the quality/cost tradeoff actually sits for your own tasks, instead of guessing. For example, a support-ticket classifier handling 10,000 tickets a day. Most are routine like “reset my password,” “where’s my order” and a cheap model nails them at near-100% confidence. The 5–10% that involve conflicting account details or ambiguous intent escalate to the expensive advisor. Routing every ticket through the expensive model would be needlessly costly; routing none of them through it would tank accuracy on the hard cases. The advisor pattern gets you both.


Human-in-the-loop, without polling

High-stakes actions need approval before they execute. The naive approach polls a status endpoint, which holds resources open, doesn’t survive a restart, and forces the agent to stay running the whole time. ApprovalGateActor is a GenFSM: idle –> awaiting_approval –> idle. The state is durable, e.g., kill the node while a request is pending, restart it, and the request is still there because the FSM state was journaled before the crash ever happened.

# approval_gate.py
class ApprovalGateActor:
    state: str = "idle"
    pending_request: dict = None

    @handler("request_approval")
    def request_approval(self, request: dict) -> dict:
        if self.state != "idle":
            return {"status": "busy", "current_state": self.state}
        self.state = "awaiting_approval"
        self.pending_request = request
        return {"status": "pending", "state": self.state}

    @handler("approve")
    def approve(self, approver_id: str = "", notes: str = "") -> dict:
        if self.state != "awaiting_approval":
            return {"error": "not_awaiting_approval"}
        decision = {"decision": "approved", "approver_id": approver_id, "notes": notes}
        self.decision_history.append(decision)
        self.state = "idle"
        self.pending_request = None
        return {"status": "approved", "state": self.state}
Step 13: ApprovalGateActor — human-in-the-loop
  get_status idle
  FSM state: idle
  request_approval
  FSM state: awaiting_approval
  approve
  Approved by: alice@example.com
  FSM state: idle
  Decisions in history: 1

The agent never polls. It calls loop.Suspend(), serializes its state, and returns. When approval comes through, the PlexSpaces runtime sends a resume signal, and the agent wakes up exactly where it paused without loss of state or rerun. For example, your debugging assistant runs 30 tool calls, identifies the root cause, and proposes a deploy. At the deploy_to_production call, it suspends. An on-call engineer reviews the trajectory in the dashboard and clicks approve. The agent resumes, and only the deployment step runs.


The aggregate view

After several eval runs, DashboardActor rolls everything up — scores, pass rates, trends over time:

Step 15: DashboardActor — aggregate results
  Total evals: 4  Avg score: 0.767

  bench-001          [XXXXXX--] score=0.700  pass=70%
  eval-smoke-001     [XXXXX---] score=0.760  pass=40%
  eval-smoke-002     [XXXXX---] score=0.730  pass=40%
  test-999           [XXXXXXX-] score=0.880  pass=90%

Four runs in this session: two smoke evals, one benchmark, one direct test. test-999 at 0.880 is a single high-confidence call. The smoke runs sit at 0.730–0.760, dragged down by the harder search scenario that consistently scores 0.40.


How PlexSpaces compares

FeatureLangGraphAutoGenCrewAIRestateTemporalPlexSpaces
Crash recoveryNoNoNoJournal replayJournal replayJournal replay
Supervision treesNoNoNoNoNoYes (one_for_one, one_for_all, rest_for_one)
Eval in same runtimeNo (LangSmith)NoNoNoNoYes (same actors, same facets)
Tool schema validationApp codeApp codeApp codeApp codeApp codeSchemaValidationFacet (config only)
Human-in-the-loopInterruptNo native supportNo native supportSignalSignalGenFSM (durable state)
PolyglotPythonPythonPythonTS/Java/Python/Go/RustTS/Java/Python/GoGo/Python/TS/Rust via WASM
Multi-agent coordinationGraph edgesShared memoryRole handoffKeyed stateWorkflow stepsTupleSpace (Linda model)
WASM sandboxingNoNoNoNoNoYes

What MiniPi tests covers

Each language port runs the same 15-step integration test. Every step exercises a production pattern, not a mock shortcut:

StepWhat it testsKey metric
1ScenarioStore: seed 10 built-in scenariosscenarios_stored=10
2LLMGateway: Ollama with mock fallback and KV cacheprovider=ollama or mock
3ToolRegistry: 4 tools with JSON Schema registeredtools_registered=4
4SchemaValidation: empty query rejected before actor runsrejected_before_actor=true
5AgentActor: full OODA loop, 10 iterations, budget enforcedoutcome=completed, steps=27–40
6TrajectoryStore: persist and retrieve by IDtrajectory_id=traj-…
7ScorerActor: two rubrics on the same trajectorytask_completion=0.85, tool_use=0.80
8EvalRunnerActor: 5-scenario smoke suite, parallelpass_rate=0.40–0.83, avg=0.76–0.82
9RegressionDetector: compare against baselineregressions=1, improvements=1
10BenchmarkActor: 3 harness configs, same scenarioswinner by score
11ApprovalGateActor: durable FSM wait and resumeidle –> awaiting –> idle
12Second eval suite: drift detectionpass_rate compared to step 8
13DashboardActor: first aggregate viewtotal_evals=2
14AdvisorActor: two-tier routing, token splitescalation_rate=40%–60%
15DashboardActor: final aggregate across all runstotal_evals=2–4, avg_score=0.767–0.81

Running it

You need a PlexSpaces node on port 8091, plus the toolchain for whichever port you want to run. Ollama with llama3.2 pulled is optional and test.sh falls back to a deterministic mock if Ollama isn’t running.

# Using Docker (recommended)
docker run -p 8000:8000 plexspaces/node:latest

# Or build from source
git clone https://github.com/plexobject/plexspaces.git
cd plexspaces && make build
# Go — 1.5M WASM, 5x parallel speedup, fastest eval
cd examples/go/apps/minipi
./build.sh && ./test.sh 8091

# Python — 47M WASM, most readable actor code, full object registry
cd examples/python/apps/minipi
./build.sh && ./test.sh 8091

# TypeScript — 13M WASM, token cost tracking per scenario
cd examples/typescript/apps/minipi
./build.sh && ./test.sh 8091

# Rust WASM — 6.3M WASM, most complete benchmark scoring
cd examples/rust/apps/minipi
./build.sh && ./test.sh 8091

# Rust embedded — no WASM, in-process node, fastest startup
cd examples/rust/embedded/minipi
./test.sh

Summary

Agents fail for harness reasons more often than model reasons. For example, the loop exits too early; a malformed tool call crashes the agent; an eval suite runs against mocks, passes, and hands you false confidence. These are infrastructure problems, and infrastructure problems have infrastructure solutions. For example, in a debugging assistant mentioned above, the harness is what lets you restart a 40-tool-call investigation from step 37 instead of step 1. It’s what lets you gate a deployment on human approval without holding a thread open for ten minutes. It’s what lets you run ten scenarios in parallel and get a pass@k score you can actually trust before you ship.

PlexSpaces brings together four decades of actor-model thinking like supervision trees from Erlang, TupleSpace coordination from Linda, durable workflows in the spirit of Temporal and wires them together specifically for agent workloads. The same runtime runs on a laptop and in production. The same actors used for eval are the actors that run in production. There’s no mismatch between the two. The harness is half the agent so build it like infrastructure.

GitHub: https://github.com/bhatti/PlexSpaces


Further reading

Previous posts in this series:

Example code and documentation:

June 18, 2026

Applying Formal Verification to Guard AI-Generated Code

Filed under: Agentic AI — admin @ 4:16 pm

How automated reasoning with Dafny and TLA+ reduces review burden, catches subtle bugs, and gives you a principled way to resist the pressure to ship without thinking


The Problem Keeps Getting Worse

Over the past year I’ve written about agentic coding from several angles such as how to keep design ownership with engineers, how to use TLA+ for executable specifications, how to apply property-based and fuzz testing for microservices, and how to structure the entire delivery process through SDLC skills that force AI to operate within human-defined constraints. Each of those things helps but none of them fully solves the core problem.

The core problem: AI-generated code is probabilistic, and at scale, probability catches up with you. Based on Brooks’ Mythical Man-Month breakdown, coding itself is roughly 14% of the software delivery process. Agentic AI has largely solved that 14%. It writes clean, well-formatted, plausible code faster than any human. But plausible is not the same as correct. And when you generate 10× more code, the other 86% of your pipeline like design, specification, review, testing, deployment doesn’t automatically scale with it. I keep watching three failure modes play out:

  • Hallucinations scale with complexity. An AI writing a 50-line function gets it right most of the time. An AI building a major feature in a large codebases with dozens of modules operators more probabilistically. It produces shallow modules instead of deep ones, duplicates logic, and makes locally correct decisions that violate global invariants. The code looks fine at the file level but the system breaks at the integration level.
  • Review becomes the bottleneck. When one engineer’s code output multiplies by 10×, review bandwidth doesn’t scale with it. I’ve watched teams respond in two ways: slow everything down to match review capacity, or cut the review process to maintain throughput. Amazon learned what cutting review does to production reliability. It’s not a lesson you want to repeat.
  • AI-generated code is harder to review than messy code. This is the counterintuitive one. Bertrand Meyer’s article AI for Software Engineering: From Probable to Provable names it precisely: clean, well-structured AI code creates a psychological safety bias. You stop reading as carefully. The concurrency bug in elegant code is harder to spot than the same bug in obviously messy code.

The Pressure to Abandon Quality

I have observed the organizational pressure to treat 10× code output as “just faster developers” and to cut the review, specification, and verification processes accordingly. I’ve seen executive pressure to eliminate code review entirely, to lay off senior engineers who “just do reviews,” to skip integration testing because “the AI tested it.”

This is exactly backwards. When code output increases 10×, the need for rigorous verification increases proportionally not decreases. Joe Mager’s Monte Carlo simulation of agentic coding pipelines quantifies that at a defect rate of 1-in-40 commits with a 12-hour pipeline, you get 0.7% deployment success, essentially deadlock. He calls the safe zone the “valley of calm”: the region where defect rate × pipeline duration stays well below 1.

Formal verification is the tool that keeps you in the valley. It doesn’t slow the generative side down and the AI still generates code fast. It gates the output mathematically, so you catch invariant violations before they reach production rather than after. The practical solution is a triple-engine pipeline: a generative engine (the LLM) that produces implementations fast, a verification engine (Dafny/TLA+/Z3) that proves correctness mathematically, an AI assisted specification engine where LLMs write the loop invariants, lemma stubs, and preconditions that feed the verifier. Human engineers own the intent and specification: what invariants matter, what correctness means in the problem domain. AI assists on all three layers. This post shows how to build that pipeline using a real RBAC system as the example. The companion repository is at github.com/bhatti/automated-reasoning.


From Logic AI to LLMs and Back

Modern LLMs work by predicting the next token, i.e., statistical, probabilistic, pattern-matching at scale. But AI didn’t start here. The dominant AI paradigm from the 1970s through the 1990s was symbolic and logical: knowledge representation, inference engines, expert systems, formal reasoning. We went from logic to probability and now we need both.

timeline
    title AI Paradigms and Verification Approaches
    section Logic Era (1970s–1990s)
        1972 : Prolog — logic programming and knowledge representation
        1979 : Boyer-Moore theorem prover
        1986 : Eiffel introduces Design by Contract
        1987 : TLA created by Leslie Lamport
    section Hybrid Era (2000s–2010s)
        1999 : Z3 SMT solver (Microsoft Research)
        2005 : Alloy model finder
        2009 : Dafny created (Microsoft Research)
        2014 : TLA+ used at AWS for S3 and DynamoDB
    section LLM Era (2020s)
        2022 : ChatGPT and Copilot — probabilistic code generation goes mainstream
        2024 : Agentic coding — 10× code throughput becomes normal
        2025 : Spec-driven development movement emerges
        2026 : Formal verification as AI guardrail

Understanding this history matters for a practical reason: the tools from the logic era didn’t disappear when LLMs arrived. They got faster, more automated, and better integrated into real development workflows. The question today isn’t “logic or probability?”, it’s “how do we combine them?”

Prolog and Logic Programming

Prolog (1972) represents knowledge as facts and rules, then uses unification and backtracking to answer queries. For authorization policy, you write the what, not the how:

has_role(alice, viewer).
has_role(bob, editor).
role_inherits(editor, viewer).
role_grants(viewer, read, docs).
role_grants(editor, write, docs).

can_access(User, Action, Resource) :-
    has_role(User, Role),
    role_grants(Role, Action, Resource).
can_access(User, Action, Resource) :-
    has_role(User, Role),
    role_inherits(Role, Parent),
    role_grants(Parent, Action, Resource).

?- can_access(bob, read, docs).
% Yes — bob is editor, editor inherits viewer, viewer grants read:docs.

Design by Contract: Eiffel (1986)

Bertrand Meyer’s Eiffel language introduced Design by Contract (DbC): every method carries a formal contract such as preconditions, postconditions, class invariants that the runtime checks. I’ve been a fan of this approach for a long time, because it encodes intent alongside code rather than hoping a test suite happens to cover the right cases. DbC influenced:

  • Clojure: pre/post condition maps on functions
  • Ada/SPARK: formal proof obligations on subprograms
  • Java/C++: assert statements (though almost nobody enables them in production, which defeats the point)
  • Go: convention-based precondition checks that panic or return errors
  • Dafny: compile-time verification of contracts

The key DbC insight that gets lost in most production codebases: assertions should always be enabled in production. They’re not test-time scaffolding. They’re executable specifications that catch invariant violations the moment they occur, including input combinations no test ever anticipated.

The Verification Spectrum

Here’s how I think about the tools available, from informal to formally proven:

ApproachWhat it guaranteesEffortTools
Unit testsSpecific inputs passLowJUnit, Go testing
BDD/GherkinNamed scenarios passLow–MediumCucumber, Godog
Property-based testingRandom inputs satisfy propertiesMediumgopter, QuickCheck, Hypothesis
Fuzz testingMutated inputs don’t crashMediumgo-fuzz, AFL, libFuzzer
Contract testingAPI boundaries respectedMediumPact, api-mock-service
Static analysisType safety, null checksLowgo vet, Rust compiler
Design by ContractPre/post/invariants checked at runtimeMediumEiffel, Clojure pre/post, assertions
Model checkingAll reachable states are safeHighTLA+, SPIN, Alloy
Deductive verificationMathematical proof of correctnessHighDafny, Lean, Coq
AI-assisted proof generationLoop invariants, lemmas, and annotations generated by LLMs,Low–Mediumdafny-annotator, LLM + Dafny

The progression is from probabilistic to provable. The top rows test specific cases and find bugs. The bottom rows prove properties over all possible inputs and make entire classes of bugs impossible. AI-generated code needs both sides of this spectrum. Tests give you practical coverage fast. Proofs give you guarantees that no test suite can match. But here’s the catch I keep running into: when tests are also generated by AI, they may test the wrong thing as they optimize for passing, not for correctness. Formal specifications are the antidote. They state what correct is, mathematically, so even wrongly generated tests get caught when they conflict with the spec.


Automated Reasoning: The Technical Foundation

Before diving into code, let me explain what automated reasoning means. Automated reasoning means using software to answer mathematical questions about other software, without running it. Three activities matter here:

  1. Control flow analysis: what execution paths can the code take?
  2. Invariant discovery: what conditions hold regardless of which path it takes?
  3. Property verification: given a specification, does the code satisfy it for all inputs?

The critical distinction from testing: testing checks that specific inputs produce expected outputs. Automated reasoning proves that a property holds for every input the program could ever receive.

SAT: Boolean Satisfiability

The foundation is SAT (Boolean Satisfiability): given a formula with boolean variables, can you assign true/false values to satisfy all constraints simultaneously?

Example: (A v B) ^ (¬A v C) ^ (¬B v ¬C)
SAT solver: A=true, B=false, C=true

SAT is NP-complete in theory but practically fast with modern CDCL (Conflict-Driven Clause Learning) solvers. Industrial solvers handle millions of variables routinely.

SMT: Satisfiability Modulo Theories

SMT extends SAT with theories and first-class reasoning about integers, real numbers, arrays, bitvectors, and strings. Where SAT works with booleans, SMT works with the kinds of values programs actually use:

(assert (= (+ x y) 10))
(assert (> x 3))
(assert (> y 3))
(check-sat)
--> sat; x=4, y=6

AWS runs SMT at extraordinary scale. Their Zelkova system runs a billion SMT queries per day to analyze IAM access policies. Zelkova encodes IAM policies as logical formulas and feeds them to Z3 and CVC4. The FMCAD 2018 paper describes how policies translate to first-order logic with string theories and how incremental SMT solving makes this practical at scale.

Constraint Logic Programming (CLP)

CLP extends logic programming with constraint domains. Rather than enumerating solutions by hand, you declare variables, domains, and constraints, and the solver searches:

from ortools.sat.python import cp_model
model = cp_model.CpModel()
x = model.new_int_var(0, 10, 'x')
y = model.new_int_var(0, 10, 'y')
model.add(x + y == 10)
model.add(x > 3)
model.add(y > 3)
solver = cp_model.CpSolver()
solver.solve(model)  # finds x=4, y=6

Google’s CP-SAT scheduler uses this approach and outperforms integer programming for VM migration scheduling because interval variables natively model time-continuous constraints.

The Formal Verification Landscape

Where to apply each:

  • Distributed systems with concurrency: TLA+ (exhaustive state space exploration)
  • Algorithm correctness and data structure invariants: Dafny (deductive proof)
  • Access policy analysis: SMT (Zelkova, Cedar, Z3 directly)
  • Memory safety: Rust’s type system, Verus, or Dafny ghost state

One finding from AWS’s work that surprises most people: formal verification often makes systems faster, not just safer. Their IAM authorization engine got a 50% performance improvement after verification as the process of proving correctness forced developers to eliminate redundant computation and latent bugs that happened to be performance bottlenecks. The S3 index subsystem moved from quarterly to monthly releases after applying automated reasoning. This directly addresses the organizational pushback: verification doesn’t slow you down.


Spec-Driven Development: The Movement Behind the Tools

The insight that specifications instead of code should drive AI development has gained serious traction. Projects like OpenSpec and Spec-Kit formalize this workflow. My own you-got-skills SDLC skills set encodes it: structured workflows for PRD refinement, TRD review, architecture, work breakdown, implementation, and formal QA where AI operates within human-defined constraints rather than inventing its own.

The spec-driven philosophy is: make invalid implementations unrepresentable. You can do this through types (Rust, Haskell), contracts (Eiffel, Dafny), or formal models (TLA+). When your specification is precise enough, AI hallucinations become immediately visible as verification failures rather than subtle production bugs. The verifier catches them instead of the reviewer or the on-call engineer at 2am. Formal verification shifts the time from debugging production incidents to writing specifications that make the rest of the process faster and more predictable.


TLA+ for Concurrency

I covered TLA+ extensively in my earlier post about an year go. I’ll show a targeted example specific to RBAC: the concurrent policy update problem.

The scenario: two admins simultaneously assign roles to the same principal. Without coordination, a check-then-act race violates Separation of Duty (SoD):

Admin A: check(submitter) --> no conflict --> intend to assign
Admin B: check(approver)  --> no conflict --> intend to assign
Admin A: assign(submitter) OK
Admin B: assign(approver)   <-- SoD violated: both roles now held

The TLA+ spec models an optimistic-locking protocol and asks TLC to exhaustively check that SoD is never violated:

(* Safety: SoD never violated *)
SoDOK ==
  ~(\E r1, r2 \in assigned : r1 # r2 /\ <<r1,r2>> \in Conflicts)

(* Liveness: every pending assignment eventually completes *)
Liveness ==
  \A a \in Admins :
    [](phase[a] = "checking" => <>(phase[a] \in {"done", "idle"}))

With 2 admins and 3 roles ({Submitter, Approver, Viewer}), TLC explores 1,046 distinct states and finds no violations:

Model checking completed. No error has been found.
2349 states generated, 1046 distinct states found, 0 states left on queue.

Full spec is in tla/RBACPolicyChange.tla in the companion repo. Run it with:

java -jar ~/tla2tools.jar -config tla/RBACPolicyChange.cfg tla/RBACPolicyChange.tla

For the rest of this post I focus on Dafny, since I already covered TLA+ in depth and Dafny is where I spend most of my verification time now.


Dafny: Practical Deductive Verification

Dafny is a verification-aware programming language from Microsoft Research. It sits in the practical sweet spot: more powerful than static analyzers, far less manual effort than Coq or Lean. Dafny uses Z3 under the hood and verifies many programs automatically without manual proof steps. Importantly for this post, Dafny compiles to Go so you write your specifications in Dafny, your implementations in Go, and the type system and contracts carry through naturally.

The Amazon Dafny curriculum describes three roles Dafny plays simultaneously:

  1. Programming language: loops, classes, generics, standard data structures
  2. Proof assistant: write lemmas and Dafny proves them automatically
  3. Program verifier: attach requires/ensures to methods and Dafny proves they hold for all possible inputs

Design by Contract in Dafny

method Divide(a: int, b: int) returns (result: int)
  requires b != 0           // precondition: caller must ensure this
  ensures result * b == a   // postcondition: callee guarantees this
{
  return a / b;
}

If you call Divide(10, 0), Dafny rejects it at compile time not at runtime. That’s the shift from “testing catches bugs” to “bugs can’t be expressed.”


The Example: An RBAC System

I chose RBAC because it’s rich enough to demonstrate real verification value without being contrived. The companion project is a simplified version of my saas_rbac project. The domain model has six entities:

Why RBAC? It exhibits four bug classes that AI-generated code routinely gets wrong and each maps cleanly to a formal property:

  • Type safety: dangling references, e.g., a principal in org A assigned a role that references a resource in org B
  • Structural safety: role hierarchy must be a DAG, e.g., cycles cause infinite loops during claim resolution
  • Security safety: policy evaluation must be sound (no phantom permissions) and complete (no missed permissions)
  • Conflict safety: Separation of Duty must hold after every role assignment, including the symmetric direction AI almost always misses

Each of these is a property I state once in Dafny and prove once rather than hoping a test suite happens to exercise the right edge cases.


Step 1: Types and the System-Wide Invariant (rbac_types.dfy)

The first thing I write isn’t any method, it’s the ValidStore predicate: the system-wide invariant that every operation must preserve. Writing it out forces you to articulate what “correct state” actually means before writing a single line of logic.

predicate ValidStore(s: RBACStore) {
  // No dangling principal references
  && (forall id :: id in s.principals ==>
        s.principals[id].orgId in s.orgs)
  // Tenant isolation: role parents must be in same org
  && (forall rid :: rid in s.roles ==>
        (forall pid :: pid in SeqToSet(s.roles[rid].parentIds) ==>
           s.roles[pid].orgId == s.roles[rid].orgId))
  // Claim resources must exist in the store
  && (forall rid :: rid in s.roles ==>
        (forall c :: c in s.roles[rid].claims ==>
           c.resourceId in s.resources))
  // ... (8 more invariants)
}

A lemma proves the empty store satisfies it and Dafny verifies this automatically with no manual proof steps:

lemma EmptyStoreIsValid()
  ensures ValidStore(EmptyStore())
{}

The value here isn’t the lemma but it’s the discipline the predicate imposes. When you have to state every invariant precisely before writing code, the class of bugs you can introduce narrows dramatically. Every subsequent method carries requires ValidStore(s) and ensures ValidStore(result) and Dafny enforces this chain automatically.


Step 2: Policy Evaluation (rbac_policy.dfy)

The two most critical properties of any authorization system:

SOUNDNESS:    If Evaluate returns Allow, a valid claim chain EXISTS.
              No phantom permissions. No false positives.

COMPLETENESS: If a valid claim chain exists, Evaluate returns Allow.
              No missed permissions. No false negatives.

I write the ground-truth specification as a pure, non-executable predicate, then verify that the executable method matches it exactly:

// The specification — states what "correct" means mathematically
predicate PolicySpec(req: Request, store: RBACStore, ctx: EvalContext) {
  var principal := store.principals[req.principalId];
  exists c :: c in PrincipalClaims(principal, store.roles) &&
              ClaimGrants(c, req.action, req.resourceId, ctx)
}

// The implementation — Dafny proves it matches PolicySpec for all inputs
method Evaluate(req: Request, store: RBACStore, ctx: EvalContext)
    returns (decision: Decision)
  requires ValidStore(store)
  requires req.principalId in store.principals
  requires store.principals[req.principalId].orgId == req.orgId
  ensures decision == Allow ==> PolicySpec(req, store, ctx)    // SOUNDNESS
  ensures decision == Deny  ==> !PolicySpec(req, store, ctx)   // COMPLETENESS

Dafny verifies the loop implementation with a loop invariant that tracks “no match found in claims[0..i]”:

while i < |claimSeq|
  invariant decision == Deny ==>
    forall j :: 0 <= j < i ==>
      !ClaimGrants(claimSeq[j], req.action, req.resourceId, ctx)
  decreases |claimSeq| - i
{
  if ClaimGrants(claimSeq[i], req.action, req.resourceId, ctx) {
    decision := Allow;
    return;
  }
  i := i + 1;
}

The decreases clause proves termination and Dafny guarantees no infinite loops, for any input. When AI generates the implementation, if it introduces a subtle loop condition bug, Dafny catches it immediately rather than at a production incident. A bonus lemma proves monotonicity and adding claims can never turn an Allow into a Deny:

lemma MoreClaimsMonotonic(req, store1, store2, ctx)
  requires store1 has subset of claims of store2
  ensures PolicySpec(req, store1, ctx) ==> PolicySpec(req, store2, ctx)

Step 3: Role Hierarchy with No Cycles (rbac_role_hierarchy.dfy)

AddParent proves that cycles can never be introduced, regardless of what sequence of operations an API caller attempts:

method AddParent(child: RoleId, parent: RoleId, roles: map<RoleId, Role>)
    returns (result: map<RoleId, Role>, ok: bool)
  requires NoCycles(roles)
  ensures ok  ==> NoCycles(result)    // DAG invariant always preserved
  ensures !ok ==> result == roles     // rejection leaves the store unchanged
{
  var wouldCycle := child in Ancestors(parent, roles, |roles|);
  if wouldCycle { return roles, false; }
  // safe to add the parent edge
}

Ancestors computes the full ancestor set with bounded recursion, e.g., fuel of |roles| is sufficient for any valid DAG. This is a property that’s easy to state but extremely hard to test exhaustively: you’d have to enumerate all possible role graph topologies. Dafny proves it once, for all possible graphs.


Step 4: Separation of Duty (rbac_separation_of_duty.dfy)

SoD says certain role pairs must never be co-assigned and you can’t be both the invoice submitter and the invoice approver. The subtle bug AI code routinely misses is the symmetric case: checking (existing, new) but not (new, existing). This is exactly the kind of off-by-one semantic error that looks correct on inspection and only surfaces in edge-case inputs.

predicate SoDSatisfied(assignedRoles: set<RoleId>, conflicts: ConflictSet) {
  forall a, b ::
    a in assignedRoles && b in assignedRoles && a != b ==>
      (a, b) !in conflicts
}

method AssignRole(principal, newRole, conflicts)
  requires SoDSatisfied(SeqToSet(principal.roleIds), conflicts)
  ensures ok  ==> SoDSatisfied(SeqToSet(updated.roleIds), conflicts)
  ensures !ok ==> exists existing ::
    existing in SeqToSet(principal.roleIds) &&
    (existing, newRole) in conflicts   // proof witness for why it was rejected

Here’s what Dafny outputs when an AI generates the broken version that only checks one direction:

rbac_separation_of_duty.dfy(42,4): Error: a postcondition could not be proved
  ensures ok ==> SoDSatisfied(SeqToSet(updated.roleIds), conflicts)

Counterexample:
  principal.roleIds = ["approver"]
  newRole = "submitter"
  conflicts = {("submitter", "approver")}  ? (new, existing) direction missed

That counterexample shows exactly which input violates the contract, with a concrete example. Without Dafny, catching this requires either a carefully targeted test case or it shows up in production when someone discovers SoD can be bypassed by using conflict pairs in reverse order.


Step 5: Constraint Monotonicity (rbac_constraints.dfy)

Constraints make RBAC dynamic like time windows, geo fences, usage quotas. The key properties to prove are:

// Adding constraints can only reduce access, never increase it
lemma AddingConstraintReducesAccess(base, extra, ctx)
  ensures AllConstraintsHold(base + [extra], ctx) ==>
          AllConstraintsHold(base, ctx)

// Empty constraint list always passes (vacuous truth — no constraints = no restrictions)
lemma EmptyConstraintsAlwaysHold(ctx)
  ensures AllConstraintsHold([], ctx)
{}

// Higher usage makes quota constraints harder to satisfy
lemma HigherUsageHarder(limit, usage1, usage2, ctx)
  requires usage1 <= usage2
  ensures ConstraintHolds(MaxUsage(limit), ctx[usage:=usage2]) ==>
          ConstraintHolds(MaxUsage(limit), ctx[usage:=usage1])

These seem obvious. They are exactly the properties that break when AI generates constraint evaluation with subtle off-by-one errors or a flipped inequality direction (>= instead of >). Proving them once means you catch the implementation error in the Go translation from a failed test instead of production from an access control bypass.


The Go Implementation: Verified by Construction

The Go implementation translates the Dafny specifications directly. Every design decision traces back to a proved property.

Types Mirror Dafny Datatypes

// go/pkg/types/types.go

type Claim struct {
    ID          ClaimID
    Action      string
    ResourceID  ResID
    Constraints []Constraint   // empty = always passes (vacuous truth, proved by EmptyConstraintsAlwaysHold)
}

// NewTimeWindow enforces the Dafny precondition ValidConstraint at construction time
func NewTimeWindow(start, end int) (Constraint, error) {
    if start >= end || end > 24 {
        return Constraint{}, fmt.Errorf("invalid time window: start < end <= 24 required")
    }
    return Constraint{Kind: TimeWindowKind, StartHour: start, EndHour: end}, nil
}

// NewGeoFence — Dafny requires |regions| > 0
func NewGeoFence(regions []string) (Constraint, error) {
    if len(regions) == 0 {
        return Constraint{}, fmt.Errorf("geo fence requires at least one region")
    }
    return Constraint{Kind: GeoFenceKind, Regions: regions}, nil
}

// NewMaxUsage — Dafny requires limit > 0
func NewMaxUsage(limit int) (Constraint, error) {
    if limit <= 0 {
        return Constraint{}, fmt.Errorf("max usage limit must be positive")
    }
    return Constraint{Kind: MaxUsageKind, MaxCount: limit}, nil
}

Constraint Evaluation Maps Directly to Dafny

// go/pkg/constraints/constraints.go

// Holds mirrors Dafny's ConstraintHolds predicate exactly.
// Every case corresponds to a branch in the Dafny match expression.
func Holds(c types.Constraint, ctx types.EvalContext) bool {
    switch c.Kind {
    case types.TimeWindowKind:
        // Dafny: ctx.currentHour >= c.startHour && ctx.currentHour < c.endHour
        return ctx.CurrentHour >= c.StartHour && ctx.CurrentHour < c.EndHour
    case types.GeoFenceKind:
        // Dafny: ctx.currentRegion in c.allowedRegions
        for _, r := range c.Regions {
            if r == ctx.CurrentRegion {
                return true
            }
        }
        return false
    case types.MaxUsageKind:
        // Dafny: ctx.currentUsage < c.maxCount
        return ctx.CurrentUsage < c.MaxCount
    default:
        return false
    }
}

// AllHold evaluates a conjunction of constraints.
// Dafny proved: AllConstraintsHold([], ctx) == true (EmptyConstraintsAlwaysHold)
// Dafny proved: AllConstraintsHold(base + [extra], ctx) ==> AllConstraintsHold(base, ctx)
func AllHold(cs []types.Constraint, ctx types.EvalContext) bool {
    for _, c := range cs {
        if !Holds(c, ctx) {
            return false
        }
    }
    return true
}

Role Hierarchy: BFS with Proven Cycle Detection

// go/pkg/hierarchy/hierarchy.go

// HasCycle returns true if adding parent to child would create a cycle.
// Mirrors Dafny: child in Ancestors(parent, roles, |roles|)
func (r *Resolver) HasCycle(child, parent types.RoleID) bool {
    visited := map[types.RoleID]bool{}
    queue := []types.RoleID{parent}
    for len(queue) > 0 {
        current := queue[0]
        queue = queue[1:]
        if current == child {
            return true
        }
        if visited[current] {
            continue
        }
        visited[current] = true
        if role, ok := r.roles[current]; ok {
            queue = append(queue, role.ParentIDs...)
        }
    }
    return false
}

// AddParent adds a parent role with cycle guard.
// Mirrors Dafny: requires NoCycles, ensures NoCycles preserved or store unchanged.
func (r *Resolver) AddParent(child, parent types.RoleID) error {
    if r.HasCycle(child, parent) {
        return fmt.Errorf("adding parent %s to %s would create a cycle", parent, child)
    }
    role := r.roles[child]
    role.ParentIDs = append(role.ParentIDs, parent)
    r.roles[child] = role
    return nil
}

// TransitiveClaims collects all claims reachable through the role hierarchy.
// BFS bounded by number of roles — same as Dafny's fuel parameter.
func TransitiveClaims(roleID types.RoleID, roles map[types.RoleID]types.Role) []types.Claim {
    var claims []types.Claim
    visited := map[types.RoleID]bool{}
    queue := []types.RoleID{roleID}
    for len(queue) > 0 {
        current := queue[0]
        queue = queue[1:]
        if visited[current] {
            continue
        }
        visited[current] = true
        role, ok := roles[current]
        if !ok {
            continue
        }
        claims = append(claims, role.Claims...)
        queue = append(queue, role.ParentIDs...)
    }
    return claims
}

Store: Invariant Enforcement at Every Write

// go/pkg/store/store.go

// AssignRole mirrors Dafny AssignRole:
//   requires SoDSatisfied(current roles, conflicts)
//   ensures  SoDSatisfied(updated roles, conflicts) OR rejection with witness
func (s *Store) AssignRole(principalID types.PrinID, roleID types.RoleID) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    principal, ok := s.principals[principalID]
    if !ok {
        return fmt.Errorf("principal %s not found", principalID)
    }
    role, ok := s.roles[roleID]
    if !ok {
        return fmt.Errorf("role %s not found", roleID)
    }
    // Tenant isolation — from Dafny ValidStore predicate
    if role.OrgID != principal.OrgID {
        return fmt.Errorf("tenant isolation: role org %s != principal org %s",
            role.OrgID, principal.OrgID)
    }
    // SoD conflict check — checks BOTH directions, per Dafny SoDSatisfied predicate
    for _, existingRoleID := range principal.RoleIDs {
        if s.hasConflict(existingRoleID, roleID) {
            return fmt.Errorf("separation of duty: role %q conflicts with existing role %q",
                roleID, existingRoleID)
        }
    }
    // Safe to assign — SoD preserved (Dafny ensures clause holds)
    principal.RoleIDs = append(principal.RoleIDs, roleID)
    s.principals[principalID] = principal
    return nil
}

Policy Engine Encodes the Soundness/Completeness Contract

// go/pkg/policy/policy.go

// Evaluate decides Allow or Deny for a request.
// Preconditions from Dafny requires clauses: request fields valid, principal exists, tenant matches.
// Postconditions from Dafny ensures clauses: Allow iff valid claim chain exists.
func (e *Engine) Evaluate(req types.Request, ctx types.EvalContext) (types.Decision, error) {
    if err := req.Validate(); err != nil {
        return types.Deny, fmt.Errorf("invalid request: %w", err)
    }
    principal, ok := e.store.GetPrincipal(req.PrincipalID)
    if !ok {
        return types.Deny, nil  // deny-by-default — proved by DenyByDefault lemma
    }
    if principal.OrgID != req.OrgID {
        return types.Deny, fmt.Errorf("tenant isolation violated")
    }
    // Walk transitive claims — same BFS algorithm as Dafny Evaluate method
    claims := hierarchy.PrincipalClaims(principal, e.store.AllRoles())
    for _, c := range claims {
        if constraints.ClaimGrants(c, req.Action, req.ResourceID, ctx) {
            return types.Allow, nil
        }
    }
    return types.Deny, nil
}

Property-Based Tests: The Bridge from Provable to Probable

Each Dafny lemma gets a matching gopter property-based test. Dafny proves for all possible inputs; gopter fires hundreds of random inputs and catches bugs in the Go translation where the Dafny spec is correct but the Go implementation diverges.

// go/pkg/policy/policy_test.go

// Mirrors: DenyByDefault lemma in rbac_invariants.dfy
func TestProp_NoRolesAlwaysDenied(t *testing.T) {
    props := gopter.NewProperties(gopter.DefaultTestParameters())
    props.Property("principal with no roles is always denied", prop.ForAll(
        func(action, resource string) bool {
            s := store.New()
            _ = s.AddOrg(types.Organization{ID: "org", Name: "org"})
            _ = s.AddResource(types.Resource{ID: resource, Name: resource, Kind: "api"})
            _ = s.AddPrincipal(types.Principal{ID: "p", OrgID: "org", Name: "P"})
            engine := policy.New(s)
            req := types.Request{OrgID: "org", PrincipalID: "p",
                Action: action, ResourceID: resource}
            decision, _ := engine.Evaluate(req, types.EvalContext{CurrentHour: 12})
            return decision == types.Deny
        },
        gen.AlphaString(), gen.AlphaString(),
    ))
    props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}

// Mirrors: AddingConstraintReducesAccess lemma in rbac_constraints.dfy
func TestProp_ConstraintMonotonicity(t *testing.T) {
    props := gopter.NewProperties(gopter.DefaultTestParameters())
    props.Property("subset of constraints passing implies prefix passes", prop.ForAll(
        func(hour int, region string, usage int) bool {
            ctx := types.EvalContext{
                CurrentHour:   abs(hour) % 24,
                CurrentRegion: region,
                CurrentUsage:  abs(usage) % 100,
            }
            tw, _ := types.NewTimeWindow(9, 17)
            base := []types.Constraint{tw}
            geo, _ := types.NewGeoFence([]string{"us-east-1"})
            extended := append(base, geo)
            // If the extended (stricter) set passes, the base set MUST also pass
            if constraints.AllHold(extended, ctx) {
                return constraints.AllHold(base, ctx)
            }
            return true
        },
        gen.Int(), gen.AnyString(), gen.Int(),
    ))
    props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}

// Mirrors: HigherUsageHarder lemma in rbac_constraints.dfy
func TestProp_QuotaMonotonicity(t *testing.T) {
    props := gopter.NewProperties(gopter.DefaultTestParameters())
    props.Property("higher usage never turns Deny into Allow for quota", prop.ForAll(
        func(limit int, lo int, delta int) bool {
            limit = abs(limit)%100 + 1
            lo = abs(lo) % 200
            hi := lo + abs(delta)%100   // hi >= lo guaranteed

            quota, _ := types.NewMaxUsage(limit)
            ctxLo := types.EvalContext{CurrentUsage: lo}
            ctxHi := types.EvalContext{CurrentUsage: hi}

            // If quota passes at HIGHER usage, it MUST pass at lower usage
            if constraints.Holds(quota, ctxHi) {
                return constraints.Holds(quota, ctxLo)
            }
            return true
        },
        gen.Int(), gen.Int(), gen.Int(),
    ))
    props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}

// Mirrors: OwnClaimsIncluded lemma in rbac_role_hierarchy.dfy
func TestProp_OwnClaimsAlwaysIncluded(t *testing.T) {
    props := gopter.NewProperties(gopter.DefaultTestParameters())
    props.Property("role's own claims always appear in transitive claims", prop.ForAll(
        func(claimCount int) bool {
            claimCount = abs(claimCount)%5 + 1
            var claims []types.Claim
            for i := 0; i < claimCount; i++ {
                claims = append(claims, types.Claim{
                    ID:         types.ClaimID(fmt.Sprintf("c%d", i)),
                    Action:     fmt.Sprintf("action%d", i),
                    ResourceID: "res1",
                })
            }
            roles := map[types.RoleID]types.Role{
                "role1": {ID: "role1", OrgID: "org1", Claims: claims},
            }
            transitive := hierarchy.TransitiveClaims("role1", roles)
            for _, c := range claims {
                found := false
                for _, tc := range transitive {
                    if tc.ID == c.ID {
                        found = true
                        break
                    }
                }
                if !found {
                    return false
                }
            }
            return true
        },
        gen.Int(),
    ))
    props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}

Running the full suite:

+ empty constraint list always passes: OK, passed 200 tests.
+ hour in [start,end) passes, hour outside fails: OK, passed 500 tests.
+ subset of constraints passing implies prefix passes: OK, passed 300 tests.
+ higher usage never turns Deny into Allow for quota: OK, passed 300 tests.
+ role's own claims always appear in transitive claims: OK, passed 200 tests.
+ adding a parent never reduces transitive claims: OK, passed 200 tests.
+ unknown principal is always denied: OK, passed 200 tests.
+ principal with no roles is always denied: OK, passed 200 tests.
PASS — 2300+ property-based test cases executed.

Each line corresponds to a Dafny lemma. The property tests don’t replace the proofs, they catch bugs in the Go translation that the Dafny verifier can’t see.


Adding a Feature the Verified Way

Let me walk through adding a RateLimit constraint from scratch. This is the exact workflow for extending a formally verified system, and it shows why the upfront cost is much lower than it looks.

Step 1: Add the datatype in Dafny

datatype ConstraintKind =
    | TimeWindow(startHour: nat, endHour: nat)
    | GeoFence(allowedRegions: seq<string>)
    | MaxUsage(maxCount: nat)
    | RateLimit(requestsPerMinute: nat)  // NEW

predicate ValidConstraint(c: ConstraintKind) {
    match c
    case TimeWindow(s, e) => 0 <= s < e <= 24
    case GeoFence(regions) => |regions| > 0
    case MaxUsage(max) => max > 0
    case RateLimit(rpm) => rpm > 0   // must be positive
}

Step 2: Define evaluation semantics

predicate ConstraintHolds(c: ConstraintKind, ctx: EvalContext) {
    match c
    case TimeWindow(s, e) => s <= ctx.currentHour < e
    case GeoFence(regions) => ctx.currentRegion in regions
    case MaxUsage(max) => ctx.currentUsage < max
    case RateLimit(rpm) => ctx.currentRequestRate < rpm   // NEW
}

Step 3: Write and prove a monotonicity lemma

// Lower rate limit is harder to satisfy — proved automatically
lemma LowerRateLimitHarder(rpm1: nat, rpm2: nat, ctx: EvalContext)
    requires rpm1 <= rpm2
    requires rpm1 > 0 && rpm2 > 0
    ensures ConstraintHolds(RateLimit(rpm1), ctx) ==>
            ConstraintHolds(RateLimit(rpm2), ctx)
{
    // Dafny proves this in under a second: if rate < rpm1 <= rpm2 then rate < rpm2
}

Step 4: Verify

$ dafny verify dafny/rbac_constraints.dfy
Dafny program verifier finished with 12 verified, 0 errors

Step 5: Implement in Go

func NewRateLimit(rpm int) (Constraint, error) {
    if rpm <= 0 {
        return Constraint{}, fmt.Errorf("rate limit must be positive")
    }
    return Constraint{Kind: RateLimitKind, MaxCount: rpm}, nil
}

// Add to Holds() in constraints.go:
case types.RateLimitKind:
    return ctx.CurrentRequestRate < c.MaxCount

Step 6: Write the matching property test

func TestProp_LowerRateLimitHarder(t *testing.T) {
    props := gopter.NewProperties(gopter.DefaultTestParameters())
    props.Property("lower rate limit is harder to satisfy", prop.ForAll(
        func(rpm1, rpm2, rate int) bool {
            rpm1 = abs(rpm1)%100 + 1
            rpm2 = rpm1 + abs(rpm2)%100   // rpm2 >= rpm1 guaranteed
            ctx := types.EvalContext{CurrentRequestRate: abs(rate) % 200}
            c1, _ := types.NewRateLimit(rpm1)
            c2, _ := types.NewRateLimit(rpm2)
            if constraints.Holds(c1, ctx) {
                return constraints.Holds(c2, ctx)
            }
            return true
        },
        gen.Int(), gen.Int(), gen.Int(),
    ))
    props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}

That full loop from datatype –> predicate –> lemma –> verify –> implement –> test takes maybe 20 minutes for a new constraint type. The result ships with a mathematical proof that the Go implementation matches the specification.


The Complete Pipeline

The workflow in the companion repo ties everything together:

Running the full pipeline:

# Step 1: Verify formal specs
$ make verify-dafny
[DAFNY] Verifying rbac_types.dfy...              ? PASS
[DAFNY] Verifying rbac_policy.dfy...             ? PASS
[DAFNY] Verifying rbac_role_hierarchy.dfy...     ? PASS
[DAFNY] Verifying rbac_separation_of_duty.dfy... ? PASS
[DAFNY] Verifying rbac_constraints.dfy...        ? PASS
[DAFNY] Verifying rbac_invariants.dfy...         ? PASS
All 6 Dafny files verified successfully.

# Step 2: Model check concurrent protocol
$ make check-tla
[TLA+] Model checking RBACPolicyChange...
Model checking completed. No error has been found.
  2349 states generated, 1046 distinct states found.

# Step 3: Run property-based tests
$ make test
+ empty constraint list always passes: OK, passed 200 tests.
+ time window boundary conditions: OK, passed 500 tests.
+ constraint monotonicity: OK, passed 300 tests.
+ quota monotonicity: OK, passed 300 tests.
+ own claims in transitive closure: OK, passed 200 tests.
+ adding parent never removes claims: OK, passed 200 tests.
+ unknown principal always denied: OK, passed 200 tests.
+ no roles always denied: OK, passed 200 tests.
PASS — 2300+ property-based test cases executed.

# Step 4: Smoke-test the API
$ make run &
Server starting on :9090...

# alice can read docs (viewer role, time window 9-17, currently hour 10)
$ curl -s localhost:9090/evaluate -d '{
  "org_id":"acme", "principal_id":"alice",
  "action":"read", "resource_id":"docs",
  "hour":10, "region":"us-east-1", "usage":0
}' | jq .decision
"Allow"

# alice denied at 10pm — time constraint blocks access outside 9-17
$ curl -s localhost:9090/evaluate -d '{
  "org_id":"acme", "principal_id":"alice",
  "action":"read", "resource_id":"reports",
  "hour":22, "region":"us-east-1", "usage":0
}' | jq .decision
"Deny"

# bob can write docs from US (editor role + geo constraint us-east-1)
$ curl -s localhost:9090/evaluate -d '{
  "org_id":"acme", "principal_id":"bob",
  "action":"write", "resource_id":"docs",
  "hour":12, "region":"us-east-1", "usage":0
}' | jq .decision
"Allow"

# bob denied from EU — geo constraint blocks non-US regions
$ curl -s localhost:9090/evaluate -d '{
  "org_id":"acme", "principal_id":"bob",
  "action":"write", "resource_id":"docs",
  "hour":12, "region":"eu-west-1", "usage":0
}' | jq .decision
"Deny"

# SoD blocks carol (finance role) from also being submitter
$ curl -s localhost:9090/principals/carol/roles -d '{"role_id":"submitter"}'
{"error":"separation of duty: role \"submitter\" conflicts with existing role \"finance\""}

Who Writes the Specs? The Human-AI Division

This is the question I get asked most. Here’s how I answer it.

Humans must own:

  • What the invariants are, e.g., SoD, tenant isolation, deny-by-default, referential integrity
  • What the formal properties mean in the problem domain
  • Reviewing counterexamples from the verifier and refining specs accordingly
  • Architecture decisions: which tool for which problem, which 20% of the codebase to verify

AI can assist:

  • Dafny syntax, e.g.,LLMs generate valid Dafny from English property descriptions, as the TLA+ for the LLM era article demonstrates for TLA+
  • Boilerplate Go translated from Dafny type definitions
  • Test scaffolding for gopter properties
  • Translating Dafny lemmas into property test outlines
  • Generating loop invariants and decreases clauses from BFS/iteration patterns the LLM recognizes

The feedback loop in practice:

  1. Human writes ValidStore capturing tenant isolation
  2. AI generates the AddPrincipal method in Go
  3. Dafny verifies the spec — or produces a counterexample
  4. If counterexample: human understands the bug (usually a missed invariant direction), refines the spec
  5. AI regenerates from the refined spec
  6. Repeat until proof succeeds

Marc Brooker’s analysis of what AI agents find easy versus hard makes the point precisely: agents succeed on tasks with good automated feedback and struggle on tasks without it. Formal verification is that feedback as it is mathematical, precise, and automatable. It turns the review loop into a tight iteration between human specifier and verifier, rather than a bottleneck where an engineer reads 1,000 lines of plausible AI code hoping to spot a subtle invariant violation. This directly counters the “cut review to go faster” argument: you don’t cut review instead you replace line-by-line code review with specification review, which is faster, higher leverage, and catches the bugs that matter.


When to Apply Formal Verification

Not every line of code needs formal verification. Here’s how I decide where to apply it:

Apply formal verificationSkip it
Authorization and access controlUI rendering logic
Cryptographic protocolsCRUD boilerplate
Distributed consensusSimple data transformations
Financial calculationsUser-facing text content
Schema migration validatorsLogging and metrics
Safety-critical state machinesConfiguration defaults

The pattern: apply where bugs are expensive like security, correctness, data integrity and where the specification can be stated mathematically. For the critical 20% of a codebase where correctness failures are severe, formal verification pays for itself on the first prevented production incident. For the other 80%, tests and code review are the right tools. This targeting also addresses the organizational pressure argument directly. You don’t need to formally verify everything, which would be impractical. You verify the parts where the cost of being wrong is highest. That’s a defensible, scoped investment that produces measurable risk reduction.


Getting Started with Dafny: Three Steps

  • Step 1: Start with types. Write ValidXxx predicates before writing any methods. This forces you to articulate what “correct state” means before writing code that’s supposed to produce it. The predicates are small, incremental, and require no theorem-proving expertise. AI can bootstrap this step. LLMs are now quite capable at generating Dafny precondition/postcondition stubs from English property descriptions. See dafny-annotator: AI-Assisted Verification of Dafny Programs.
  • Step 2: Add contracts to one critical method. Pick the authorization check. Add requires/ensures. Let Dafny fail. Understand why. Add lemmas. The first proof is the hardest; subsequent ones follow the same pattern.
  • Step 3: Mirror each lemma with a property test. This catches translation bugs in the Go implementation and keeps spec and code in sync as the system evolves.
// Dafny lemma — proved by the verifier
lemma EmptyConstraintsAlwaysHold(ctx: EvalContext)
  ensures AllConstraintsHold([], ctx)
{}
// Matching gopter property — catches Go translation bugs
props.Property("empty constraint list always passes", prop.ForAll(
    func(hour int, region string) bool {
        ctx := types.EvalContext{CurrentHour: hour % 24, CurrentRegion: region}
        return constraints.AllHold(nil, ctx)
    },
    gen.Int(), gen.AnyString(),
))

Conclusion: You Can’t Outsource Thinking

The AI era has come full circle. In the 1980s, AI meant logic like Prolog, expert systems, formal inference. The 2020s flipped to probabilistic: statistical token prediction that generates plausible code at extraordinary speed. But plausible was never the goal. Correct is the goal. And correctness was always logic’s domain.

The Bertrand Meyer’s article From Probable to Provable captures the shift precisely: the engineering role moves from writing code to writing specifications. From debugging via console.log to managing verification pipelines. From reviewing AI-generated code line by line to reviewing the specs the verifier checks against.

The division of labor looks like this:

LayerOwnerActivity
SpecificationHuman engineerDefines invariants, contracts, correctness properties
Code generationAI (LLM)Produces candidate implementations fast
VerificationFormal tools (Dafny, TLA+, Z3)Proves or refutes correctness mathematically
TestingProperty-based + fuzzCatches translation bugs between spec and implementation
ReviewHuman engineerReviews counterexamples, refines specifications

This is the answer to the organizational pressure to skip review, reduce verification, and just ship. The pressure comes from observing 10× code output and concluding that verification overhead is blocking throughput. The data says the opposite: organizations that remove verification to increase throughput move from the valley of calm to the plateau of misery. They ship more code with lower reliability. The pipeline stalls.

Formal verification, applied selectively to the critical 20% of your codebase, keeps the defect rate low enough that the rest of the pipeline flows. It shifts human effort from reading AI-generated code line by line to writing the specifications that make wrong implementations immediately visible. That’s a higher-leverage use of engineering time and a better argument to make to management than “we need more review bandwidth.”

The tools are practical today:

  • Dafny verifies all 6 RBAC specification files in under 30 seconds on a laptop
  • TLC model-checks the concurrent update protocol in under a second
  • gopter runs 2,300+ property tests in under a second
  • Total upfront overhead: roughly 20% more time spent writing specs rather than debugging production

Mager’s valley of calm stays wide when your defect rate stays low. Formal verification is the most effective tool I’ve found for keeping it there.

Everything in this post runs from the companion repository: github.com/bhatti/automated-reasoning.


References

  1. AWS: An Unexpected Discovery – Automated Reasoning Often Makes Systems More Efficient
  2. CACM: Systems Correctness Practices at Amazon Web Services
  3. CACM: AI for Software Engineering – From Probable to Provable
  4. Dafny: Teaching Program Verification at Amazon
  5. Galois: Automated Lean Proofs for Every Type
  6. Jane Street: Formal Methods
  7. Brooker: What’s Easy, What’s Hard for AI Agents
  8. Mager: The Valley of Calm
  9. Mager: The New Calculus of AI-Based Coding
  10. AI Writes Code, You Own the Design
  11. Beyond Vibe Coding – TLA+ with Claude
  12. Contract Testing for REST APIs
  13. TLA+ for the LLM Era
  14. Use Prolog to Improve LLM Reasoning
  15. Google OR-Tools CP-SAT for Scheduling
  16. you-got-skills: SDLC Skills for AI-Assisted Development
  17. ProVerB: Program Verification Book
  18. Loughridge et al. “DafnyBench: A Benchmark for Formal Software Verification.”

June 17, 2026

Building a Self-Improving AI Agent with Durable Actors: MiniHermes

Filed under: Agentic AI — admin @ 8:25 pm

What Is Hermes Agent?

Hermes Agent from Nous Research is very capable open agent that centers on three ideas that reinforce each other:

  • Structured system prompt with function-calling discipline. The system prompt teaches the model when to call a tool versus when to answer directly, how to format tool inputs as JSON, and how to interpret results and loop forward. The model learns that end_turn means the task is finished. This discipline makes Hermes far more reliable than agents running open-ended prompts.
  • Multi-step tool loop. After each LLM response, the agent checks: did the model request a tool? If yes, execute it, append the result, and call the LLM again up to a configured limit. This is what lets Hermes chain steps like “search –> read –> summarise” without the user driving each step by hand.
  • Self-critique and skill accumulation. After a complex task, Hermes reflects on the conversation and extracts a reusable skill, a named, structured description of the steps it took. The next time it encounters a similar request, it injects that skill into context and executes faster, without re-discovering the procedure from scratch.

These three properties make Hermes genuinely useful. But the reference implementation is a monolithic Python process. One crash loses every in-flight session. There is no distribution, no tenant isolation, no scheduled automation, and no provider failover. It is excellent research code and a fragile foundation for anything beyond a single-user demo.

MiniHermes keeps all three Hermes ideas and rebuilds the execution model on PlexSpaces, an actor-based distributed runtime. The result compiles to a single WASM binary, runs 12 actors under supervision, and adds durable state, fault isolation, distributed cron, context compression, and guardrails without changing how the core agent loop reasons.


The Problem: Stateless vs. Stateful Monolith

Most AI agents fall into one of two camps, and both have real problems.

  • Stateless agents are easy to deploy but forget everything between requests. You can’t reuse a procedure the agent learned last Tuesday. You can’t track that the user prefers metric units. Every conversation starts from zero. The workarounds like external caches, vector stores turn the agent into infrastructure glue rather than an intelligent system.
  • Stateful monoliths like the Hermes reference implementation go the other direction: one process owns everything. That’s clean for development, but fragile under load. When the process crashes, every active session vanishes. A bug in skill extraction can corrupt the memory that session management depends on.

The actor model offers a third path. Decompose the system into many small actors, each owning exactly one responsibility, communicating only through messages. When one crashes, the supervisor restarts just that actor. The others keep running.


PlexSpaces Primitives

Before walking through the actors, it helps to understand the primitives every actor has access to inside the WASM sandbox. These are the only operations available, no filesystem, no global state, no raw sockets. This constraint is deliberate: it is part of what makes the system auditable and safe.

KV: Durable Point Lookup

# Persist and restore session history across restarts
host.kv_put(f"session_history:{session_id}", json.dumps(messages))
raw = host.kv_get(f"session_history:{session_id}")
messages = json.loads(raw) if raw else []

KV stores anything keyed by an exact string: session history, skill metadata, cron job state, provider configuration. The durability facet checkpoints it automatically, so a restarted actor picks up exactly where it left off.

TupleSpace: Pattern-Matched Coordination

TupleSpace is not KV. Rather than point lookups, it supports wildcard queries:

# Index a skill under multiple trigger keywords
host.ts.write(["skill_trigger", "csv",         "skill-001"])
host.ts.write(["skill_trigger", "spreadsheet", "skill-001"])
host.ts.write(["skill_trigger", "pivot",       "skill-001"])

# Find every skill that might match — None is a wildcard
all_triggers = host.ts.read_all(["skill_trigger", None, None])
# ? [["skill_trigger","csv","skill-001"], ["skill_trigger","spreadsheet","skill-001"], ...]

# Audit log: all events of a specific type
events = host.ts.read_all(["audit", "tool_executed", None, None])

# Health snapshots: last N polls
snapshots = host.ts.read_all(["health_snapshot", None, None])

TupleSpace powers skill indexes, memory tiers, audit logs, and health snapshots, anything where you scan across many entries rather than fetching one by ID.

Design tradeoff. TupleSpace pattern matching scales well for hundreds to thousands of entries but is not a replacement for a vector database or SQL at large scale. For this POC it removes an external dependency entirely; a production system with millions of skills would add an embedding-based index alongside it.

BlobStorage: Large, Opaque Content

# Skill procedures can be several paragraphs — too large for KV values
host.blob.upload(f"skill_procedure_{skill_id}", procedure_text.encode())
procedure = host.blob.download(f"skill_procedure_{skill_id}").decode()

BlobStorage handles the full procedure text that would be awkward as a KV value and wasteful to pass in message payloads.

Channel: At-Least-Once Delivery

# Cron scheduler enqueues a job
host.channel.send("", "cron:pending", "cron_job", job_payload)

# Agent receives, processes, then acks — message redelivered if agent crashes before ack
msg, ok, _ = host.channel.receive("", "cron:pending", timeout_ms=5000)
if ok:
    # ... process the job ...
    host.channel.ack("", "cron:pending", msg["msg_id"])
    # or: host.channel.nack("", "cron:pending", msg["msg_id"], True)  # requeue

Channel provides the durability that host.send() does not. If the consuming actor crashes between receive and ack, the message is redelivered on restart. This is what makes recurring tasks survive node failures without a separate message broker.

DistributedLock: Cluster-Wide Leader Election

// Go — CronSchedulerActor.tick()
// TryAcquire returns false immediately if another node holds the lock
// TTL of 90s is longer than the 60s tick interval, preventing gaps
acquired, _ := host.Lock().TryAcquire("minihermes", "cron_leader", 90000)
if !acquired {
    return // another node is the leader this cycle
}
// Safe to fire jobs — only this node runs this block right now

Without DistributedLock, every node in a three-node cluster would fire every cron job simultaneously. The lock ensures exactly one leader schedules per tick.

SendAfter: Actor-Managed Timers

@init_handler
def on_init(self, config: dict) -> None:
    host.process_groups.join("svc:health_monitor")
    # Arm the first tick — no external cron daemon needed
    host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

@handler("poll_tick", "cast")
def poll_tick(self) -> None:
    # ... do poll work ...
    # Re-arm: each tick schedules the next
    host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

send_after replaces external schedulers for periodic work inside an actor. The actor manages its own timeline.

Ask vs. Send: Request-Reply vs. Fire-and-Forget

# host.ask() — blocks until a response arrives (or timeout)
llm_resp = host.ask(llm_id, "completion",
                    {"messages": messages, "tools": tools},
                    timeout_ms=30000)

# host.send() — returns immediately, caller never waits
host.send(audit_id, "log_event",
          {"event_type": "tool_executed", "detail": f"tool={name}"})

This distinction matters for latency. Audit events and async skill learning always use send(). The calling actor never waits for them. LLM completions and tool results use ask() because the outcome is needed before continuing.

IncrCounter: Lightweight Metrics

# Increment a named counter — visible to monitoring without any external metrics system
host.incr_counter("llm_completions_total", 1)
host.incr_counter("tool_executions_total", 1)
host.incr_counter(f"tool_{name}_total", 1)
host.incr_counter("skill_matches_total", len(matched_ids))

Every key operation in MiniHermes emits a counter. Aggregated across actors, these give a metrics dashboard without Prometheus or a separate telemetry pipeline.


MiniHermes Architecture

MiniHermes consists of 12 actors and compiles to a single WASM binary. The PlexSpaces supervisor boots 12 actors from it at startup, each with its own state, crash domain, and message contract.

The four actor behaviors map to four different runtime contracts:

BehaviorActorsWhat It Provides
GenServerAgent, LLM, Tools, Skills, Memory, Compressor, Cron, Session, HealthSynchronous request-reply with durable state
GenFSMGuardrailsGateValidated state machine and invalid transitions are rejected at runtime
GenEventAuditEventFire-and-forget event delivery; callers never block
WorkflowSkillExtractionWorkflowDurable multi-step execution with per-step checkpoints and cancel/query signals

Fault isolation. A bug in SkillStoreActor cannot corrupt AgentActor‘s session history. If SkillExtractionWorkflow crashes mid-extraction, it resumes from its last checkpoint without restarting the conversation. The one_for_one supervisor strategy restarts only the failed actor; everything else keeps running.

# app-config.toml
[supervisor]
strategy = "one_for_one"           # restart ONLY the crashed child
max_restarts = 10
max_restart_window_seconds = 60    # if 10 crashes in 60s, escalate to parent supervisor

Latency tradeoff. Each actor boundary costs one ask() call instead of an in-process function call. For an LLM agent this is negligible as LLM round-trips dominate at 100ms to 10s. The isolation and recoverability benefits far outweigh the sub-millisecond message overhead.


The Supervisor Tree and the Let-It-Crash Philosophy

Monolithic agent frameworks force every developer to write defensive error handling around every tool call, every LLM request, every memory write. MiniHermes takes the Erlang philosophy instead: let actors crash, and let supervisors restart them in a clean state.

When ToolExecutorActor crashes due to a bad tool payload, a timeout, or a WASM trap, the supervisor restarts it with clean state. The AgentActor‘s in-flight request receives a timeout error and can retry. Every other actor continues running. The audit trail, the cron scheduler, the skill store, the LLM gateway, none of them know a crash happened.

This is the opposite of a monolith, where one bad tool call can corrupt the process heap and take the entire agent down.


Security: WASM, Firecracker, and Actor Isolation

Security in MiniHermes comes from three concentric layers, not from application-level checks.

  • Layer 1 Actor message isolation. Each actor owns its state exclusively. No shared memory, no global variables. Communication happens only through host.ask() and host.send(). Even if a prompt injection tricks AgentActor into misbehaving, it cannot read LLMGatewayActor‘s stored API credentials or SkillStoreActor‘s procedure data as those live in separate actor state.
  • Layer 2 WASM linear memory sandbox. Every actor compiles to a WebAssembly module. The WIT (WebAssembly Interface Types) definition explicitly lists every operation the actor can call:
// wit/plexspaces-actor/host.wit
// Actors can ONLY call these imports — nothing else is accessible
interface host {
    send:       func(to: string, msg-type: string, payload: payload) -> result<_, actor-error>;
    ask:        func(to: string, msg-type: string, payload: payload, timeout-ms: u64) -> result<payload, actor-error>;
    kv-get:     func(key: string) -> result<payload, actor-error>;
    kv-put:     func(key: string, value: payload) -> result<_, actor-error>;
    http-fetch: func(link-name: string, method: string, path: string, request: payload) -> result<payload, actor-error>;
    ts-write:   func(tuple: list<string>) -> result<_, actor-error>;
    ts-read-all:func(pattern: list<option<string>>) -> result<list<list<string>>, actor-error>;
    // No filesystem. No env vars. No raw network. No process exec.
}

A malicious tool payload cannot exfiltrate environment variables or write to the filesystem because those syscalls do not exist in the WASM environment.

  • Layer 3 Firecracker. In a production deployment, each WASM runtime runs inside a Firecracker microVM, a lightweight KVM-based hypervisor that provides hardware-enforced memory and I/O isolation between tenants. A compromise in one tenant’s actor cannot affect another tenant’s data or execution even if the WASM sandbox were bypassed.

Tenant isolation. Every PlexSpaces operation propagates tenant context automatically. KV keys, TupleSpace tuples, process groups, and object registry entries are all scoped by tenant and namespace:

# Framework-enforced key scoping — no application code can bypass this
KV:          tenant-acme:prod:session_history:sess-001
TupleSpace:  tenant-acme:prod:["skill_trigger", "csv", "skill-001"]
PG:          tenant-acme:prod:svc:agent

Tenant acme cannot retrieve a session belonging to tenant globex. The framework rejects the request before it reaches any actor.


The Agent Loop

AgentActor drives the core conversation. When it receives a chat message, here is the full sequence:

User: "calculate 42 * 17 and remember the result"

  1. Restore session history from KV (survives restarts)
  2. Ask ContextCompressorActor: token budget > 75%?
     --> Yes: summarize the middle, keep the recent tail, archive original
  3. Ask SkillStoreActor: known procedures for "calculate" + "memory_store"?
     --> Found: inject skill into system prompt
  4. Ask ToolExecutorActor: list current tool schemas
  5. LOOP (max 8 iterations):
     a. Ask LLMGatewayActor: complete with these messages + tools
     b. stop_reason = tool_use:
        --> GuardrailsGate.check("calculator")   --> allow
        --> ToolExecutor.execute("calculator", {expr: "42*17"}) --> {result: 714}
        --> GuardrailsGate.check("memory_store") --> allow
        --> ToolExecutor.execute("memory_store", {key: "last_calc", value: "714"})
        --> Append results; continue loop
     c. stop_reason = end_turn --> break
  6. KV.put("session_history:sess-001", messages)   --  durable checkpoint
  7. send (fire-and-forget): SkillStoreActor.evaluate_for_learning
  8. send (fire-and-forget): AuditEventActor.log_event
  == "42 × 17 = 714. I've stored the result in your memory."

The Python implementation:

@actor
class AgentActor:
    system_prompt: str = state(default="You are a helpful AI assistant with access to tools.")
    messages: list     = state(default_factory=list)
    max_iterations: int = state(default=8)
    token_budget: int   = state(default=4096)

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        self.system_prompt = args.get("system_prompt", self.system_prompt)
        host.process_groups.join("svc:agent")
        # Publish capabilities for registry-based discovery
        host.registry.register(ctx="", object_type="actor", object_id=config["actor_id"],
                                object_category="agent",
                                capabilities=["chat", "tool_use", "memory"])

    @handler("chat")
    def chat(self, message: str = "", session_id: str = "") -> dict:
        # 1. Restore durable session
        if session_id:
            raw = host.kv_get(f"session_history:{session_id}")
            if raw:
                self.messages = json.loads(raw)
        self.messages.append({"role": "user", "content": message})

        # 2. Compress if over token budget
        comp_id, _ = pg_first("svc:context_compressor")
        if comp_id:
            resp = ask(comp_id, "check_and_compress",
                       {"messages": self.messages, "token_budget": self.token_budget})
            if resp and resp.get("compressed"):
                self.messages = resp["messages"]

        # 3. Inject matching skills
        skill_id, _ = pg_first("svc:skill_store")
        skill_context = ""
        if skill_id:
            resp = ask(skill_id, "match_skills", {"query": message})
            if resp and resp.get("skills"):
                skill_context = self._format_skills(resp["skills"])

        # 4. Get live tool schemas
        tool_exec_id, _ = pg_first("svc:tool_executor")
        tools = []
        if tool_exec_id:
            resp = ask(tool_exec_id, "list_tools", {})
            tools = resp.get("tools", []) if resp else []

        system = self.system_prompt
        if skill_context:
            system += f"\n\n## Relevant Skills\n{skill_context}"

        # 5. The tool loop — max_iterations prevents runaway execution
        final_response = ""
        for iteration in range(self.max_iterations):
            llm_id, _ = pg_first("svc:llm_gateway")
            llm_resp = ask(llm_id, "completion",
                           {"messages": [{"role": "system", "content": system}] + self.messages,
                            "tools": tools},
                           timeout_ms=30000)

            response    = llm_resp.get("response", {})
            stop_reason = response.get("stop_reason", "end_turn")
            self.messages.append({"role": "assistant",
                                   "content": response.get("content", ""),
                                   "stop_reason": stop_reason})

            if stop_reason == "end_turn":
                final_response = response.get("content", "")
                break

            if stop_reason == "tool_use":
                guard_id, _ = pg_first("svc:guardrails")
                for tc in response.get("tool_calls", []):
                    # Every tool call clears the guardrail first
                    if guard_id:
                        check = ask(guard_id, "check_tool",
                                    {"tool_name": tc["name"], "input": tc["input"]})
                        if check and check.get("decision") == "deny":
                            self.messages.append({"role": "tool",
                                                   "content": f"[denied: {tc['name']}]"})
                            continue
                    result = ask(tool_exec_id, "execute",
                                 {"name": tc["name"], "input": tc["input"]})
                    self.messages.append({"role": "tool",
                                          "tool_call_id": tc["id"],
                                          "content": json.dumps(result)})
                    host.send(audit_id, "log_event",
                              {"event_type": "tool_executed",
                               "detail": f"tool={tc['name']} session={session_id}"})
                    host.incr_counter("tool_executions_total", 1)

        # 6. Checkpoint session — durable across restarts
        if session_id:
            host.kv_put(f"session_history:{session_id}", json.dumps(self.messages))

        # 7+8. Async learning and audit — never block the response
        if skill_id:
            host.send(skill_id, "evaluate_for_learning",
                      {"messages": self.messages, "user_intent": message})
        host.incr_counter("agent_chats_total", 1)
        return {"status": "ok", "response": final_response, "session_id": session_id}

Step 7 uses host.send(), not host.ask(). Skill learning never adds latency to the response, it happens in the background while the user reads the answer.


The LLM Gateway: Hot-Swap and Circuit Breaker

LLMGatewayActor is the single point through which all LLM calls flow. It can switch providers at runtime without restarting, and it protects downstream actors from a flaky provider with a built-in circuit breaker.

# Switch from Ollama to Anthropic — takes effect immediately, no restart
curl -X POST http://localhost:8091/api/v1/actors/llm_gateway/switch_provider \
  -d '{"provider":"anthropic","model":"claude-opus-4-8"}'

# Or to OpenAI
curl -X POST http://localhost:8091/api/v1/actors/llm_gateway/switch_provider \
  -d '{"provider":"openai","model":"gpt-4o"}'

The circuit breaker lives in the actor’s durable state, it survives restarts:

@actor
class LLMGatewayActor:
    provider:              str  = state(default="ollama")
    model:                 str  = state(default="llama3.2")
    circuit_open:          bool = state(default=False)
    consecutive_failures:  int  = state(default=0)
    total_completions:     int  = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:llm_gateway")
        host.send_after(30_000, "timer_tick", {"op": "timer_tick"})

    @handler("completion")
    def completion(self, messages: list = None, tools: list = None) -> dict:
        if self.circuit_open:
            # Fail fast — don't queue work behind a broken provider
            return {"status": "ok", "response": self._simulated_response(),
                    "circuit_open": True}
        try:
            result = self._call_provider(messages or [], tools or [])
            self.consecutive_failures = 0
            self.total_completions += 1
            host.incr_counter("llm_completions_total", 1)
            return {"status": "ok", "response": result}
        except Exception as e:
            self.consecutive_failures += 1
            if self.consecutive_failures >= 3:
                self.circuit_open = True
                host.warn(f"LLM circuit opened after {self.consecutive_failures} failures")
                host.incr_counter("llm_circuit_opens_total", 1)
            return {"error": str(e), "response": self._simulated_response()}

    @handler("timer_tick", "cast")
    def timer_tick(self) -> None:
        # Gradual recovery: one fault cleared per 30s tick
        # 3 faults ? 90s before circuit closes again — prevents flapping
        if self.circuit_open and self.consecutive_failures > 0:
            self.consecutive_failures -= 1
            if self.consecutive_failures == 0:
                self.circuit_open = False
                host.info("LLM circuit closed — provider available again")
        host.send_after(30_000, "timer_tick", {"op": "timer_tick"})

    @handler("switch_provider")
    def switch_provider(self, provider: str = "", model: str = "") -> dict:
        self.provider = provider
        self.model    = model
        # Switching resets the circuit — assume the new provider is healthy
        self.circuit_open         = False
        self.consecutive_failures = 0
        return {"status": "ok", "provider": provider, "model": model}

    def _call_provider(self, messages: list, tools: list) -> dict:
        if self.provider == "ollama":
            resp = host.http_fetch("ollama", "POST", "/api/chat",
                                   {"model": self.model, "messages": messages, "stream": False})
        elif self.provider == "anthropic":
            resp = host.http_fetch("anthropic", "POST", "/v1/messages",
                                   {"model": self.model, "messages": messages,
                                    "tools": tools, "max_tokens": 4096})
        elif self.provider == "openai":
            resp = host.http_fetch("openai", "POST", "/v1/chat/completions",
                                   {"model": self.model, "messages": messages, "tools": tools})
        return self._normalize(resp)

Every provider response normalizes to the same format before leaving the gateway:

{
  "content":    "42 × 17 = 714",
  "stop_reason": "end_turn",
  "tool_calls": [],
  "usage":      {"input_tokens": 112, "output_tokens": 18}
}

AgentActor doesn’t knows which provider answered and switching providers is transparent to the rest of the system.

Design tradeoff. The circuit breaker in this POC uses a simple failure count threshold. A production implementation would add per-provider backoff, budget caps, and latency-based degradation.


Skill Learning: The Self-Improvement Loop

This is what separates MiniHermes from every standard agent loop. When the agent uses three or more tools in a single turn, it asynchronously extracts a reusable skill. The next time the user asks something similar, the agent injects that skill into the system prompt and skips the re-discovery phase entirely.

The Durable Extraction Workflow

SkillExtractionWorkflow uses the @workflow_actor behavior, which checkpoints state after each step. A node crash during step 2 of 3 resumes from step 2, not the beginning:

@workflow_actor
class SkillExtractionWorkflow:

    @run_handler
    def run(self, payload: dict = None) -> dict:
        user_intent  = payload.get("user_intent", "")
        tool_sequence = payload.get("tool_sequence", [])
        domain        = payload.get("domain", "general")
        llm_id        = payload.get("llm_id", "")

        # Three focused LLM passes — each optimizes for a different extraction goal.
        # Python runs them sequentially (shared LLM budget).
        # Go runs them in true parallel goroutines for lower latency.
        name_result      = self._analyse_name(llm_id, user_intent, tool_sequence)
        # ? workflow checkpoints here; crash-safe from this point

        procedure_result = self._analyse_procedure(llm_id, user_intent, tool_sequence)
        # ? checkpoint

        trigger_result   = self._analyse_triggers(llm_id, user_intent, domain)
        # ? checkpoint

        skill_id = f"skill-{host.now_ms()}"
        skill_store_id, _ = pg_first("svc:skill_store")
        if skill_store_id:
            ask(skill_store_id, "propose_skill", {
                "skill_id":        skill_id,
                "name":            name_result.get("name", "unnamed-skill"),
                "description":     name_result.get("description", ""),
                "procedure":       procedure_result.get("procedure", ""),
                "tags":            trigger_result.get("tags", []),
                "trigger_patterns": trigger_result.get("patterns", []),
            })
        return {"status": "ok", "skill_id": skill_id}

    @signal_handler("cancel")
    def cancel(self) -> None:
        # In-flight extraction can be cancelled without crashing the actor
        host.info("SkillExtraction cancelled")

    @query_handler("status")
    def query_status(self) -> dict:
        return {"task_id": self.task_id, "status": self.status, "progress": self.progress}

Three Storage Layers for Three Access Patterns

@handler("propose_skill")
def propose_skill(self, skill_id: str = "", name: str = "",
                  description: str = "", procedure: str = "",
                  tags: list = None, trigger_patterns: list = None) -> dict:

    # KV: metadata — fast exact-key lookup when the ID is known
    meta = {"skill_id": skill_id, "name": name, "description": description,
            "status": "active", "usage_count": 0,
            "created_at": host.now_ms(), "last_used_at": host.now_ms()}
    host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))

    # BlobStorage: full procedure text — potentially several paragraphs
    host.blob.upload(f"skill_procedure_{skill_id}", procedure.encode())

    # TupleSpace: keyword indexes — pattern scan at query time, no SQL needed
    for tag in (tags or []):
        host.ts.write(["skill_tag", tag, skill_id, name])
    for pattern in (trigger_patterns or []):
        host.ts.write(["skill_trigger", pattern, skill_id])

    host.incr_counter("skills_created_total", 1)
    return {"status": "ok", "skill_id": skill_id}

Why three layers? KV answers “give me skill X” in O(1). TupleSpace answers “which skills match this query?” without an index build step. BlobStorage keeps large procedure text out of both KV values and message payloads.

Skill Matching at Query Time

@handler("match_skills")
def match_skills(self, query: str = "") -> dict:
    query_words = set(query.lower().split())

    # Scan all trigger entries — None is a wildcard
    all_triggers = host.ts.read_all(["skill_trigger", None, None])

    matched_ids = set()
    for tpl in all_triggers:
        pattern = tpl[1].lower()
        if pattern in query_words or any(w in pattern for w in query_words):
            matched_ids.add(tpl[2])

    skills = []
    for skill_id in matched_ids:
        meta_json = host.kv_get(f"skill_meta:{skill_id}")
        if not meta_json:
            continue
        meta = json.loads(meta_json)
        if meta.get("status") != "active":
            continue
        # Load the full procedure only for matched, active skills
        meta["procedure"] = host.blob.download(f"skill_procedure_{skill_id}").decode()
        skills.append(meta)
        # Track usage for lifecycle decisions
        meta["usage_count"]    += 1
        meta["last_used_at"]   = host.now_ms()
        host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))

    host.incr_counter("skill_matches_total", len(skills))
    return {"status": "ok", "skills": skills}

Skills Age Out Automatically

Skills that go unused for 30 days transition to stale. After 90 more days they become archived. A daily send_after tick drives this, no external scheduler:

@handler("timer_tick", "cast")
def timer_tick(self) -> None:
    now             = host.now_ms()
    thirty_days_ms  = 30 * 24 * 60 * 60 * 1000
    ninety_days_ms  = 90 * 24 * 60 * 60 * 1000

    all_tags = host.ts.read_all(["skill_tag", None, None, None])
    seen     = set()
    for t in all_tags:
        skill_id = t[2]
        if skill_id in seen:
            continue
        seen.add(skill_id)
        meta_json = host.kv_get(f"skill_meta:{skill_id}")
        if not meta_json:
            continue
        meta = json.loads(meta_json)
        age  = now - meta.get("last_used_at", now)
        if meta["status"] == "active" and age > thirty_days_ms:
            meta["status"] = "stale"
            host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))
        elif meta["status"] == "stale" and age > ninety_days_ms:
            meta["status"] = "archived"
            host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))

    host.send_after(24 * 60 * 60 * 1000, "timer_tick", {"op": "timer_tick"})
active  --> (30 days unused) -->  stale  --> (90 more days) -->  archived

This prevents the skill store from accumulating noise from one-off tasks that will never recur.


Memory: Three Tiers, One Actor

MemoryActor manages three memory tiers with different durability and retrieval characteristics. The Hermes reference implementation stores facts in flat files; MiniHermes uses KV + TupleSpace + BlobStorage, with each tier mapped to a storage layer.

@actor
class MemoryActor:
    memory_count: int = state(default=0)

    @handler("store_memory")
    def store_memory(self, key: str = "", value: str = "",
                     scope: str = "global", tier: str = "reachable",
                     agent_id: str = "", session_id: str = "") -> dict:
        if not key:
            return {"error": "key required"}
        scoped_key = self._scoped_key(scope, agent_id, session_id, key)

        if tier == "deep":
            # BlobStorage: large, rarely needed, not scanned by default
            host.blob.upload(f"deep_memory_{scoped_key}", value.encode())
        else:
            # KV: durable point lookup
            host.kv_put(scoped_key, str(value))

        # TupleSpace index: queryable by scope and tier regardless of storage layer
        host.ts.write(["memory", scope, tier, key, str(value)[:64]])
        self.memory_count += 1
        return {"status": "ok", "key": key, "scope": scope, "tier": tier}

    @handler("recall_memory")
    def recall_memory(self, key: str = "", scope: str = "global",
                      agent_id: str = "", session_id: str = "") -> dict:
        scoped_key = self._scoped_key(scope, agent_id, session_id, key)
        value = host.kv_get(scoped_key)
        if not value:
            # Try deep tier
            try:
                value = host.blob.download(f"deep_memory_{scoped_key}").decode()
            except Exception:
                pass
        return {"status": "ok", "key": key, "value": value, "found": bool(value)}

    @handler("list_memories")
    def list_memories(self, scope: str = "global", tier: str = None) -> dict:
        pattern = ["memory", scope, tier or None, None, None]
        tuples  = host.ts.read_all(pattern)
        memories = [{"key": t[3], "value": t[4], "tier": t[2]}
                    for t in tuples if len(t) >= 5]
        return {"status": "ok", "memories": memories, "count": len(memories)}

    def _scoped_key(self, scope: str, agent_id: str, session_id: str, key: str) -> str:
        if scope == "agent"   and agent_id:   return f"mem:agent:{agent_id}:{key}"
        if scope == "session" and session_id: return f"mem:session:{session_id}:{key}"
        return f"mem:global:{key}"

The three scopes (global, agent, session) determine which facts survive which boundaries: session memories disappear with the session, agent memories persist across sessions, global memories are shared across all agents.


Distributed Cron: Recurring Tasks That Survive Node Failures

You may need to run “summarize my tasks every morning” request. Making it work reliably across a cluster requires solving three problems at once: who fires the job when there are three nodes, what happens if the firing node crashes mid-delivery, and how do you prevent duplicate execution? MiniHermes solves all three with two primitives:

// Go — CronSchedulerActor
func (a *CronSchedulerActor) tick() {
    // TryAcquire returns false immediately if another node holds the lock.
    // TTL of 90s exceeds the 60s tick interval, preventing leader gaps.
    acquired, _ := host.Lock().TryAcquire("minihermes", "cron_leader", 90000)
    if !acquired {
        return // another node leads this cycle — nothing to do
    }

    now := host.NowMs()
    for _, jobID := range a.JobIDs {
        job := a.loadJob(jobID)
        if now-job.LastRunAt >= job.IntervalMs {
            payload := map[string]interface{}{
                "job_id": job.JobID, "prompt": job.Prompt, "session_id": job.SessionID,
            }
            // Channel: at-least-once. If agent crashes before ack, job redelivers.
            host.Ch().Send("", "cron:pending", "cron_job", payload)
            job.LastRunAt = now
            a.saveJob(job)
        }
    }
}

The agent runs each cron job in an isolated session context so the job never bleeds into the user’s live conversation:

@handler("process_cron_job", "cast")
def process_cron_job(self, job_id: str = "", prompt: str = "",
                     session_id: str = "") -> None:
    cron_session = f"cron:{session_id}"

    # Stash the current interactive conversation
    saved_messages = self.messages[:]

    # Load the cron session's own history — completely separate from user sessions
    raw = host.kv_get(f"session_history:{cron_session}")
    self.messages = json.loads(raw) if raw else []

    self._run_agent_loop(prompt, tools=[])

    host.kv_put(f"session_history:{cron_session}", json.dumps(self.messages))
    self.messages = saved_messages  # restore user conversation

    host.send(audit_id, "log_event",
              {"event_type": "cron_executed", "detail": f"job_id={job_id}"})

Creating a recurring task takes one API call:

curl -X POST http://localhost:8091/api/v1/actors/cron_scheduler/create_job \
  -d '{
    "job_id":     "daily-digest",
    "prompt":     "Summarize today'\''s tasks and send a digest email",
    "schedule":   "every_24h",
    "session_id": "cron-digest"
  }'

Context Compression: Long Conversations Without Truncation

Every LLM agent eventually exceeds the model’s context window. The reference Hermes implementation truncates, it drops the oldest messages and loses context. MiniHermes compresses instead: ContextCompressorActor summarizes the middle of the conversation, keeps the recent tail intact, and archives the full original.

@handler("check_and_compress")
def check_and_compress(self, messages: list = None, token_budget: int = 4096) -> dict:
    messages        = messages or []
    estimated_tokens = sum(len(str(m)) // 4 for m in messages)

    if estimated_tokens < token_budget * 0.75:
        return {"compressed": False, "messages": messages}

    system_msgs  = [m for m in messages if m.get("role") == "system"]
    other_msgs   = [m for m in messages if m.get("role") != "system"]
    recent_count = max(4, len(other_msgs) // 3)
    middle       = other_msgs[:-recent_count]
    recent       = other_msgs[-recent_count:]

    if len(middle) < 2:
        return {"compressed": False, "messages": messages}

    # Archive the full original before compression — preserves audit trail
    if self.session_id:
        host.kv_put(f"full_history_archive:{self.session_id}", json.dumps(messages))

    llm_id, _ = pg_first("svc:llm_gateway")
    summary_resp = ask(llm_id, "completion", {
        "messages": [
            {"role": "system",
             "content": "Summarize this conversation history concisely. "
                        "Preserve key facts, tool results, and decisions."},
            {"role": "user", "content": json.dumps(middle)}
        ],
        "tools": []
    })

    summary_text = summary_resp.get("response", {}).get("content", "")
    summary_msg  = {"role": "assistant",
                    "content": f"[Conversation summary: {summary_text}]",
                    "is_summary": True}

    compressed = system_msgs + [summary_msg] + recent
    host.incr_counter("context_compressions_total", 1)
    return {"compressed": True, "messages": compressed,
            "original_count": len(messages), "compressed_count": len(compressed)}

Design tradeoff. LLM-based summarization costs tokens and adds latency to that one turn. The tradeoff is that the compressed context is semantically richer than simple truncation as the model retains the meaning of earlier turns, not just the most recent N messages. For a task-focused agent this matters: a calculation result from turn 3 is still relevant at turn 50.


Guardrails: Per-Tool Policy Enforcement Without Redeployment

GuardrailsGateActor implements a GenFSM that sits between every tool call and execution. Every call passes through it. Policies update at runtime via a single message — no redeploy, no restart.

@fsm_actor(states=["allow", "review", "approved", "denied"], initial="allow")
class GuardrailsGateActor:
    # tool_name ? "allow" | "deny" | "review"
    policies: dict = state(default_factory=dict)
    deny_count: int = state(default=0)

    @handler("check_tool")
    def check_tool(self, tool_name: str = "", input: dict = None) -> dict:
        policy = self.policies.get(tool_name, "allow")

        if policy == "deny":
            self.deny_count += 1
            host.incr_counter("tool_denials_total", 1)
            host.send(audit_id, "log_event",
                      {"event_type": "tool_denied", "detail": f"tool={tool_name}"})
            return {"decision": "deny", "reason": f"{tool_name} is blocked by policy"}

        if policy == "review":
            # FSM transitions to review — observable by operators via get_state
            self.fsm_state = "review"
            host.send(audit_id, "log_event",
                      {"event_type": "tool_review", "detail": f"tool={tool_name}"})
            # Production: pause here and await human approval via Channel
            self.fsm_state = "approved"
            return {"decision": "allow", "reviewed": True}

        return {"decision": "allow"}

    @handler("set_policy")
    def set_policy(self, tool_name: str = "", decision: str = "allow") -> dict:
        self.policies[tool_name] = decision
        host.send(audit_id, "log_event",
                  {"event_type": "policy_set",
                   "detail": f"tool={tool_name} decision={decision}"})
        return {"status": "ok", "tool_name": tool_name, "decision": decision}

    @handler("get_state")
    def get_state(self) -> dict:
        return {"fsm_state": self.fsm_state, "policies": self.policies,
                "deny_count": self.deny_count}
# Block a dangerous tool immediately — affects all in-flight and future calls
curl -X POST http://localhost:8091/api/v1/actors/guardrails/set_policy \
  -d '{"tool_name":"delete_file","decision":"deny"}'

# Route a sensitive tool through human review
curl -X POST http://localhost:8091/api/v1/actors/guardrails/set_policy \
  -d '{"tool_name":"send_email","decision":"review"}'

The GenFSM behavior validates every transition at runtime. Attempting allow --> approved without going through review first is rejected by the framework so that bugs in the policy logic cannot produce invalid states.


Tools: Runtime Registration and HTTPFetch Execution

Tools are not compiled in. Any HTTP endpoint can become a tool at runtime without redeploying the binary:

# Register a weather API as a tool — takes effect immediately
curl -X POST http://localhost:8091/api/v1/actors/tool_executor/register_tool \
  -d '{
    "name":        "weather",
    "description": "Get current weather for a city",
    "input_schema": {"type":"object","properties":{"city":{"type":"string"}}},
    "handler_type": "service_link",
    "handler_config": {"link_name":"openweather","path":"/data/2.5/weather","method":"GET"}
  }'

ToolExecutorActor dispatches registered tools via host.http_fetch() and the only way to make outbound network calls from within the WASM sandbox:

@actor
class ToolExecutorActor:
    tools: dict     = state(default_factory=dict)   # name ? spec
    exec_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        self.tools = {t["name"]: t for t in _BUILTIN_TOOLS}
        host.process_groups.join("svc:tool_executor")

    @handler("register_tool")
    def register_tool(self, name: str = "", description: str = "",
                      input_schema: dict = None, handler_type: str = "builtin",
                      handler_config: dict = None) -> dict:
        self.tools[name] = {
            "name": name, "description": description,
            "input_schema": input_schema or {},
            "handler_type": handler_type,
            "handler_config": handler_config or {}
        }
        return {"status": "ok", "name": name}

    @handler("execute")
    def execute(self, name: str = "", input: dict = None) -> dict:
        input = input or {}
        if name not in self.tools:
            return {"error": f"unknown tool: {name}"}
        self.exec_count += 1
        host.incr_counter(f"tool_{name}_total", 1)

        spec = self.tools[name]
        if spec.get("handler_type") == "service_link":
            cfg  = spec.get("handler_config", {})
            resp = host.http_fetch(cfg["link_name"], cfg.get("method","GET"),
                                   cfg["path"], input)
            return {"result": resp}

        # Built-in handlers
        if name == "calculator":
            expr = input.get("expression", "0")
            try:
                result = eval(expr, {"__builtins__": {}})  # demo only — see gaps section
                return {"result": str(result)}
            except Exception as e:
                return {"error": str(e)}
        if name == "memory_store":
            mem_id, _ = pg_first("svc:memory")
            if mem_id:
                return ask(mem_id, "store_memory", input) or {}
        if name == "memory_recall":
            mem_id, _ = pg_first("svc:memory")
            if mem_id:
                return ask(mem_id, "recall_memory", input) or {}

        return {"result": f"[simulated] {name} executed"}

Service Discovery: Process Groups vs. Object Registry

MiniHermes demonstrates both discovery patterns side by side.

Process Groups — simple, built-in, zero configuration:

# Every actor announces itself on startup
host.process_groups.join("svc:agent")

# Callers find the first available member — location-transparent
agent_id, err = pg_first("svc:agent")
result = ask(agent_id, "chat", {"message": "Hello"})
// Go version — same pattern
agentID, err := host.PG().First("svc:agent")

Object Registry — richer, capability-aware, preferred for production:

# On startup — declare what this actor can do
host.registry.register(ctx="", object_type="actor",
                        object_id=self.actor_id,
                        object_category="skill_store",
                        capabilities=["match_skills", "propose_skill", "lifecycle"])

# Caller — find an actor that specifically supports skill matching
actors = host.registry.discover(ctx="", object_type="actor",
                                 object_category="skill_store",
                                 required_capability="match_skills")
skill_id = actors[0]["object_id"] if actors else None
// Go — capability-aware lookup
agentID, err := registryFirst("agent", "svc:agent", "tool_use")

Process groups answer “is there anyone in this group?” Registry answers “is there anyone in this group who can do this?” The registry is the better choice when multiple actor versions may be deployed simultaneously, or when different instances offer different capabilities.


Audit Trail and Health Monitoring

Non-Blocking Audit with GenEvent

AuditEventActor uses the GenEvent behavior. Senders call host.send() with fire-and-forget so audit logging never adds latency to the critical path:

@event_actor
class AuditEventActor:
    event_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:audit")

    @handler("log_event", "cast")  # "cast" = fire-and-forget, no reply
    def log_event(self, event_type: str = "", detail: str = "",
                  timestamp: int = 0) -> None:
        ts = timestamp or host.now_ms()
        host.ts.write(["audit", event_type, ts, detail])
        self.event_count += 1

    @handler("query_events")
    def query_events(self, event_type: str = None) -> dict:
        pattern = ["audit", event_type or None, None, None]
        events  = host.ts.read_all(pattern)
        return {"status": "ok", "events": events, "count": len(events)}

The TupleSpace audit log is append-only by construction, there is no ts.delete() in the sandbox. Every tool call, policy change, skill creation, cron execution, and circuit event lands here and stays queryable by event type.

Health Monitor with SendAfter Polling

HealthMonitorActor never subscribes to membership change events. It polls every service group on a fixed interval and writes a snapshot to TupleSpace:

_SERVICE_GROUPS = [
    "svc:llm_gateway", "svc:tool_executor", "svc:agent",
    "svc:skill_store", "svc:guardrails", "svc:audit",
    "svc:cron_scheduler", "svc:session_manager", "svc:memory",
    "svc:context_compressor", "svc:health_monitor",
]

@actor
class HealthMonitorActor:
    poll_count:      int  = state(default=0)
    last_poll_ms:    int  = state(default=0)
    group_health:    dict = state(default_factory=dict)
    poll_interval_ms: int = state(default=5000)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:health_monitor")
        host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

    @handler("poll_tick", "cast")
    def poll_tick(self) -> None:
        health = {}
        for grp in _SERVICE_GROUPS:
            try:
                members      = host.process_groups.members(grp)
                health[grp]  = len(members)
            except Exception:
                health[grp] = 0

        self.group_health  = health
        self.poll_count   += 1
        self.last_poll_ms  = host.now_ms()

        host.ts.write(["health_snapshot", self.last_poll_ms, json.dumps(health)])
        # Each tick reschedules the next — no external scheduler
        host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

    @handler("get_health")
    def get_health(self) -> dict:
        degraded = [g for g, c in self.group_health.items() if c == 0]
        return {
            "status":       "ok" if not degraded else "degraded",
            "group_health": self.group_health,
            "healthy":      len(self.group_health) - len(degraded),
            "degraded":     degraded,
        }

Polling converges to the true state on every tick regardless of event ordering, it’s always eventually consistent and never stale for more than one poll interval.


Primitives Scorecard

MiniHermes uses 16 distinct PlexSpaces primitives across 12 actors:

PrimitiveWhere UsedWhat It Enables
KV.Get/PutAll actorsSession history, skill metadata, cron jobs, provider config
TupleSpace.Write/ReadAllSkills, Memory, Audit, HealthTag index, memory tiers, audit log, health snapshots
BlobStorage.Upload/DownloadSkills, MemorySkill procedures, deep memory archives
Channel.Send/Receive/AckCronAt-least-once job delivery; redelivers on crash
DistributedLock.TryAcquireCronSingle scheduler leader per cluster
ProcessGroups.Join/FirstAll actorsLocation-transparent svc:* discovery
ObjectRegistry.Register/DiscoverAgent, Skills, Session, HealthCapability-aware routing
SendAfterLLM, Cron, Health, SkillsSelf-scheduling tick loops; replaces external cron
HTTPFetchLLM, ToolsOutbound calls to Ollama, OpenAI, Anthropic, tool APIs
AskAgent, Tools, CompressorRequest-reply across actor boundaries
SendAgent, Cron, AuditFire-and-forget: audit events, async skill learning
IncrCounterAll actorsMetrics on every key operation
Workflow (run/signal/query)SkillWorkflowDurable parallel skill extraction with cancel/query
Durability (checkpoint_interval)All stateful actorsState persistence across crashes and restarts
GenFSMGuardrailsValidated state machine; invalid transitions rejected
GenEventAuditNon-blocking event delivery; callers never wait

Known Gaps

MiniHermes is a proof of concept, not a production system. The same disclaimer applies here as in the MiniClaw post: the point is to demonstrate what the architecture can support, not to ship something you should run in production today.

  • Skill quality and safety. The extraction workflow uses LLM reflection without any validation layer. Extracted skills can be incorrect, subtly wrong, or even harmful if the original task involved a bad assumption. A production system needs automated skill evaluation, human review for high-impact skills, and version history with rollback.
  • Calculator eval. The built-in calculator tool uses Python’s eval() with empty builtins. This is a demo shortcut. In production, replace it with an AST-based evaluator or a sandboxed tool actor in its own WASM module with no outbound capabilities at all.
  • Skill matching at scale. TupleSpace keyword matching works well up to thousands of skills. For a large skill store, keyword overlap produces too many false positives. The fix is an embedding-based vector index for semantic similarity but that requires an embedding model and an external vector store.
  • Context compression quality. The compressor summarizes the middle of the conversation with a generic prompt. It does not distinguish between a casual exchange and a chain of tool results that the later part of the conversation depends on. Poor summarization can cause the agent to “forget” a result it needs. Production compression needs to identify load-bearing context and exclude it from summarization.
  • No per-session actor instances. AgentActor stores self.messages as actor state, which all chat calls within one actor share. This is safe when there is one actor per session, but the POC maps many sessions to one actor instance. A production deployment should either run one actor per session or explicitly key all state by session_id.
  • No prompt injection defense. Tool results flow back into the conversation without any sanitization. A malicious tool response could attempt to override the system prompt. Production systems need input/output validation and possibly an LLM-as-judge layer between tool results and the next LLM call.
  • Circuit breaker threshold is fixed. Three consecutive failures opens the circuit. A slow provider that times out 20% of the time would never trip the breaker. Production needs adaptive thresholds based on error rate windows, not just consecutive failure counts.
  • No credential management. The LLM gateway reads provider API keys from service link configuration, which in this POC are stored in app-config.toml. A production system needs the phantom-token pattern from MiniClaw: the gateway resolves a real key from actor-private KV and never echoes it in any response or log.

MiniHermes vs. MiniClaw: Complementary, Not Competing

DimensionMiniClawMiniHermes
Primary focusSecurity and multi-tenant isolationSelf-improvement and operational resilience
Agent topologyMulti-agent orchestration with sub-tasksSingle self-improving long-lived agent
Session modelEphemeral per-requestLong-lived with LLM-based compression
Skill learningNone — static tool catalogAutomatic from conversation, durable workflow
SchedulingNoneDistributed cron with DistLock + Channel
LLM integrationSimulated onlyReal Ollama + OpenAI + Anthropic, hot-swap
Provider managementNoneHot-swap + gradual circuit breaker
Memory tiersSingle KV scopeCore / Reachable / Deep across three storage layers
GuardrailsWASM + actor isolation (structural)GenFSM gate with per-tool runtime policies
Credential handlingPhantom token in actor-private KVService link config (see gaps)
ObservabilityTupleSpace audit, health pollingSame, plus IncrCounter metrics on every operation

MiniClaw establishes the security foundation with WASM isolation, tenant enforcement, credential proxying, blast-radius containment. MiniHermes builds on that same foundation to add learning, resilience, and operational flexibility. A production system would combine both.


Building and Running

Prerequisites

Go implementation:

brew tap tinygo-org/tools && brew install tinygo
cargo install wasm-tools
npm install -g @bytecodealliance/jco

Python implementation:

pip install -e path/to/sdks/python

Ollama (optional — falls back to simulated LLM):

brew install ollama
ollama run llama3.2   # pulls ~2GB on first run

All tests pass without any LLM running. When Ollama is available, LLMGatewayActor switches automatically from the simulated fallback to real inference.

Build and Test

# Python
cd examples/python/apps/minihermes
./build.sh                       # componentize-py ? WASM Component Model binary
pytest test_minihermes.py -v     # unit tests, no live node required

# Go
cd examples/go/apps/minihermes
./build.sh                       # TinyGo ? wasm-tools ? component binary
go test ./... -v                 # unit tests, no live node required

Integration Tests Against a Live Node

# Start a PlexSpaces node first — see docs/getting-started.md
cd examples/go/apps/minihermes
./test.sh 8091                   # 21 steps, roughly 2 minutes

The test script covers the full actor tree:

# Basic agent chat
ask "agent" '{"op":"chat","message":"Hello","session_id":"test-1"}'

# Tool use — triggers guardrail check before execution
ask "agent" '{"op":"chat","message":"Calculate 42 * 17","session_id":"test-1"}'

# Hot-swap LLM provider
ask "llm_gateway" '{"op":"switch_provider","provider":"anthropic","model":"claude-opus-4-8"}'

# Register a new tool at runtime
ask "tool_executor" '{
  "op":"register_tool","name":"weather",
  "description":"Get weather for a city",
  "input_schema":{"type":"object","properties":{"city":{"type":"string"}}},
  "handler_type":"service_link",
  "handler_config":{"link_name":"openweather","path":"/data/2.5/weather","method":"GET"}
}'

# Create a cron job
ask "cron_scheduler" '{
  "op":"create_job","job_id":"morning-digest",
  "prompt":"Summarize pending tasks","schedule":"every_24h","session_id":"cron-main"
}'

# Block a tool via guardrails
ask "guardrails" '{"op":"set_policy","tool_name":"delete_file","decision":"deny"}'

# Query health across all service groups
ask "health_monitor" '{"op":"get_health"}'

# Query audit trail for tool executions
ask "audit_event" '{"op":"query_events","event_type":"tool_executed"}'

Conclusion

MiniHermes is a proof of concept, not a production agent platform. What it demonstrates is a way of thinking about agent systems that is different from the standard monolith approach. The Hermes Agent design from Nous Research gives us three powerful ideas: prompt discipline, multi-step tool loops, and skill accumulation. Those ideas work whether the agent runs in one Python process or across 12 actors. What changes is everything else, e.g., what happens when a component crashes, how you update a policy without restarting, how you prevent one tenant’s data from touching another’s, and how you keep conversations going past the model’s context limit.

The actor model with PlexSpaces provides a set of primitives like KV, TupleSpace, BlobStorage, Channel, DistributedLock, SendAfter, GenFSM, GenEvent, Workflow that map directly onto the operational problems an agent system faces. State durability, fault isolation, leader election, non-blocking audit, validated state machines, durable workflows: each is one primitive. The full source for both Python and Go implementations lives at github.com/bhatti/PlexSpaces. The architecture is meant to be a starting point, not a finished product.


References

June 1, 2026

Killing the State Machine: Declarative AI Coding Agents with an Orchestration System

Filed under: Agentic AI — admin @ 7:16 pm

Background

I have built a number of agentic systems over the last year. I built a PII detection system with LangChain and Vertex AI that scans documents and redacts sensitive data without human review. I built an API compatibility guardian using LangGraph that catches breaking changes before they reach production. And I built a production-grade enterprise AI platform on vLLM serving multiple teams and use cases. Most recently I wrote a complete guide to production AI agents with MCP and A2A.

Alongside that work I have been using agentic coding tools heavily by letting AI write code while I own the architecture and design. I documented that approach in AI Writes Code, You Own the Design, which covers how to use skills with structured methodology files to make AI coding agents produce consistent, reviewable, architecturally sound output instead of chaos.

But there’s a deeper layer of context. Over ten years ago, before GitHub Actions and GitLab Runner existed as concepts, I built a distributed orchestration engine for automating heterogeneous tasks with declarative syntax. It used Docker, Kubernetes, shell scripts, and custom worker types to handle diverse workloads. The core insight then is the same insight that applies now: scheduling, fault tolerance, retries, timeouts, observability, and capacity management are solved problems. Your application should not implement them. That engine became Formicary, which I open-sourced. This post shows how I applied Formicary to automated agentic coding workflows and why enterprises keep making the same expensive mistake.


The Problem I Keep Seeing

When teams build AI coding agents like systems that pick up GitHub issues, plan implementations, write code, run tests, and open PRs, they reach for the obvious approach: a coordinator process, a state machine, custom pollers. The initial version works. Then it accumulates. I have seen enterprises building custom solutions with 50K+ lines of TypeScript. Look inside these systems and you find the same failure modes every time:

  • No per-phase timeouts. If the AI model hangs during implementation, the process runs until a global job timeout kills it — often 90 minutes later, after consuming an expensive model session and blocking other work.
  • Silent work drop. When the worker pool fills, the system silently skips newly discovered issues instead of queuing them.
  • Context loss between phases. The planner writes a plan file. The implementer starts a fresh AI session and re-explores the entire codebase from scratch. The planning work gets thrown away.
  • Custom DAG reinvention. The state machine handles branching: tests fail -> retry, model blocked -> notify human. This is just a DAG with exit-code routing. It’s already solved, and the custom version is always underpowered.
  • Crummy restarts. Retry a failed issue and the agent reuses the same branch name. Git conflict. Failure. Start over.
  • Infrastructure lock-in. You can’t run it on a laptop because it’s tangled with Kubernetes pod lifecycle management.
  • High cost per new feature. Adding a security review phase means new state transitions, new code, a new deployment takes days of engineering time.

The root mistake is treating orchestration as application logic. These teams write scheduling, capacity management, artifact passing, observability, and retry logic inside their agent code. Every one of those concerns is already solved by mature orchestration frameworks. Stop writing that code.


The Declarative Replace

I have used a 50K+ lines TypeScript agent system in an enterprise environment, which I replaced with a few declarative workflow definitions such as:

ai-gh-issue-picker.yaml   (~100 lines)  — polls GitHub, submits jobs
ai-gh-implement.yaml      (~500 lines)  — plan -> implement -> test -> verify -> PR -> monitor -> learn
ai-gh-cleanup.yaml        (~80 lines)   — stale workspace and branch cleanup

No orchestration code. No state machine. No custom pollers. No retry logic. No timeout management. Formicary handles all of it.

Here is every decision, with the reasoning.


Decision 1: Replace Custom Pollers with a Cron Job

Custom polling processes run continuously, consume resources, and require their own deployment lifecycle. I replaced the GitHub issue poller with a Formicary cron job:

job_type: ai-gh-issue-picker
cron_trigger: "0 * * * * * *"   # every minute (7-field cron)
max_concurrency: 1               # only one picker at a time

skip_if: >-
  {{if ge (CountByJobTypeAndState "ai-gh-implement" "PENDING") 10}} true {{end}}

The skip_if fires at the scheduler level before any worker is allocated, before any task runs. If 10 implement jobs are already pending, Formicary skips the entire picker invocation silently. Zero worker cost.

The gather-issues task fetches GitHub issues labeled ai-ready, moves each label to ai-in-progress, and writes a compact issues.json. I wrote it in Python rather than bash because Python eliminates the jq/base64/subshell-scoping traps that plagued the original version:

import json, os, subprocess

repo = f"{os.environ['GH_ORG']}/{os.environ['GH_REPO']}"

def gh(*args):
    r = subprocess.run(["gh"] + list(args), capture_output=True, text=True)
    return r

r = gh("issue", "list", "-R", repo,
       "--label", os.environ["PICKUP_LABEL"], "--state", "open",
       "--limit", os.environ.get("MAX_PENDING", "10"),
       "--json", "number,title,url")
issues = json.loads(r.stdout) if r.returncode == 0 else []

for issue in issues:
    gh("issue", "edit", str(issue["number"]), "-R", repo,
       "--remove-label", os.environ["PICKUP_LABEL"],
       "--add-label", os.environ["INPROGRESS_LABEL"])

issues_json = json.dumps(issues, separators=(',', ':'))
with open("issues.json", "w") as f:
    f.write(issues_json + "\n")
print(f"::set-output name=IssuesJSON::{issues_json}")

The submit-jobs task uses SubmitJobsFromJSON, a Formicary template function that submits one implement job per issue directly through the DB. A unique index on user_key (keyed as ai-gh-implement-{org}-{repo}-{number}) rejects duplicate submissions at the constraint level. No pre-flight lookups, no race conditions:

environment:
  SUBMITTED_IDS: >-
    {{if .IssuesJSON}}{{SubmitJobsFromJSON "ai-gh-implement" .IssuesJSON
        (printf "GitHubOrg=%s" .GitHubOrg) (printf "GitHubRepo=%s" .GitHubRepo)}}{{end}}
  PENDING_COUNT: '{{CountByJobTypeAndState "ai-gh-implement" "PENDING"}}'

Decision 2: Replace the State Machine with a DAG

A 12-state custom state machine becomes a named DAG in YAML. The full pipeline looks like this:

Exit-code routing handles every branch. No code required:

- task_type: implement
  on_exit_code:
    COMPLETED: unit-test
    "2": notify-blocked    # model signals blocked
    "3": fix-tests         # tests failing
  on_failed: notify-blocked

The unit-test task verifies commits exist, shows the diff, then detects and runs the project’s test suite, it checks for Makefile, Cargo.toml, package.json, go.mod, or pytest and runs whichever it finds. If no commits were made, it fails immediately. If tests fail, it routes to fix-tests. The self-verify task runs a separate AI reviewer session that runs tests, checks correctness, checks security, and verifies the implementation matches the issue. A fresh context catches mistakes the implementer’s context was blind to. If self-verify cannot resolve a problem, create-pr still runs but the PR body explicitly states what remains unresolved. Silently creating PRs with known failures is a common failure mode in imperative systems, I designed against it.


Decision 3: Give Every Phase Its Own Timeout

The biggest operational gap in imperative agents is missing per-phase timeouts. I gave every task its own:

- task_type: plan
  timeout: 15m

- task_type: implement
  timeout: 45m

- task_type: unit-test
  timeout: 10m

- task_type: self-verify
  timeout: 15m

- task_type: cleanup
  always_run: true    # runs even if the job fails
  timeout: 1m

always_run: true on cleanup guarantees Formicary removes the workspace and branch regardless of outcome. Without it, stuck jobs leak temporary directories and dead branches indefinitely.


Decision 4: Flow Context Forward Through Artifacts

Imperative bots lose context between phases because each phase is a separate pod with no shared state. The planner’s work gets discarded. I solved this years ago with a shared workspace and an artifact chain:

Each task declares its dependencies and Formicary downloads the upstream artifacts automatically:

- task_type: self-verify
  dependencies:
    - setup       # downloads meta.env
    - implement   # downloads impl_result.json, impl_conversation.txt, impl_diff.patch
  script:
    - |
      TASK_DIR="$PWD"          # capture executor dir before any cd
      source "$TASK_DIR/meta.env"
      cd "$WS/repo"
      # all artifacts available in $TASK_DIR/

One critical detail: save TASK_DIR="$PWD" before any cd. Artifacts must be written back to the executor’s working directory, not to the repo:

TASK_DIR="$PWD"
source "$TASK_DIR/meta.env"
cd "$WS/repo"
# ... do work ...
jq ... > "$TASK_DIR/result.json"   # write to TASK_DIR, not to repo

The implementer now reads PLAN.md that the planner wrote. Context survives across phases.


Decision 5: Use Nonces to Make Restarts Safe

One issue with imperative implementation was that when a job retried a failed issue, it reused the same branch name. Git conflict. In the workflow definition, I added a 4-byte random hex nonce to every branch:

NONCE=$(head -c 4 /dev/urandom | xxd -p)
BRANCH="ai/{{.IssueNumber}}-${SLUG}-${NONCE}"
# e.g., ai/42-fix-login-timeout-a3f1

retry: 1 on the implement job submits a fresh attempt with a new nonce -> new branch -> no conflicts. The ai-gh-cleanup job removes stale branches after PR merge.


Decision 6: Stream Output and Extract Structured Status

I need two things simultaneously: real-time visibility of what the agent is doing, and structured status for routing decisions. claude --print streams output through tee, while the prompt instructs Claude to output a JSON status object on its final line:

claude --print --dangerously-skip-permissions --model "$MODEL" --max-turns 100 \
  "$(cat /tmp/impl_prompt.txt)" 2>&1 | tee "$TASK_DIR/impl_conversation.txt"

# Extract the last JSON object with a "status" key
STATUS_JSON=$(grep -oE '\{[^{}]*"status"[^{}]*\}' \
  "$TASK_DIR/impl_conversation.txt" | tail -1)
STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "UNKNOWN"')
[ "$STATUS" = "BLOCKED" ] && exit 2
[ "$STATUS" = "TESTS_FAILING" ] && exit 3

--dangerously-skip-permissions is required. Without it, Claude only produces text describing what it would do, zero file changes, zero commits. With it, Claude actually reads files, writes code, and runs tests. This gives me four things at once: real-time streaming to the Formicary dashboard, exit-code routing from the status field, artifact data for downstream tasks, and the full AI conversation captured as a debuggable artifact.


Decision 7: Encode Methodology in Skills

I don’t ask Claude to “write some code.” I embed skill instructions that encode engineering discipline into every prompt. I wrote about this approach in depth in AI Writes Code, You Own the Design, the core idea is that freeform prompting produces inconsistent output, while skill-encoded prompting produces output that follows a contract.

claude --print --model opus --max-turns 30 \
  "Use the ygs-wbs skill approach:
   1. Explore the codebase
   2. Decompose into vertical-slice tasks
   3. Write PLANS/{issue-slug}-{number}-plan.md with acceptance criteria"

If you-got-skills is installed on the worker, Claude discovers /ygs-wbs as a slash command automatically. The prompt-embedded version works either way, no dependency on the skills package being present.

The four skills that shape this pipeline:

PhaseSkillWhat it enforces
planygs-wbsVertical slices, acceptance criteria, explicit scope
implementygs-implementAtomic commits, tests after each task, scope guardrails
fix-testsygs-investigateRoot cause analysis, not symptom masking
self-verifyygs-code-reviewRun tests, check correctness, fix critical issues

Each skill acts as a contract. “Plan vertically, commit atomically, stop when blocked” produces far more consistent and reviewable output than open-ended instructions.


Decision 8: Make the Dashboard Show What’s Happening

Formicary’s job description field accepts markdown. Every submitted implement job carries clickable links to the issue, branch, and PR:

{
  "job_type": "ai-gh-implement",
  "description": "#42: Fix login timeout | [org/repo](https://github.com/org/repo)",
  "params": {
    "IssueLink": "[#42: Fix login timeout](https://github.com/org/repo/issues/42)",
    "BranchLink": "[ai/42-fix-login-a3f1](https://github.com/org/repo/tree/ai/42-fix-login-a3f1)",
    "PRLink": ""
  }
}

The PRLink starts empty and the create-pr task populates it once the PR exists. Every job in the dashboard now shows exactly what it’s working on with one-click navigation to the relevant GitHub page.


Decision 9: Capture Everything as Artifacts

Every task uploads artifacts with when: always including on failure. This is what makes debugging possible rather than a guessing game:

ArtifactContents
plan_conversation.txtFull AI conversation during planning
plan_result.jsonStatus, complexity, task count, summary
impl_conversation.txtFull AI conversation during implementation
impl_result.jsonStatus, files changed, commit count
impl_diff.patchComplete git diff of all changes
impl_commits.txtList of commits made
test_output.txtTest suite output with pass/fail details
verify_result.jsonTest pass/fail, critical findings, any fixes
verify_conversation.txtFull AI conversation during self-verify

Every task also sets report_stdout: true, Formicary streams output to the dashboard websocket in real time. Combined with tee, you see the full AI conversation live as it happens. The workspace also persists locally at ~/claude_workspace/{issue}-{nonce} so you can cd into it after a run and inspect exactly what happened.


Decision 10: Monitor PRs and Capture Learnings

Imperative bots typically run a PR comment poller that fires every few minutes, scanning for mentions. I replaced it with a task inside the implement job that lives as long as the PR stays open:

The monitor-pr task:

  1. Polls for new PR review comments every 2 minutes
  2. Feeds each new comment to Claude, applies the change, commits, and pushes
  3. Replies on the PR confirming the fix
  4. Tracks processed comment IDs in $WS/.processed_comments to avoid re-processing
  5. Exits when the PR merges or closes

The learn task runs after the PR closes. It reviews all PR comments, reviewer feedback, and the implementation conversation, then writes a structured learning entry to ~/claude_workspace/learn_context/ using the ygs-learn skill methodology: what went well, what to improve, patterns to remember for this codebase. Over time the agent gets better at this specific repo, not just better in general.

- task_type: monitor-pr
  method: SHELL
  timeout: 24h

- task_type: learn
  method: SHELL
  # reviews PR feedback, writes to ~/claude_workspace/learn_context/

Decision 11: Support Multiple Trackers with Minimal Changes

The pipeline is intentionally tracker-agnostic. Only two tasks touch the issue tracker API: gather-issues in the picker, and create-pr plus monitor-pr in the implement job. Everything else: plan, implement, unit-test, self-verify, learn works identically regardless of tracker.

To support Jira and Bitbucket, I cloned the YAML files and swapped six commands:

  • gh issue list -> acli jira search --jql ...
  • gh issue edit -> acli jira issue update
  • git clone git@github.com: -> git clone git@bitbucket.org:
  • gh pr create -> acli bitbucket pr create
  • gh pr view -> acli bitbucket pr get
  • gh api .../comments -> acli bitbucket pr comment list

Result: ai-jira-issue-picker.yaml and ai-jira-implement.yaml, the same complete pipeline, different API calls. Both use the Atlassian CLI (acli) configured at ~/.config/acli/config.json.


What Formicary Gives You Without Writing a Line

When I started applying Formicary to agentic coding, I wasn’t sure it had everything needed. It had almost all of it already:

  • Cron: scheduling with 7-field syntax (including seconds)
  • Per-task timeouts: the feature imperative bots most consistently lack
  • Exit-code routing (on_exit_code): conditional DAG without custom code
  • always_run: true: guaranteed cleanup regardless of failure
  • Artifact: passing between tasks via S3
  • Encrypted secrets: with automatic log redaction
  • max_concurrency: capacity management declared in YAML
  • retry + delay_between_retries: automatic backoff
  • Go template functions: variable substitution in scripts
  • SHELL executor: runs on a laptop with no Kubernetes
  • KUBERNETES executor production-grade pod-per-task isolation
  • Markdown in job descriptions: visible, clickable in the dashboard

Two additions were made specifically for this use case.

Native Kubernetes secret injection. The naive pattern passes API keys through the orchestrator as template variables, which stores them in the job definition. The new pattern lets the kubelet inject them at pod start time, the value never touches Formicary:

container:
  image: ghcr.io/formicary-ai/agent-worker:latest
  env_from:
    - secret_ref: claude-bedrock-settings
    - secret_ref: ai-agent-secrets

Or for a single named key:

container:
  env_value_from:
    - name: ANTHROPIC_API_KEY
      secret_name: ai-agent-secrets
      key: anthropic-api-key

Per-task service accounts work the same way for IRSA on AWS or Workload Identity on GCP:

container:
  service_account: ai-agent-irsa-sa

CountByJobTypeAndState template function. The original capacity check made an HTTP API call requiring a token, an available endpoint, and network round-trip time. The new function queries the job database directly at the scheduler level before any worker is allocated:

skip_if: >-
  {{if ge (CountByJobTypeAndState "ai-gh-implement" "PENDING" "EXECUTING") 10}} true {{end}}

If the count hits the threshold, Formicary skips the entire job invocation with zero cost. The script also does a fine-grained check using the configurable MaxPendingJobs variable. Two layers: cheap early termination at the scheduler, tunable limits inside the task.


The Numbers

MetricImperative BotFormicary Declarative
Lines of orchestration code~50,000 LOC~700 lines YAML
State machine states12+0 (implicit in DAG)
Custom pollersMultiple0
Per-phase timeoutsNoneYes, per-task
Context between phasesLost (new pod, new session)Preserved via artifact chain
Runs locally without K8sNoYes (SHELL executor)
K8s isolation in productionPod-per-jobPod-per-task
Time to add a new phaseHours to daysMinutes (copy task block, change prompt)
Restart safetyBranch conflictsNonce-based, no conflicts
Real-time outputText logs onlyDashboard streaming + tee
Diagnostics on failureText logsFull AI conversations + diffs as artifacts
Capacity check costHTTP API callDB query at scheduler level
VerificationLimitedunit-test + self-verify (separate AI session)
Multi-trackerOne tracker hardcodedClone YAML, swap 6 commands
Continuous learningNonelearn task after every PR close
Secret injectionEnv vars on hostNative Kubernetes env_from / env_value_from

Getting Started

Option A: SHELL executor (local dev, fastest path)

This is where to start. The SHELL executor runs scripts directly on the host and inherits ~/.claude/settings.json, gh auth login, and all other host credentials automatically, no secrets configuration needed.

# 1. Prerequisites (one-time)
npm install -g @anthropic-ai/claude-code
gh auth login

# 2. Start Formicary (queen + embedded ant worker)
docker pull plexobject/formicary
docker run plexobject/formicary

# 3. Deploy workflow definitions
git clone https://github.com/bhatti/formicary.git
cd docs/examples
./deploy-ai-workflows.sh --mode shell --repo your-org/your-repo --setup-labels

# 4. Set org config so the picker knows where to look
curl -X POST http://localhost:7777/api/orgs/default/configs \
  -H 'Content-Type: application/json' \
  -d '{"name":"GitHubOrg","value":"your-org"}'
curl -X POST http://localhost:7777/api/orgs/default/configs \
  -H 'Content-Type: application/json' \
  -d '{"name":"GitHubRepo","value":"your-repo"}'

# 5. Label an issue — the picker fires within 1 minute
gh issue edit 1 --repo your-org/your-repo --add-label "ai-ready"

# 6. Watch it run
open http://localhost:7777

Option B: Kubernetes with Bedrock via Tailscale

Pods can’t resolve Tailscale hostnames by name, but they can reach the IP. Resolve it once:

TAILSCALE_IP=$(python3 -c "import socket; print(socket.gethostbyname('ai'))")

kubectl create namespace formicary-ai

kubectl create secret generic claude-bedrock-settings \
  --namespace=formicary-ai \
  --from-literal=ANTHROPIC_BEDROCK_BASE_URL=http://${TAILSCALE_IP}/bedrock \
  --from-literal=CLAUDE_CODE_USE_BEDROCK=1 \
  --from-literal=CLAUDE_CODE_SKIP_BEDROCK_AUTH=1 \
  --from-literal=ANTHROPIC_DEFAULT_OPUS_MODEL=us.anthropic.claude-opus-4-6-v1 \
  --from-literal=ANTHROPIC_DEFAULT_SONNET_MODEL=us.anthropic.claude-sonnet-4-6 \
  --from-literal=ANTHROPIC_DEFAULT_HAIKU_MODEL=us.anthropic.claude-haiku-4-5-20251001-v1:0

kubectl create secret generic ai-agent-secrets \
  --namespace=formicary-ai \
  --from-literal=github-token=$(gh auth token)

If the Tailscale IP changes, regenerate the secret with --dry-run=client -o yaml | kubectl apply -f -.

Option C: Standard Anthropic API key

kubectl create secret generic ai-agent-secrets \
  --from-literal=anthropic-api-key=sk-ant-... \
  --from-literal=github-token=$(gh auth token)

Job YAMLs reference it with env_value_from, so the key is injected by the kubelet and never passes through Formicary.


Ten Lessons

  • Timeouts are not optional. AI models hang. Give every phase its own timeout. A global job timeout is not a substitute when the plan phase hangs, you want to retry that phase, not restart the whole job from scratch.
  • Structured JSON output unlocks routing. Ask the AI to output {"status": "DONE|BLOCKED|TESTS_FAILING", ...} on its final line. Route on that field. Extract metadata for dashboards.
  • Flow context forward. If planning and implementation run in separate sessions with no shared artifacts, the implementer re-explores the entire codebase and discards all planning work. Pass PLAN.md as an artifact. Cost and quality both improve.
  • Use nonces for idempotency. Branch names, workspace paths, artifact names, all need a per-run nonce. Never reuse a name across retry attempts.
  • Guarantee cleanup. Set always_run: true on cleanup tasks. Workspaces and branches accumulate fast. One stuck job should not leave garbage forever.
  • Let the orchestrator manage capacity. Set max_concurrency on the job and use skip_if with a scheduler-level DB query. Don’t write custom capacity management code, it will be wrong.
  • Skills are the real leverage. The quality gap between freeform prompting and methodology-encoded prompting is large. Invest in skill definitions. The skill is a contract: “plan vertically, commit atomically, stop when blocked.” Consistent contracts produce consistent, reviewable output. I covered this in depth in AI Writes Code, You Own the Design.
  • Declarative wins operationally. Adding a security review phase to the declarative version takes minutes: copy a task block, write a prompt, add an on_completed route. The same change to an imperative system takes days. The asymmetry grows with every phase you add.
  • Capture everything on failure. Upload artifacts with when: always. When something fails, you want the full AI conversation, the git diff, and the test output — not just “job failed.”
  • Build a feedback loop. Most AI coding systems run, merge, and forget. The learn task after every PR close gives the agent a memory of what works and what doesn’t in this specific codebase. Over time, that compounds.

References

The job definitions described in this post are in docs/examples/ in the Formicary repository. See docs/ai-agents.md for the full setup guide.

April 28, 2026

Building Mini OpenClaw: Secure AI Agents with Actors, WASM, and Supervision

Filed under: Agentic AI,Computing — admin @ 7:17 pm

Introduction

Most agent frameworks start simple: one process, one conversation loop, one tool registry, one memory store, and one pile of credentials. That simplicity is useful for demos, but dangerous for enterprise systems. If a prompt injection reaches a tool with broad permissions, the whole runtime becomes part of the blast radius (see https://arxiv.org/abs/2403.02691). If one tool call hangs or crashes, it can stall the agent loop. If memory and sessions are shared by convention instead of isolated by construction, tenant boundaries depend on every developer remembering every guardrail every time. Enterprise teams need a different foundation. They need agents that isolate state, limit blast radius, enforce tenant boundaries, and recover from failures without operator intervention. They need the same properties that telecom systems have delivered for four decades: per-process isolation, supervision trees, guardian processes, and location-transparent messaging.

This post shows how I built Mini OpenClaw as a proof of concept implementation that runs entirely on PlexSpaces, an actor-based distributed runtime inspired by Erlang/OTP. OpenClaw-style systems are useful because they give developers a programmable agent runtime: tools, memory, planning, execution, and orchestration. MiniClaw keeps that spirit, but changes the failure and security model. Instead of one runtime owning everything, each responsibility becomes an actor with its own state, permissions, lifecycle, and supervision boundary. MiniClaw deploys ten actors inside a WebAssembly + Firecracker sandbox to deliver a secure, fault-tolerant agent system. Every actor owns its state exclusively. Every message travels through explicit channels and every failure triggers a supervised restart instead of full-system crash.

OpenClaw’s 2026.4.29 release triggered plugin dependency repair loops at startup and cold paths due to monolithic core owns too many responsibilities. MiniClaw starts from the opposite position: every responsibility is an actor from the beginning, with its own state, and its own explicit message contract.


Part 1: Agents and Actors Isomorphism

1.1 The Same Computational Model

An LLM agent has four things: state (conversation history, tool results), a processing loop (receive message, reason, act), communication (call tools, delegate to other agents), and failure modes (timeouts, hallucinations, rate limits). An actor has exactly the same structure. This is not a coincidence. Both actors and agents derive from the same computational model, isolated units of stateful computation that communicate by passing messages.

# From examples/python/apps/miniclaw/agent.py
# An agent IS an actor same structure, same guarantees
# For readability, this POC keeps message history directly on the `AgentActor`. 
# In a production deployment, I would usually run one actor instance per session or 
# store history by `session_id` to avoid cross-session context mixing.
@actor
class AgentActor:
    """Core agent: receive user message, call LLM, execute tools, loop until end_turn."""

    system_prompt: str = state(default="You are a helpful AI assistant with access to tools.")
    messages: list  = state(default_factory=list)   # Conversation state
    max_history: int = state(default=50)            # Context window bound
    total_chats: int = state(default=0)             # Usage counter
    agent_name: str  = state(default="general-assistant")

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        self.agent_name = args.get("agent_name", self.agent_name)
        self.system_prompt = args.get("system_prompt", self.system_prompt)
        host.process_groups.join("svc:agent")        # Announces itself for discovery
        write_actor_info(self.actor_id, self.agent_name,
                         "Core agent loop with tool calling and session memory",
                         ["chat", "tool_use", "memory"])

    @handler("chat")
    def chat(self, message: str = "", session_id: str = "") -> dict:
        # Agent processing loop: receive message -> reason -> act
        ...

The mapping is direct. Every agent concept has an actor primitive:

Agent ConceptActor PrimitiveMiniClaw Implementation
Conversation historyActor-private statemessages: list (serialized, isolated)
Tool callingInter-actor messagingask(tool_reg_id, "execute_tool", ...)
Agent delegationLocation-transparent Askask(agent_id, "chat", ...) via process groups
Crash recoverySupervisor restart + durability facetState checkpointed to SQLite, restored on restart
Rate limitingPer-actor circuit breaker statecircuit_open, consecutive_failures in actor state
MemoryScoped KV + TupleSpaceGlobal/agent/session scopes via MemoryActor
Audit trailFire-and-forget GenEventhost.send(audit_id, "log_event", ...) — non-blocking

1.2 Four Behaviors Map to Four Agent Archetypes

PlexSpaces provides four actor behaviors. Each maps to a distinct agent archetype:

BehaviorAgent ArchetypeMiniClaw ActorDecorator
GenServerTool executor, stateful helperAgentActor, LLMRouterActor, ToolRegistryActor, MemoryActor, SessionManagerActor, TaskQueueActor, HealthMonitorActor@actor
GenEventAudit logger, event publisherAuditEventActor@event_actor
GenStateMachineState-machine agent, quality gateAgentStateFSM@fsm_actor(states=[...], initial="idle")
WorkflowOrchestrator, pipeline coordinatorOrchestratorActor@workflow_actor

Part 2: PlexSpaces Primitives

Before walking through each actor, it helps to see the five low-level primitives that every actor uses. These are the only operations available inside the WASM sandbox without filesystem or global state.

2.1 Process Groups and Object Registry for Location-Transparent Discovery

Every actor is registered in an actor-registry and can optionally join a named process group on @init_handler. Callers look up the first member with pg_first(), a one-liner that hides whether the target is local or on a remote node:

# From examples/python/apps/miniclaw/helpers.py
def pg_first(group: str) -> Tuple[Optional[str], Optional[str]]:
    """Return (actor_id, None) for the first member of a process group, or (None, error)."""
    try:
        members = host.process_groups.members(group)
        if members:
            return members[0], None
        return None, f"no members in {group}"
    except Exception as e:
        return None, str(e)

Every actor announces itself on startup:

@init_handler
def on_init(self, config: dict) -> None:
    host.process_groups.join("svc:agent")
    write_actor_info(self.actor_id, self.agent_name,
                     "Core agent loop with tool calling and session memory",
                     self.capabilities)

The orchestrator discovers agents via pg_first("svc:agent"), it does not know the agent’s address, node, or port. The framework routes the message transparently.

2.2 Fire-and-Forget Audit with host.send, Never host.ask

The audit trail uses host.send() (fire-and-forget) rather than host.ask() (request-reply). This is a deliberate design choice: audit events must never add latency to the agent’s critical path.

# From examples/python/apps/miniclaw/helpers.py
def fire_audit(event_type: str, detail: str) -> None:
    """Fire-and-forget audit event. Failures are logged, never raised."""
    audit_id, err = pg_first("svc:audit")
    if err or not audit_id:
        host.debug(f"fire_audit: {err}")
        return
    try:
        host.send(audit_id, "log_event", {
            "op": "log_event",
            "event_type": event_type,
            "detail": detail,
            "timestamp": host.now_ms(),
        })
    except Exception as e:
        host.warn(f"fire_audit: send failed: {e}")

Every actor calls fire_audit() after each meaningful operation. The audit actor receives the event asynchronously. If the audit actor is slow or temporarily down, callers are unaffected, they never wait for a response.

2.3 TupleSpace: Queryable Shared Coordination State

TupleSpace (host.ts) is the coordination layer. Unlike KV (point lookup by key), TupleSpace supports pattern queries like read all tuples matching a template with None wildcards:

# Write a memory tuple
host.ts.write(["memory", "global", "user_name", "Alice"])

# Read all global memories — None matches any value in that position
tuples = host.ts.read_all(["memory", "global", None, None])

# Read all audit events of a specific type
events = host.ts.read_all(["audit", "tool_executed", None, None])

# Orchestrator checkpoints sub-task results for crash recovery
host.ts.write(["orch_result", task_id, i, str(result)])

The write_actor_info helper uses TupleSpace to publish actor capabilities for external discovery without blocking callers:

# From examples/python/apps/miniclaw/helpers.py
def write_actor_info(actor_id: str, name: str, description: str, capabilities: list) -> None:
    """Write actor capability tuples to TupleSpace for discovery."""
    try:
        host.ts.write(["agent_card", actor_id, name, description])
        for cap in capabilities:
            host.ts.write(["agent_cap", cap, actor_id])
    except Exception as e:
        host.warn(f"write_actor_info: {e}")

2.4 send_after for Scheduling Timers

The health monitor uses host.send_after() to schedule a self-message after every poll interval. No cron job, no external scheduler, the actor manages its own polling timeline:

@init_handler
def on_init(self, config: dict) -> None:
    # Schedule first poll; each tick reschedules the next
    host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

@handler("poll_tick", "cast")
def poll_tick(self) -> None:
    # ... do poll work ...
    # Re-arm: each tick schedules the next — no external scheduler needed
    host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

2.5 host.channel for Channel-Backed Durable Queues

The Channel primitive provides at-least-once message delivery with explicit ack/nack:

# Producer: send to channel
msg_id = host.channel.send("", _TASK_CHANNEL, task_type, task)

# Consumer: receive, process, then ack or nack
msg, ok, _ = host.channel.receive("", _TASK_CHANNEL, timeout_ms)
if ok:
    host.channel.ack("", _TASK_CHANNEL, msg["msg_id"])   # commit
    # OR
    host.channel.nack("", _TASK_CHANNEL, msg["msg_id"], True)  # requeue

2.6 The Let-It-Crash Philosophy

Monolithic agent frameworks force developers to write defensive error handling around every tool call, every LLM request, and every memory access. MiniClaw takes the Erlang philosophy: let actors crash, and let guardians restart them in a clean state. A guardian supervisor watches its children. When one crashes, it applies a restart strategy. The other children continue running, unaffected without cascading failures and global error handlers.

# From examples/python/apps/miniclaw/app-config.toml
[supervisor]
strategy = "one_for_one"          # Restart ONLY the crashed actor
max_restarts = 10                 # Allow up to 10 restarts
max_restart_window_seconds = 60   # Within a 60-second window
# If 10 crashes in 60s -> escalate to parent supervisor

PlexSpaces provides three restart strategies, each suited to different failure patterns:

StrategyBehaviorAgent Use Case
one_for_oneRestart only the crashed actorIndependent tools: calculator crash does not affect weather
rest_for_oneRestart crashed actor + all actors started after itPipeline stages: if retriever crashes, restart generator and validator too
one_for_allRestart all children when any crashesTightly coupled team: research + analysis + writing agents share context

2.7 Monitors and Links

PlexSpaces provides two mechanisms for actors to watch each other (similar to Erlang):

  • Monitors (host.monitor()) provide one-way observation. The monitoring actor receives a __DOWN__ message when the monitored actor stops.
  • Links (host.link()) provide bidirectional fate-sharing. If either linked actor crashes abnormally, the other receives an __EXIT__ message.
# Monitor: one-way watch. ValidatorAgent watches workers.
monitor_ref = host.monitor(worker_id)

@handler("__DOWN__", "cast")
def on_down(self, monitor_ref: str = "", down_from: str = "", down_reason: str = "") -> None:
    """Monitored worker stopped. ValidatorAgent stays alive and compensates."""
    self.failed_workers.append(down_from)
    # Spawn replacement, redistribute work, alert operator

# Link: bidirectional fate-sharing. Coordinating agents share fate.
host.link(peer_id)

@handler("__EXIT__", "cast")
def on_exit(self, exit_from: str = "", exit_reason: str = "") -> None:
    """Linked peer died abnormally. Clean up shared resources."""
    self.linked_peers.remove(exit_from)

In MiniClaw, the guardian supervisor monitors all ten actors. If the LLMRouterActor crashes, the supervisor restarts it with a clean state. The AgentActor‘s in-flight request receives a timeout error while the MemoryActor, the AuditEventActor, and every other actor continues running without interruption.

The supervisor IS the guardian pattern from Erlang. Every MiniClaw actor runs under guardian supervision for crash recovery.


Part 3: WASM + Firecracker Sandbox

3.1 Defense in Depth

MiniClaw actors run inside three concentric isolation layers:

  1. Actor isolation: Each actor owns its state exclusively. No shared memory, no global variables, no cross-actor data access. Communication happens only through host.ask() and host.send().
  2. WASM + Firecracker sandbox: Each actor compiles to a WebAssembly module that runs inside a hardware-enforced memory sandbox. The WASM linear memory is isolated per actor instance. In production deployments, each WASM runtime itself runs inside a Firecracker microVM, a lightweight KVM-based hypervisor that boots in ~125ms and provides hardware-level memory and I/O isolation between tenants.
  3. Tenant isolation: Every PlexSpaces operation requires a RequestContext with explicit tenant and namespace identifiers via JWT authentication. The framework rejects cross-tenant access before the request reaches the actor.

3.2 What the Two-Layer Sandbox Prevents

Attack VectorMonolithic FrameworkWASM SandboxWASM + Firecracker
open("/etc/passwd")Succeeds with full FS accessBlocked with no FS import in WITBlocked with separate VM filesystem
os.environ["API_KEY"]Succeeds with env vars sharedBlocked with no env access in WASMBlocked with separate VM env
Read another actor’s memorySucceeds with shared processBlocked with WASM linear memory is per-instanceSeparate VM address space
Escape WASM sandbox via JIT bugPossible in theoryPartially mitigatedBlocked with hypervisor hardware boundary
Cross-tenant KV accessPossible if scoping misconfiguredBlocked with RequestContext enforcedBlocked with separate VM tenant

The WIT (WebAssembly Interface Types) definition explicitly declares what the actor can access:

// From wit/plexspaces-actor/host.wit
// The actor can ONLY call these imports — nothing else
interface host {
    send: func(to: string, msg-type: string, payload: payload) -> result<_, actor-error>;
    ask: func(to: string, msg-type: string, payload: payload, timeout-ms: u64) -> result<payload, actor-error>;
    kv-get: func(key: string) -> result<payload, actor-error>;
    kv-put: func(key: string, value: payload) -> result<_, actor-error>;
    http-fetch: func(link-name: string, method: string, path: string, request: payload) -> result<payload, actor-error>;
    // No filesystem. No env vars. No raw network. No process exec.
}

3.3 Tenant Isolation by Construction

Every PlexSpaces operation propagates tenant context through the call chain. KV keys, TupleSpace tuples, object-registry and process groups are all scoped by tenant and namespace. A session created by tenant acme cannot be retrieved by tenant globex and the framework rejects the request before it reaches the actor.

# Every API request carries tenant context — enforced at framework level
# KV keys scoped:     tenant-acme:prod:session:sess-001
# TupleSpace scoped:  tenant-acme:prod:["memory", "global", "user_name", "Alice"]
# Process groups:     tenant-acme:prod:svc:llm_router

There is no internal() bypass for application code. Tenant boundaries are enforced by construction, not by convention.


Part 4: MiniClaw Architecture

MiniClaw decomposes the agent framework into ten actors. Every actor runs as a WebAssembly module inside the PlexSpaces runtime, discovers collaborators through object-registry or process groups, and persists state through the durability facet.

ActorBehaviorResponsibilitySecurity Property
LLMRouterActorGenServerRoute LLM calls, circuit-break on failureReal API keys never leave the actor (phantom token proxy)
ToolRegistryActorGenServerRegister tools with schemas, execute in isolationSchema validation prevents malformed tool inputs
AgentActorGenServerCore agent loop: message -> LLM -> tool -> repeatBounded iteration (max 5) prevents infinite loops
SessionManagerActorGenServerMap users to sessions, enforce tenant scopeTenant-scoped KV keys prevent cross-tenant access
OrchestratorActorWorkflowDecompose tasks, delegate, checkpoint progressDurable checkpoints survive crashes
MemoryActorGenServerScoped memory (global/agent/session)KV + TupleSpace dual-write with tenant scoping
AuditEventActorGenEventImmutable log of every actor operationFire-and-forget; senders never block on audit
AgentStateFSMGenStateMachineLifecycle guard: idle -> processing -> tool_executing -> respondingValidates transitions; rejects illegal states
TaskQueueActorGenServerDurable task queue backed by Channel; enqueue/dequeue/ack/nackAt-least-once delivery; no external broker
HealthMonitorActorGenServerPeriodic PG membership polling via send_after; writes health snapshotsSimple polling eliminates subscription races

Part 5: Design Patterns Used in MiniClaw

The NanoClaw project introduced an important design philosophy: instead of reaching for external infrastructure when you hit a constraint, first ask whether the primitives you already have can solve the problem.

Pattern 1: Phantom Token / Credential Proxy

The constraint: Agents need to call an LLM provider, but callers should never see real API keys. Storing keys in the agent payload means any log line or bug report leaks credentials.

The actor solution: LLMRouterActor owns the credential store. It exposes a register_credential op that stores phantom_token -> real_api_key in its private KV namespace. Callers pass only the opaque token; the actor resolves the real key internally and discards it before building any response.

# Phantom token: real key stored in actor-private KV — never echoed to callers
@handler("register_credential")
def register_credential(self, phantom_token: str = "", api_key: str = "") -> dict:
    if not phantom_token or not api_key:
        return {"error": "phantom_token and api_key required"}
    host.kv_put(f"cred:{phantom_token}", api_key)  # Only this actor reads it
    return {"status": "ok", "phantom_token": phantom_token}  # api_key never returned

@handler("chat_completion")
def chat_completion(self, messages: list = None, tools: list = None,
                    phantom_token: str = "") -> dict:
    resolved_key = host.kv_get(f"cred:{phantom_token}") if phantom_token else ""
    # resolved_key used by real HTTP client; discarded here
    # ... call LLM, build response ...
    return {"status": "ok", "response": response}  # resolved_key never in response

Actor-private state means the real key is inaccessible from any other actor, any other tenant, and any logged payload. Even if a prompt injection tricks the agent into returning its full state, the real credential is not in the agent, it is in the router actor, which never echoes it back.

Pattern 2: Task Queue (TaskQueueActor)

The constraint: The orchestrator needs to enqueue work items for agents to process asynchronously but the environment already has the Channel primitive and no external message broker.

The actor solution: TaskQueueActor is a thin wrapper around host.channel. The Channel handles durability, at-least-once delivery, and redelivery on nack transparently:

# From examples/python/apps/miniclaw/infra.py
_TASK_CHANNEL = "tasks:pending"

@actor
class TaskQueueActor:
    """Thin actor wrapper around the host Channel primitive."""

    enqueued: int = state(default=0)
    completed: int = state(default=0)
    failed: int = state(default=0)

    @handler("enqueue")
    def enqueue(self, task_type: str = "generic", payload: dict = None) -> dict:
        task = {"task_type": task_type, "payload": payload or {}, "enqueued_at": host.now_ms()}
        msg_id = host.channel.send("", _TASK_CHANNEL, task_type, task)
        self.enqueued += 1
        fire_audit("task_enqueued", f"msg_id={msg_id} type={task_type}")
        return {"status": "ok", "msg_id": msg_id}

    @handler("dequeue")
    def dequeue(self, limit: int = 1, timeout_ms: int = 0) -> dict:
        tasks = []
        for _ in range(int(limit)):
            msg, ok, _ = host.channel.receive("", _TASK_CHANNEL, int(timeout_ms))
            if not ok:
                break
            tasks.append(msg)
        return {"status": "ok", "tasks": tasks, "count": len(tasks)}

    @handler("ack")
    def ack(self, msg_id: str = "") -> dict:
        host.channel.ack("", _TASK_CHANNEL, msg_id)   # commits the delivery
        self.completed += 1
        return {"status": "ok", "msg_id": msg_id}

    @handler("nack")
    def nack(self, msg_id: str = "", requeue: bool = True) -> dict:
        host.channel.nack("", _TASK_CHANNEL, msg_id, requeue)  # requeue for redelivery
        self.failed += 1
        return {"status": "ok", "msg_id": msg_id, "requeue": requeue}

PlexSpaces supports multiple providers for queues/channels such as Kafka, SQS, redis or backed by process-groups communication. The Channel primitive is built into the PlexSpaces host, durable, ordered, with explicit ack/nack semantics. If the consumer crashes mid-processing, the unacked message is redelivered on the next dequeue.

Pattern 3: Polling Over Events (HealthMonitorActor)

The constraint: We want to know the health of all service actors, but subscribing to process group membership change events introduces races: a join and a crash can arrive out of order, leaving stale membership in the subscriber’s view.

The actor solution: HealthMonitorActor never subscribes to anything. It polls every service group on a configurable interval using send_after to schedule its own next tick:

# From examples/python/apps/miniclaw/infra.py
_SERVICE_GROUPS = [
    "svc:llm_router", "svc:tool_registry", "svc:agent",
    "svc:session_manager", "svc:memory", "svc:audit",
    "svc:agent_fsm", "svc:task_queue",
]

@actor
class HealthMonitorActor:
    """Polls process group membership on a fixed interval using send_after."""

    poll_count: int = state(default=0)
    last_poll_ms: int = state(default=0)
    group_health: dict = state(default_factory=dict)
    poll_interval_ms: int = state(default=5000)

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        if args.get("poll_interval_ms"):
            iv = int(args["poll_interval_ms"])
            self.poll_interval_ms = min(max(iv, 1000), 300_000)
        host.process_groups.join("svc:health_monitor")
        host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

    @handler("poll_tick", "cast")
    def poll_tick(self) -> None:
        health = {}
        for grp in _SERVICE_GROUPS:
            try:
                members = host.process_groups.members(grp)
                health[grp] = len(members)
            except Exception:
                health[grp] = 0
        self.group_health = health
        self.poll_count += 1
        self.last_poll_ms = host.now_ms()

        import json
        host.ts.write(["health_snapshot", self.last_poll_ms, json.dumps(health)])
        # Re-arm: each tick schedules the next — no external scheduler needed
        host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})

    @handler("get_health")
    def get_health(self) -> dict:
        degraded = [g for g, c in self.group_health.items() if c == 0]
        return {
            "status": "ok",
            "group_health": self.group_health,
            "healthy": len(self.group_health) - len(degraded),
            "degraded": degraded,
        }

Polling is always correct as it converges to the true membership on every tick regardless of event order. get_health returns not just a count but a list of degraded groups, making it immediately actionable.

The Constraint-Aware Philosophy

These four patterns share a common thread: each one reaches for the primitives already available in the PlexSpaces sandbox before introducing external dependencies.

NeedNaive SolutionNanoClaw SolutionPrimitive Used
Protect API keysEnvironment variables or secrets managerPhantom token stored in actor-private KVhost.kv_put/kv_get
Async task queueRabbitMQ / SQSChannel-backed queue with ack/nackhost.channel.send/receive/ack/nack
Service health monitoringEvent subscription + fan-outPeriodic send_after poll + TupleSpace snapshothost.send_after + host.process_groups.members()
Capability discoveryService registry with TTLProcess groups + TupleSpace agent cardshost.process_groups.join/members() + host.ts.write/read_all

The WASM sandbox is not a limitation to work around instead it is the guide for designing simpler, more auditable systems.


Part 6: The Agent Loop

6.1 The Loop in Code

The AgentActor drives the core agent loop. It receives a user message, calls the LLM, checks for tool requests, executes tools, feeds results back, and repeats with a hard cap of five iterations to prevent runaway loops.

# From examples/python/apps/miniclaw/agent.py
_MAX_ITER = 5
...
    @handler("chat")
    def chat(self, message: str = "", session_id: str = "") -> dict:
        if not message:
            return {"error": "message is required"}

        self.messages.append({"role": "user", "content": message})

        # Discover tools
        tool_reg_id, _ = pg_first("svc:tool_registry")
        tools = []
        if tool_reg_id:
            resp = ask(tool_reg_id, "list_tools", {})
            if resp:
                tools = resp.get("tools", [])

        # Signal FSM: processing
        fsm_id, _ = pg_first("svc:agent_fsm")
        if fsm_id:
            host.send(fsm_id, "transition", {"op": "transition", "to": "processing"})

        final_response = ""
        for i in range(_MAX_ITER):
            llm_id, err = pg_first("svc:llm_router")
            if err or not llm_id:
                final_response = f"[no LLM] Processed: {message}"
                break

            llm_resp = ask(llm_id, "chat_completion", {"messages": [{"role": "system", "content": self.system_prompt}] + self.messages, "tools": tools}, 10000)
            if not llm_resp or "error" in llm_resp:
                final_response = f"LLM unavailable: {llm_resp}"
                break

            response = llm_resp.get("response", {})
            stop_reason = response.get("stop_reason", "end_turn")
            content = response.get("content", "")

            assistant_msg = {"role": "assistant", "content": content, "stop_reason": stop_reason}
            if response.get("tool_calls"):
                assistant_msg["tool_calls"] = response["tool_calls"]
            self.messages.append(assistant_msg)

            if stop_reason == "end_turn":
                final_response = content
                break

            if stop_reason == "tool_use":
                if fsm_id:
                    host.send(fsm_id, "transition", {"op": "transition", "to": "tool_executing"})

                for tc in response.get("tool_calls", []):
                    tc_name = tc.get("name", "")
                    tc_input = tc.get("input", {})
                    tool_output = {}
                    if tool_reg_id:
                        tool_output = ask(tool_reg_id, "execute_tool", {"name": tc_name, "input": tc_input}) or {}

                    self.messages.append({
                        "role": "tool",
                        "tool_call_id": tc.get("id", ""),
                        "content": str(tool_output),
                    })
                    fire_audit("tool_called", f"tool={tc_name} session={session_id}")

                if fsm_id:
                    host.send(fsm_id, "transition", {"op": "transition", "to": "processing"})
                final_response = f"Tool results applied (iteration {i + 1})"
            else:
                final_response = content
                break

        # FSM: responding ? idle
        if fsm_id:
            host.send(fsm_id, "transition", {"op": "transition", "to": "responding"})
            host.send(fsm_id, "transition", {"op": "transition", "to": "idle"})

        # Compact history if needed
        if len(self.messages) > self.max_history:
            keep = self.max_history // 2
            self.messages = self.messages[:1] + self.messages[-keep:]

        # Persist history in KV if session provided
        if session_id:
            import json
            host.kv_put(f"session_history:{session_id}", json.dumps(self.messages))

        self.total_chats += 1
        fire_audit("agent_chat", f"session={session_id}")
        return {
            "status": "ok",
            "response": final_response,
            "session_id": session_id,
            "messages_count": len(self.messages),
        }

The _MAX_ITER = 5 cap prevents runaway loops. In a monolithic framework, this cap requires global state or thread-local storage.


Part 7: Circuit Breakers and Immutable Audit Trails

7.1 LLM Router

The LLMRouterActor simulates an LLM with tool-call routing. In production, replace the simulation with a real API call via host.http_fetch() over a named service link:

# From examples/python/apps/miniclaw/llm_router.py
TOOL_CALL_TRIGGERS = ("weather", "search", "calculate", "lookup", "find")

# `LLMRouterActor` is a simulator in this POC. It demonstrates the routing 
# boundary where production code would call OpenAI, Anthropic, Bedrock, Gemini, or 
# an internal model endpoint through a named service link.
@actor
class LLMRouterActor:
    """Simulated LLM router with tool-calling capability."""

    model: str = state(default="miniclaw-simulated-v1")
    request_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        self.model = config.get("args", {}).get("model", self.model)
        host.process_groups.join("svc:llm_router")

    @handler("chat_completion")
    def chat_completion(self, messages: list = None, tools: list = None) -> dict:
        messages = messages or []
        tools = tools or []
        self.request_count += 1

        user_msg = ""
        for m in reversed(messages):
            if m.get("role") == "user":
                user_msg = str(m.get("content", "")).lower()
                break

        should_use_tool = tools and any(kw in user_msg for kw in TOOL_CALL_TRIGGERS)

        if should_use_tool:
            tool = tools[0] if tools else {}
            tool_name = tool.get("name", "search") if isinstance(tool, dict) else "search"
            response = {
                "stop_reason": "tool_use",
                "content": "",
                "tool_calls": [{"id": f"tc_{self.request_count}", "name": tool_name,
                                 "input": {"query": user_msg}}],
            }
        else:
            response = {
                "stop_reason": "end_turn",
                "content": f"[{self.model}] Processed: {user_msg}",
                "tool_calls": [],
            }
        return {"status": "ok", "response": response, "model": self.model}

To add a circuit breaker for production LLM rate limits, extend the actor state with circuit_open and consecutive_failures. The actor IS the circuit breaker, and the durability facet ensures the circuit state survives restarts:

@actor
class LLMRouterActor:
    model: str = state(default="gpt-4o")
    circuit_open: bool = state(default=False)
    consecutive_failures: int = state(default=0)
    request_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:llm_router")
        # Schedule circuit recovery timer
        host.send_after(30_000, "timer_tick", {"op": "timer_tick"})

    @handler("chat_completion")
    def chat_completion(self, messages: list = None, tools: list = None) -> dict:
        if self.circuit_open:
            return {"error": "circuit_open", "circuit_open": True}

        try:
            # Production: real API call via host.http_fetch("llm-api", ...)
            result = self._call_llm(messages, tools)
            self.consecutive_failures = 0
            self.request_count += 1
            return result
        except Exception as e:
            self.consecutive_failures += 1
            if self.consecutive_failures >= 3:
                self.circuit_open = True
            return {"error": str(e), "circuit_open": self.circuit_open}

    @handler("timer_tick", "cast")
    def timer_tick(self) -> None:
        # Gradual recovery: decrement failure count by 1 each tick (30s).
        # 3 failures -> 90s before circuit closes again. Prevents premature re-open.      
        if self.circuit_open and self.consecutive_failures > 0:
            self.consecutive_failures -= 1
            if self.consecutive_failures == 0:
                self.circuit_open = False
        host.send_after(30_000, "timer_tick", {"op": "timer_tick"})

7.2 Immutable Audit Trail

The AuditEventActor captures every agent action as a fire-and-forget event. Senders never block. Events flow into TupleSpace for append-only, queryable storage:

# From examples/python/apps/miniclaw/memory.py

@event_actor
class AuditEventActor:
    """GenEvent actor: fire-and-forget audit events stored in TupleSpace."""

    event_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:audit")

    @handler("log_event", "cast")
    def log_event(self, event_type: str = "", detail: str = "", timestamp: int = 0) -> None:
        ts = timestamp or host.now_ms()
        try:
            host.ts.write(["audit", event_type, ts, detail])
        except Exception as e:
            host.warn(f"AuditEvent: ts.write failed: {e}")
        self.event_count += 1

    @handler("get_stats")
    def get_stats(self) -> dict:
        return {"status": "ok", "event_count": self.event_count}

Notice the "cast" annotation on log_event, this marks the handler as fire-and-forget. The sender (fire_audit() in helpers.py) calls host.send(), not host.ask() without blocking.


Part 8: Tools as Actors with MCP-Style Isolation

8.1 Each Tool Gets Supervision, Metrics, and Fault Recovery

In MiniClaw, the ToolRegistryActor manages tool definitions and dispatches execution. Each tool handler runs within the actor’s sandboxed environment:

# From examples/python/apps/miniclaw/tool_registry.py

@actor
class ToolRegistryActor:
    """Registry of callable tools with simulated execution."""

    tools: dict = state(default_factory=dict)   # name -> tool spec
    exec_count: int = state(default=0)
    actor_id: str = state(default="")

    @init_handler
    def on_init(self, config: dict) -> None:
        self.actor_id = config.get("actor_id", "")
        self.tools = {t["name"]: t for t in _BUILTIN_TOOLS}
        host.process_groups.join("svc:tool_registry")
        host.info(f"ToolRegistryActor init actor_id={self.actor_id} tools={list(self.tools)}")

    @handler("list_tools")
    def list_tools(self) -> dict:
        return {"status": "ok", "tools": list(self.tools.values()), "count": len(self.tools)}

    @handler("register_tool")
    def register_tool(self, name: str = "", description: str = "", input_schema: dict = None) -> dict:
        if not name:
            return {"error": "name is required"}
        self.tools[name] = {"name": name, "description": description, "input_schema": input_schema or {}}
        host.info(f"ToolRegistry: registered tool={name}")
        return {"status": "ok", "name": name}

    @handler("execute_tool")
    def execute_tool(self, name: str = "", input: dict = None) -> dict:
        input = input or {}
        if name not in self.tools:
            return {"error": f"unknown tool: {name}"}

        self.exec_count += 1
        host.info(f"ToolRegistry: executing tool={name} exec={self.exec_count}")

        # Simulated responses per tool type
        if name == "web_search":
            return {"result": f"Search results for: {input.get('query', '')}"}
        if name == "calculator":
            expr = input.get("expression", "0")
            try:
				# Demo-only restricted evaluation.
				# Production code should replace this with an AST-based evaluator or a sandboxed tool actor.                    
                result = eval(expr, {"__builtins__": {}})  # noqa: S307
                return {"result": str(result)}
            except Exception:
                return {"result": f"Could not evaluate: {expr}"}
        if name == "weather":
            location = input.get("location", "unknown")
            return {"result": f"Weather in {location}: 22°C, partly cloudy"}

        return {"result": f"[simulated] {name} output for input {input}"}

    @handler("get_stats")
    def get_stats(self) -> dict:
        return {"status": "ok", "tool_count": len(self.tools), "exec_count": self.exec_count}

8.2 What Standalone MCP Servers Lack

CapabilityStandalone MCPTool-as-Actor (MiniClaw)
State persistenceIn-memory only; lost on restartDurability facet checkpoints to SQLite
Multi-tenant accessNo built-in tenant scopingRequestContext enforces tenant isolation
MetricsMust add manually per toolPer-actor invocation counts automatic
Fault toleranceProcess crash loses all stateSupervisor restarts; state restored from checkpoint
SandboxProcess boundary onlyWASM linear memory + optional Firecracker VM

Part 9: Agent Lifecycle State Machine

9.1 Scoped Memory with KV + TupleSpace Dual-Write

MemoryActor writes every memory entry to both KV (for durable point-lookup) and TupleSpace (for queryable pattern-scan across a scope):

# From examples/python/apps/miniclaw/memory.py

@actor
class MemoryActor:
    """Scoped memory backed by KV (persistent) and TupleSpace (queryable)."""

    memory_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:memory")

    @handler("store_memory")
    def store_memory(self, key: str = "", value: str = "",
                     scope: str = "global", agent_id: str = "", session_id: str = "") -> dict:
        if not key:
            return {"error": "key is required"}
        scoped_key = _scoped_key(scope, agent_id, session_id, key)
        host.kv_put(scoped_key, str(value))                     # KV: durable point-lookup
        host.ts.write(["memory", scope, key, str(value)])       # TupleSpace: queryable scan
        self.memory_count += 1
        fire_audit("memory_stored", f"scope={scope} key={key}")
        return {"status": "ok", "key": key, "scope": scope}

    @handler("recall_memory")
    def recall_memory(self, key: str = "", scope: str = "global",
                      agent_id: str = "", session_id: str = "") -> dict:
        scoped_key = _scoped_key(scope, agent_id, session_id, key)
        value = host.kv_get(scoped_key)
        return {"status": "ok", "key": key, "value": value, "found": bool(value)}

    @handler("list_memories")
    def list_memories(self, scope: str = "global") -> dict:
        try:
            tuples = host.ts.read_all(["memory", scope, None, None])
            memories = [{"key": t[2], "value": t[3]} for t in tuples if len(t) >= 4]
        except Exception:
            memories = []
        return {"status": "ok", "memories": memories, "scope": scope}


def _scoped_key(scope: str, agent_id: str, session_id: str, key: str) -> str:
    if scope == "agent" and agent_id:
        return f"mem:agent:{agent_id}:{key}"
    if scope == "session" and session_id:
        return f"mem:session:{session_id}:{key}"
    return f"mem:global:{key}"

The three scopes are not just naming conventions — they determine which memories survive across session boundaries:

ScopePersists acrossExample
globalEverything including sessions, agent restartsUser name, user preferences
agentRestarts of this specific agentAgent-specific learned facts
sessionOnly within a single session“We were discussing X” context

9.2 Session Management with KV with a Channel+User Index

SessionManagerActor stores session metadata in KV and maintains a secondary index that maps channel+user_id to session_id:

# From examples/python/apps/miniclaw/agent.py

@actor
class SessionManagerActor:
    """Manages agent session lifecycle backed by KV storage."""

    active_sessions: int = state(default=0)
    total_created: int = state(default=0)
    session_ids: list = state(default_factory=list)

    @handler("create_session")
    def create_session(self, channel: str = "web", user_id: str = "anonymous",
                       agent_id: str = "agent") -> dict:
        import json
        session_id = f"sess-{channel}-{user_id}-{host.now_ms()}"
        meta = {"session_id": session_id, "channel": channel, "user_id": user_id,
                "agent_id": agent_id, "created_at": host.now_ms(), "status": "active"}
        host.kv_put(f"session:{session_id}", json.dumps(meta))
        host.kv_put(f"session_map:{channel}:{user_id}", session_id)  # secondary index
        self.session_ids.append(session_id)
        self.active_sessions += 1
        fire_audit("session_created", f"session_id={session_id} channel={channel} user_id={user_id}")
        return {"status": "ok", "session_id": session_id}

    @handler("get_session")
    def get_session(self, session_id: str = "", channel: str = "", user_id: str = "") -> dict:
        import json
        if not session_id and channel and user_id:
            # Natural key lookup via secondary index
            session_id = host.kv_get(f"session_map:{channel}:{user_id}")
        if not session_id:
            return {"error": "session not found"}
        raw = host.kv_get(f"session:{session_id}")
        if not raw:
            return {"error": "session not found", "session_id": session_id}
        meta = json.loads(raw)
        meta["status"] = "ok"
        return meta

The secondary index means a chatbot can route an incoming webhook (which carries channel and user_id but not a session token) directly to the right session without a scan.

9.3 State Management

The AgentStateFSM tracks execution state through a finite state machine. It validates transitions at runtime and attempting idle -> responding is rejected. This catches bugs in the agent loop before they produce corrupt state.

# From examples/python/apps/miniclaw/memory.py

# Sole authoritative definition of the FSM.
# Adding a new state requires only adding it here.
_VALID_FSM_TRANSITIONS = {
    "idle": {"processing", "tool_executing"},
    "processing": {"tool_executing", "responding", "idle"},
    "tool_executing": {"processing", "idle"},
    "responding": {"idle"},
}


@fsm_actor(states=["idle", "processing", "tool_executing", "responding"], initial="idle")
class AgentStateFSM:
    """Agent lifecycle FSM: idle -> processing -> tool_executing -> responding -> idle."""

    fsm_state: str = state(default="idle")
    transition_count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.process_groups.join("svc:agent_fsm")

    @handler("transition")
    def transition(self, to: str = "") -> dict:
        allowed = _VALID_FSM_TRANSITIONS.get(self.fsm_state, set())
        if to not in allowed:
            host.debug(f"FSM: invalid transition {self.fsm_state} -> {to}")
            return {"status": "ignored", "from": self.fsm_state, "to": to}
        prev = self.fsm_state
        self.fsm_state = to
        self.transition_count += 1
        host.debug(f"FSM: {prev} -> {to}")
        return {"status": "ok", "from": prev, "to": to}

    @handler("get_state")
    def get_state(self) -> dict:
        return {"status": "ok", "state": self.fsm_state, "transitions": self.transition_count}

Operators query the FSM to see what every agent does at any moment with full observability.


Part 10: Multi-Agent Orchestration with Durable Checkpoints

The OrchestratorActor decomposes complex tasks and delegates each sub-task to the AgentActor. It uses the Workflow behavior, which checkpoints progress after each step:

# From examples/python/apps/miniclaw/orchestrator.py

@workflow_actor
class OrchestratorActor:
    """Durable workflow: decompose task -> delegate to agents -> aggregate results."""

    status: str = state(default="idle")
    task_id: str = state(default="")
    progress: int = state(default=0)

    @init_handler
    def on_init(self, config: dict) -> None:
        host.info(f"OrchestratorActor init actor_id={config.get('actor_id', '')}")

    @run_handler
    def run(self, payload: dict = None) -> dict:
        payload = payload or {}
        task = payload.get("task", "explain how agents work")
        task_id = payload.get("task_id", f"orch-{host.now_ms()}")

        self.status = "running"
        self.task_id = task_id
        self.progress = 0

        agent_id, err = pg_first("svc:agent")
        if err or not agent_id:
            self.status = "failed"
            return {"error": "no agents in svc:agent", "task_id": task_id}

        # Decompose: split on " and " for multi-step tasks
        lower = task.lower()
        idx = lower.find(" and ")
        sub_tasks = [task[:idx].strip(), task[idx + 5:].strip()] if idx >= 0 else [task]

        sub_results = []
        for i, sub_task in enumerate(sub_tasks):
            self.progress = (i + 1) * 100 // len(sub_tasks)
            resp = ask(agent_id, "chat",
                       {"message": sub_task, "session_id": f"orch-{task_id}-{i}"}, 15000)
            if not resp:
                self.status = "failed"
                return {"error": "sub-task failed", "task_id": task_id}
            # Checkpoint sub-result to TupleSpace — survives orchestrator crash
            host.ts.write(["orch_result", task_id, i, str(resp.get("response", ""))])
            sub_results.append(resp)

        summaries = [r.get("response", "") for r in sub_results if r.get("response")]
        self.status = "completed"
        self.progress = 100
        fire_audit("orchestrator_completed", f"task_id={task_id} subtasks={len(sub_tasks)}")
        return {
            "status": "ok",
            "task_id": task_id,
            "result": " | ".join(summaries),
            "sub_results": sub_results,
            "sub_tasks": len(sub_tasks),
        }

    @signal_handler("cancel")
    def cancel(self) -> None:
        self.status = "cancelled"
        host.info(f"Orchestrator cancelled task_id={self.task_id}")

    @query_handler("status")
    def query_status(self) -> dict:
        return {"task_id": self.task_id, "status": self.status, "progress": self.progress}

The @run_handler, @signal_handler, and @query_handler decorators map cleanly to the Workflow behavior’s three message types:

  • run: starts the workflow execution
  • signal: sends an out-of-band control message (e.g., cancellation mid-workflow)
  • query: reads durable workflow state without blocking the running workflow

Part 11: Multi-App Deployments

In this example all ten actors share a single WASM binary via ACTOR_REGISTRY:

# From examples/python/apps/miniclaw/miniclaw_actor.py
ACTOR_REGISTRY = {
    "llm_router":      LLMRouterActor,
    "tool_registry":   ToolRegistryActor,
    "agent":           AgentActor,
    "session_manager": SessionManagerActor,
    "orchestrator":    OrchestratorActor,
    "memory":          MemoryActor,
    "audit_event":     AuditEventActor,
    "agent_fsm":       AgentStateFSM,
    "task_queue":      TaskQueueActor,
    "health_monitor":  HealthMonitorActor,
}

This is convenient for development and single-tenant deployments. For enterprise multi-tenant deployments, you can split actors into separate applications to achieve stronger isolation:

  • llm-gateway/ – LLMRouterActor only for credential management isolated
  • agent-app/ – AgentActor + SessionManagerActor one app per tenant team
  • tools-app/ – ToolRegistryActor + MemoryActor hared tool catalog
  • audit-app/ – AuditEventActor compliance isolation
  • infra-app/ – TaskQueueActor + HealthMonitorActor

In the multi-app model, each application gets its own Firecracker microVM in production, providing hardware-level tenant isolation. Actors across applications discover each other via process groups or object registry and the code changes only in app-config.toml, not in the actor implementations.

Plugins as Deployed Apps, Not Bundled Packages

OpenClaw’s post-mortem describes a painful middle state: too much moved toward plugins, while plugins were still bundled, repaired, and dependency-loaded in startup paths. This is the monolith decomposition trap: you split the code but not the process, so startup coupling survives the refactor.

PlexSpaces avoids this by treating plugins as deployed apps, not installed packages. A channel connector, or a third-party memory backend is a separate app that exposes one or more actors. The agent loop discovers them the same way it discovers any actor via pg_first("svc:telegram-connector") or on a remote node. Adding a new integration means deploying a new app, not modifying package.json.

OpenClaw patternPlexSpaces equivalentWhat changes
Bundled channel plugins in coreChannel app deployed separatelyStartup failure in the channel app doesn’t touch the agent loop
Shared node_modules dependency graphEach app is its own WASM binarySupply-chain compromise in one app’s deps can’t reach another app
Plugin repair at startupActor restarts via one_for_one supervisorOnly the failed actor restarts; the rest keep running
Hard to decompose after the factActor boundaries are message contracts from day oneMoving an actor to its own app changes app-config.toml, not the actor code

Part 12: Security Comparison Actor Framework vs. Monolithic

Security PropertyOpenClaw / MonolithicMiniClaw / Actor-Based
State isolationShared memory; one agent reads another’s statePer-actor private state; accessible only through messages
Privilege boundarySingle process; tools share agent’s full permissionsWASM sandbox; actor can only call WIT-declared imports
Sandbox depthOS process boundary onlyWASM linear memory + Firecracker microVM hardware boundary
Tenant separationApplication-level checks; misconfiguration = data leakFramework-enforced RequestContext; no bypass possible
Tool executionIn-process; tool crash = agent crashSeparate actor; tool crash triggers supervised restart
Secret managementos.environ shared across all toolsActor-scoped KV; WASM has no env var access
Audit trailOptional; must add per toolBuilt-in @event_actor; captures all operations by default
Prompt injection blast radiusFull system access: files, network, memoryConfined to single actor’s WIT capabilities
Circuit breakerMust implement per integrationBuilt into LLMRouterActor; state survives restarts
Crash recoveryProcess restart; lose all in-flight stateActor restart; resume from durability checkpoint
Quality validationHope the LLM got it rightReflection loop + three-check guardrails + LLM-as-Judge
Failure detectionUncaught exceptions; manual health checksMonitor/link primitives; __DOWN__/__EXIT__ messages
Multi-tenant scalingShard by process; complex ops burdenCellular architecture; independent failure domains

Part 13: Running the Example

Build and Deploy

cd examples/python/apps/miniclaw
./build.sh                     # componentize-py -> WASM Component Model
./test.sh 8092                 # Deploy to running node and run full test suite

What the Test Script Validates

The test script exercises all ten actors end-to-end:

# Step 3: LLM Router — simulated chat + tool routing
ask "llm_router" '{"op":"chat_completion","messages":[{"role":"user","content":"Hello!"}],"tools":[]}'

# Step 5: Agent chat — full loop including tool use
ask "agent" '{"op":"chat","message":"Search for the weather in Paris","session_id":"test-sess-1"}'

# Step 9: Agent FSM — validate state transitions
ask "agent_fsm" '{"op":"transition","to":"processing"}'
ask "agent_fsm" '{"op":"transition","to":"responding"}'

# Step 10: Orchestrator workflow — durable multi-agent task
ask "orchestrator" '{"op":"workflow_run","task":"explain AI agents","task_id":"test-orch-1"}' 60
ask "orchestrator" '{"op":"workflow_query:status"}'

# Step 8: Task Queue — Channel-backed enqueue/dequeue/ack
ask "task_queue" '{"op":"enqueue","task_type":"send_email","payload":{"to":"bob@example.com"}}'
ask "task_queue" '{"op":"dequeue","limit":1}'
ask "task_queue" '{"op":"ack","msg_id":"..."}'

App Configuration

All ten actors are declared in app-config.toml. Each actor specifies its behavior_kind, role (used to select the right class from ACTOR_REGISTRY), and facets:

[[supervisor.children]]
name = "agent"
actor_type = "miniclaw_wasm"
role = "agent"
behavior_kind = "GenServer"
args = { role = "agent", agent_name = "general-assistant",
         system_prompt = "You are a helpful AI assistant with access to tools." }
facets = [
  { type = "virtual_actor", priority = 100, config = { idle_timeout = "10m", activation_strategy = "eager" } },
  { type = "durability", priority = 90, config = { checkpoint_interval = 3 } }
]

[[supervisor.children]]
name = "orchestrator"
actor_type = "miniclaw_wasm"
role = "orchestrator"
behavior_kind = "Workflow"            # Enables @run_handler, @signal_handler, @query_handler
args = { role = "orchestrator" }
facets = [
  { type = "virtual_actor", priority = 100, config = { idle_timeout = "10m", activation_strategy = "lazy" } },
  { type = "durability", priority = 90, config = { checkpoint_interval = 5 } }
]

[[supervisor.children]]
name = "agent_fsm"
actor_type = "miniclaw_wasm"
role = "agent_fsm"
behavior_kind = "GenFSM"              # Enables @fsm_actor state machine behavior
args = { role = "agent_fsm" }
facets = [
  { type = "virtual_actor", priority = 100, config = { idle_timeout = "30m", activation_strategy = "lazy" } },
  { type = "durability", priority = 90, config = { checkpoint_interval = 1 } }
]

The Isolation Ladder

Not every deployment needs a Firecracker VM, but every production agent system should reason explicitly about which isolation layer each component requires. MiniClaw provides a progression:

LayerMechanismWhat it contains
Message isolationActor private state; all access via host.ask/sendCross-agent state reads; accidental coupling through shared memory
Tenant isolationRequestContext JWT enforced by the frameworkCross-tenant KV, TupleSpace, and process group access
App isolationSeparate deployed apps; independent startup pathsStartup coupling; plugin dependency repair contagion across integrations
WASM isolationWIT import surface; per-actor linear memorySupply-chain attacks; filesystem, env, and exec access
Firecracker/Docker isolationVM boundary per tenantWASM JIT escape; cross-tenant kernel syscall surface

The same actor code runs at every level. The app-config.toml determines which layers are active for a given deployment. Development runs message isolation only. A single-tenant production deployment adds WASM. A multi-tenant enterprise deployment adds Firecracker/Docker.


Conclusion

MiniClaw is not a finished enterprise agent platform. It is a small proof of concept that demonstrates a different foundation for one. The important lesson is not that every agent system needs these exact ten actors. The lesson is that agent runtimes benefit when isolation, supervision, explicit messaging, durable state, scoped memory, audit, and tenant boundaries are part of the architecture from the beginning. A monolithic agent loop is easy to start with, but hard to harden later. MiniClaw takes the opposite path: split the runtime into small actors, give each actor one responsibility, constrain what it can access, supervise it when it fails, and communicate only through explicit messages. Each actor owns one responsibility: routing LLM calls, managing tools, storing session metadata, persisting memory, recording audit events, coordinating workflows, or monitoring health.

MiniClaw is implemented with PlexSpaces that provides runtime primitives such as KV, TupleSpace, Channels, timers, workflows, GenEvent, and GenFSM. It allows better fault tolerance, observability, tenant-isolation, authentication, observability, rate limiting, circuit breaker, backpressure, sandboxed execution via WebAssembly and Firecracker. This POC demonstrates the shape of the solution:

  • AgentActor models the bounded agent loop: user message -> LLM -> tool call -> repeat -> final response.
  • LLMRouterActor defines the model boundary, using a simulator where production code would call OpenAI, Anthropic, Bedrock, Gemini, or an internal model.
  • ToolRegistryActor centralizes tool registration and dispatch.
  • SessionManagerActor stores session metadata in KV.
  • MemoryActor demonstrates global, agent, and session-scoped memory.
  • AuditEventActor records non-blocking audit events through GenEvent-style fire-and-forget messaging.
  • AgentStateFSM makes lifecycle transitions explicit.
  • TaskQueueActor shows durable background work through channels.
  • HealthMonitorActor polls service-group health using actor timers.
  • OrchestratorActor demonstrates workflow-style task decomposition and result aggregation.

A production MiniClaw would harden the implementation with the following:

  • strict tenant, user, session, and tool authorization on every message;
  • safe eval like asteval; the WASM sandbox reduces but does not eliminate the risk;
  • one actor instance per tenant/session or explicit session-partitioned state;
  • add schema validation before tool execution;
  • add idempotency to task queue processing;
  • hardened tool execution with separate sandboxed tool actors for high-risk tools;
  • real LLM provider integration with retries, budgets, timeouts, backoff, and circuit breakers;
  • prompt-injection detection, output validation, and optional LLM-as-judge actors;
  • stronger memory governance, including TTLs, redaction, encryption, and deletion semantics;
  • structured audit trails with retention policies and tamper-resistant storage;
  • crash-recovery tests, chaos testing, and cross-tenant isolation tests;
  • deployment hardening for secrets, networking, service links, and Firecracker isolation.

For teams building enterprise AI agents, the real question is not whether they need isolation, auditability, tenant boundaries, tool governance, and failure recovery. They do. The question is whether they bolt those properties onto a monolithic agent process later, or start with a runtime where those properties are first-class primitives.


The full source, including the Go and Python implementations, is at github.com/bhatti/PlexSpaces.

References

February 9, 2026

Building PlexSpaces: Decades of Distributed Systems Distilled Into One Framework

Filed under: Agentic AI,Computing — admin @ 10:31 pm

I previously shared my experience with distributed systems over the last three decades that included IBM mainframes, BSD sockets, Sun RPC, CORBA, Java RMI, SOAP, Erlang actors, service meshes, gRPC, serverless functions, etc. Over the years, I kept solving the same problems in different languages, on different platforms, with different tooling. Each one of these frameworks taught me something essential but they also left something on the table. PlexSpaces pulls those lessons together into a single open-source framework: a polyglot application server that handles microservices, serverless functions, durable workflows, AI workloads, and high-performance computing using one unified actor abstraction. You write actors in Python, Rust, GO or TypeScript, compile them to WebAssembly, deploy them on-premises or in the cloud, and the framework handles persistence, fault tolerance, observability, and scaling. No service mesh. No vendor lock-in. Same binary on your laptop and in production.


Why Now?

Three things converged over the last few years that made this the right moment to build PlexSpaces:

  • WebAssembly matured. Though WebAssembly ecosystem is still evolving but WASI has stabilized enough to run real server workloads. Java promised “Write Once, Run Anywhere” — WASM actually delivers it. Docker’s creator Solomon Hykes captured it in 2019: “If WASM+WASI existed in 2008, we wouldn’t have needed to create Docker.” Today that future has arrived.
  • AI agents exploded. Every AI agent is fundamentally an actor: it maintains state (conversation history), processes messages (user queries), calls tools (side effects), and needs fault tolerance (LLM APIs fail). The actor model maps naturally to agent orchestration but existing frameworks either lack durability, lock you to one language, or require separate infrastructure.
  • Multi-cloud pressure intensified. I’ve watched teams at multiple companies build on AWS in production but struggle to develop locally. Bugs surface only after deployment because Lambda, DynamoDB, and SQS behave differently from their local mocks/simulators. Modern enterprises need code that runs identically on a developer’s laptop, on-premises, and in any cloud.

PlexSpaces addresses all three: polyglot via WASM, actor-native for AI workloads, and local-first by design.


The Lessons That Shaped PlexSpaces

Every era of distributed computing burned a lesson into my thinking. Here’s what stuck and how I applied each lesson to PlexSpaces.

  • Efficiency runs deep: When I programmed BSD sockets in C, I controlled every byte on the wire. That taught me to respect the transport layer.
    Applied: PlexSpaces uses gRPC and Protocol Buffers for binary communication not because JSON is bad, but because high-throughput systems deserve binary protocols with proper schemas.
  • Contracts prevent chaos: Sun RPC introduced me to XDR and rpcgen for defining a contract, generate the code. CORBA reinforced this with IDL. I have seen countless times where teams sprinkle Swagger annotations on code and assumes that they have APIs, which keep growing without any standards, developer experience or consistency.
    Applied: PlexSpaces follows a proto-first philosophy – every API lives in Protocol Buffers, every contract generates typed stubs across languages (See OpenAPI specs for grpc/http services).
  • Parallelism needs multiple primitives: During my PhD research, I built JavaNow – a parallel computing framework that combined Linda-style tuple spaces, MPI collective operations, and actor-based concurrency on networks of workstations. That research taught me something frameworks keep forgetting: different coordination problems need different primitives. You can’t force everything through message passing alone.
    Applied: PlexSpaces provides actors and tuple spaces and channels and process groups because real systems need all of them.
  • Developer experience decides adoption: Java RMI made remote objects feel local. JINI added service discovery. Then J2EE and EJB buried developer hearts under XML configuration.
    Applied: PlexSpaces SDK provides decorator-based development (Python), inheritance-based development (TypeScript), and annotation-based development (Rust) to eliminate boilerplate.
  • Simplicity defeats complexity every time: With SOAP, WSDL, EJB, J2EE, I watched the Java enterprise ecosystem collapse under its own weight. REST won not because it was more powerful, but because it was simpler.
    Applied: One actor abstraction with composable capabilities beats a zoo of specialized types.
  • Cross-cutting concerns belong in the platform: Spring and AOP taught me to handle observability, security, and throttling consistently. But microservices in polyglot environments broke that model. Service meshes like Istio and Dapr tried to fix it with sidecar proxies but it requires another networking hop, another layer of YAML to debug.
    Applied: PlexSpaces bakes these concerns directly into the runtime. No service mesh. No extra hops.
  • Serverless is the right idea with the wrong execution: AWS Lambda showed me the future: auto-scaling, built-in observability, zero server management. But Lambda also showed me the problem: vendor lock-in, cold starts, and the inability to run locally.
    Applied: PlexSpaces delivers serverless semantics that run identically on your laptop and in the cloud.
  • Application servers got one thing right: Despite all the complexity of J2EE, I loved one idea: the application server that hosts multiple applications. You deployed WAR files to Tomcat, and it handled routing, lifecycle, and shared services. That model survived even after EJB died.
    Applied: PlexSpaces revives this concept for the polyglot serverless era where you can deploy Python ML models, TypeScript webhooks, and Rust performance-critical code to the same node.

I also built formicary, a framework for durable executions with graph-based workflow processing. That experience directly shaped PlexSpaces’ workflow and durability abstractions.


What PlexSpaces Actually Does

PlexSpaces combines five foundational pillars into a unified distributed computing platform:

  1. TupleSpace Coordination (Linda Model): Decouples producers and consumers through associative memory. Actors write tuples, read them by pattern, and never need to know who’s on the other side.
  2. Erlang/OTP Philosophy: Supervision trees restart failed actors. Behaviors define message-handling patterns.
  3. Durable Execution: Every actor operation gets journaled. When a node crashes, the framework replays the journal and restores state exactly. Side effects get cached during replay, so external calls don’t fire twice. Inspired by Restate and my earlier work on formicary.
  4. WASM Runtime: Actors compile to WebAssembly and run in a sandboxed environment. Python, TypeScript, Rust with same deployment model, same security guarantees.
  5. Firecracker Isolation: For workloads that need hardware-level isolation, PlexSpaces supports Firecracker microVMs alongside WASM sandboxing.

Core Abstractions: Actors, Behaviors, and Facets

One Actor to Rule Them All

PlexSpaces follows a design principle I arrived at after years of watching frameworks proliferate actor types: one powerful abstraction with composable capabilities beats multiple specialized types. Every actor in PlexSpaces maintains private state, processes messages sequentially (eliminating race conditions), operates transparently across local and remote boundaries, and recovers automatically through supervision.

Actor Lifecycle

Actors move through a well-defined lifecycle — one of the details that distinguishes PlexSpaces from simpler actor frameworks:

PlexSpaces supports Virtual actors (with VirtualActorFacet inspired by Orleans Actor Model) leverage this lifecycle automatically, which activate on first message, deactivate after idle timeout, and reactivate transparently on the next message. No manual lifecycle management.

Tell vs Ask: Two Message Patterns

PlexSpaces supports two fundamental communication patterns:

  • Tell (asynchronous): The sender dispatches a message and moves on. Use this for events, notifications, and one-way commands.
  • Ask (request-reply): The sender dispatches a request and waits for a response with a timeout. Use this for queries and operations that need confirmation.
from plexspaces import actor, handler, host

@actor
class OrderService:
    @handler("place_order")
    def place_order(self, order: dict) -> dict:
        # Tell: fire-and-forget notification to analytics
        host.tell("analytics-actor", "order_placed", order)
        
        # Ask: request-reply to inventory service (5s timeout)
        inventory = host.ask("inventory-actor", "check_stock", 
                            {"sku": order["sku"]}, timeout_ms=5000)
        
        if inventory["available"]:
            return {"status": "confirmed", "order_id": order["id"]}
        return {"status": "out_of_stock"}

Behaviors: Compile-Time Patterns

Behaviors define how an actor processes messages. You choose a behavior at compile time:

BehaviorAnnotationPatternBest For
Default@actorMessage-basedGeneral purpose
GenServer@gen_server_actorRequest-replyStateful services, CRUD
GenEvent@event_actorFire-and-forgetEvent processing, logging
GenFSM@fsm_actorState machineOrder processing, approval flows
Workflow@workflow_actorDurable orchestrationLong-running processes

Facets: Runtime Capabilities

Facets attach dynamic capabilities to actors without changing the actor type. I wrote about the pattern of dynamic facets and runtime composition previously. This allows adding dynamic behavior through facets, combined with Erlang’s static behavior model. Think of facets as middleware that wraps your actor. They execute in priority order like security facets fire first, then logging, then metrics, then your business logic, then persistence:

Available facets include:

  • Infrastructure: VirtualActorFacet (Orleans-style auto-activation), DurabilityFacet (persistence + replay), MobilityFacet (actor migration)
  • Storage: KeyValueFacet, BlobStorageFacet, LockFacet
  • Communication: ProcessGroupFacet (Erlang pg2-style groups), RegistryFacet
  • Scheduling: TimerFacet (transient), ReminderFacet (durable)
  • Observability: MetricsFacet, TracingFacet, LoggingFacet
  • Security: AuthenticationFacet, AuthorizationFacet
  • Events: EventEmitterFacet (reactive patterns)

Facets compose freely, e.g., add facets=["durability", "timer", "metrics"] and your actor gains persistence, scheduled execution, and Prometheus metrics with zero additional code.

Custom Facets: Extending the Framework

The facet system opens for extension. You can build domain-specific facets and register them with the framework:

use plexspaces_core::{Facet, FacetError, InterceptResult};

pub struct FraudDetectionFacet {
    threshold: f64,
}

#[async_trait]
impl Facet for FraudDetectionFacet {
    fn name(&self) -> &str { "fraud_detection" }
    fn priority(&self) -> u32 { 200 } // Run after security, before domain logic

    async fn before_method(
        &mut self, method: &str, payload: &[u8]
    ) -> Result<InterceptResult, FacetError> {
        let score = self.score_transaction(payload).await?;
        if score > self.threshold {
            return Err(FacetError::Custom("fraud_detected".into()));
        }
        Ok(InterceptResult::Continue)
    }
}

Register it once, attach it to any actor by name. This extensibility distinguishes PlexSpaces from frameworks with fixed capability sets.


Hands-On: Building Actors in Three Languages

Let me show you how PlexSpaces works in practice across all three SDKs.

Python: Decorator-Based Development

from plexspaces import actor, state, handler

@actor
class CounterActor:
    count: int = state(default=0)

    @handler("increment")
    def increment(self, amount: int = 1) -> dict:
        self.count += amount
        return {"count": self.count}  # => {"count": 5}

    @handler("get")
    def get(self) -> dict:
        return {"count": self.count}  # => {"count": 5}

The SDK eliminates over 100 lines of WASM boilerplate. You declare state with state(), mark handlers with @handler, and return dictionaries. The framework handles serialization, lifecycle, and state management.

TypeScript: Inheritance-Based Development

import { PlexSpacesActor } from "@plexspaces/sdk";

interface CounterState { count: number; }

export class CounterActor extends PlexSpacesActor<CounterState> {
  getDefaultState(): CounterState { return { count: 0 }; }

  onIncrement(payload: Record<string, unknown>) {
    const amount = Number(payload.amount ?? 1);
    this.state.count += amount;
    return { count: this.state.count };  // => {"count": 5}
  }

  onGet() { return { count: this.state.count }; }
}

Rust: Annotation-Based Development

use plexspaces_sdk::{gen_server_actor, plexspaces_handlers, handler, json};

#[gen_server_actor]
struct Counter { count: i32 }

#[plexspaces_handlers]
impl Counter {
    #[handler("increment")]
    async fn increment(&mut self, _ctx: &ActorContext, msg: &Message)
        -> Result<serde_json::Value, BehaviorError> {
        let payload: serde_json::Value = serde_json::from_slice(&msg.payload)?;
        self.count += payload["amount"].as_i64().unwrap_or(1) as i32;
        Ok(json!({ "count": self.count }))  // => {"count": 5}
    }
}

Building, Deploying, and Invoking

# Build Python actor to WebAssembly
plexspaces-py build counter_actor.py -o counter.wasm

# Deploy to a running node
curl -X POST http://localhost:8094/api/v1/deploy \
  -F "namespace=default" \
  -F "actor_type=counter" \
  -F "wasm=@counter.wasm"

# Invoke via HTTP — FaaS-style (POST = tell, GET = ask)
curl -X POST "http://localhost:8080/api/v1/actors/default/default/counter" \
  -H "Content-Type: application/json" \
  -d '{"action":"increment","amount":5}'

# Request-reply on GET
curl "http://localhost:8080/api/v1/actors/default/default/counter" \
  -H "Content-Type: application/json"
# => {"count": 5}

That’s it. No Kubernetes manifests. No Terraform. No sidecar containers. Deploy a WASM module, invoke it over HTTP. The same endpoint works as an AWS Lambda Function URL.


Durable Execution: Crash and Recover Without Losing State

Durable execution solves a problem I’ve encountered at every company I’ve worked for: what happens when a node crashes mid-operation?

PlexSpaces journals every actor operation, when messages received, side effects executed, state changes applied. When a node crashes and restarts, the framework loads the latest checkpoint and replays journal entries from that point. Side effects return cached results during replay, so external API calls don’t fire twice.

Example: A Durable Bank Account

from plexspaces import actor, state, handler

@actor(facets=["durability"])
class BankAccount:
    balance: int = state(default=0)
    transactions: list = state(default_factory=list)

    @handler("deposit")
    def deposit(self, amount: int = 0) -> dict:
        self.balance += amount
        self.transactions.append({
            "type": "deposit", "amount": amount,
            "balance_after": self.balance
        })
        return {"status": "ok", "balance": self.balance}

    @handler("withdraw")
    def withdraw(self, amount: int = 0) -> dict:
        if amount > self.balance:
            return {"status": "insufficient_funds", "balance": self.balance}
        self.balance -= amount
        self.transactions.append({
            "type": "withdraw", "amount": amount,
            "balance_after": self.balance
        })
        return {"status": "ok", "balance": self.balance}

    @handler("replay")
    def replay_transactions(self) -> dict:
        """Rebuild balance from transaction log to verify consistency."""
        rebuilt = 0
        for tx in self.transactions:
            rebuilt += tx["amount"] if tx["type"] == "deposit" else -tx["amount"]
        return {
            "replayed": len(self.transactions),
            "rebuilt_balance": rebuilt,
            "current_balance": self.balance,
            "consistent": rebuilt == self.balance
        }

Adding facets=["durability"] activates journaling and checkpointing. If the node crashes after processing ten deposits, the framework restores all ten sono data loss, no duplicate charges. Periodic checkpoints accelerate recovery by 90%+ and the framework loads the latest snapshot and replays only recent entries.


Data-Parallel Actors: Worker Pools and Scatter-Gather

When I built JavaNow during my PhD, I implemented MPI-style scatter-gather and parallel map operations. PlexSpaces brings these patterns to production through ShardGroups adata-parallel actor pools inspired by the DPA paper. A ShardGroup partitions data across multiple actor shards and supports three core operations:

  • Bulk Update: Routes writes to the correct shard based on a partition key (hash, consistent hash, or range)
  • Parallel Map: Queries all shards simultaneously and collects results
  • Scatter-Gather: Broadcasts a query and aggregates responses with fault tolerance

Example: Data-Parallel Worker Pool with Scatter-Gather

This pattern comes from the PlexSpaces examples. Each worker actor in the ShardGroup holds a partition of state and processes tasks independently and the framework handles routing, fan-out, and aggregation:

#[gen_server_actor]
pub struct WorkerActor {
    worker_id: String,
    state: Arc<RwLock<HashMap<String, Value>>>,
    tasks_processed: u64,
    total_processing_time_ms: u64,
}

#[plexspaces_handlers]
impl WorkerActor {
    #[handler("*")]
    async fn process(&mut self, _ctx: &ActorContext, msg: &Message)
        -> Result<Value, BehaviorError> {
        let payload: Value = serde_json::from_slice(&msg.payload)?;
        match payload["action"].as_str().unwrap_or("unknown") {
            "set" => {
                let key = payload["key"].as_str().unwrap_or("default");
                self.state.write().await.insert(key.to_string(), payload["value"].clone());
                self.tasks_processed += 1;
                Ok(json!({ "action": "set", "key": key, "worker_id": self.worker_id }))
            }
            "get_total_count" => {
                let state = self.state.read().await;
                let total: u64 = state.values().filter_map(|v| v.as_u64()).sum();
                Ok(json!({
                    "total": total, "worker_id": self.worker_id,
                    "keys_processed": state.len()
                }))
            }
            "stats" => {
                let avg_time = if self.tasks_processed > 0 {
                    self.total_processing_time_ms / self.tasks_processed
                } else { 0 };
                Ok(json!({
                    "worker_id": self.worker_id,
                    "tasks_processed": self.tasks_processed,
                    "avg_processing_time_ms": avg_time,
                    "keys_in_state": self.state.read().await.len()
                }))
            }
            _ => Err(BehaviorError::ProcessingError(format!("Unknown action")))
        }
    }
}

The #[handler("*")] wildcard routes all messages to a single dispatch method — the worker decides what to do based on the action field. Each worker tracks its own processing statistics, so you can identify hot shards or slow workers.

The orchestration code shows all three data-parallel operations in sequence including bulk update, parallel map, and parallel reduce:

// Create a pool of 20 workers with hash-based partitioning
let pool_id = client.create_worker_pool(
    "worker-pool-1", "worker", 20,
    PartitionStrategy::PartitionStrategyHash,
    HashMap::new(),
).await?;

// Bulk update: route 10,000 messages to the right shard by key
let mut updates = HashMap::new();
for i in 0..10_000 {
    let key = format!("key-{:05}", i);
    updates.insert(key.clone(), json!({ "action": "set", "key": key, "value": i }));
}
client.parallel_update(&pool_id, updates,
    ConsistencyLevel::ConsistencyLevelEventual, false).await?;

// Parallel map: query every worker simultaneously
let results = client.parallel_map(&pool_id,
    json!({ "action": "get_total_count" })).await?;
// => 20 responses, one per worker, each with its partition's total

// Parallel reduce: aggregate stats across all workers
let stats = client.parallel_reduce(&pool_id,
    json!({ "action": "stats" }),
    ShardGroupAggregationStrategy::ShardGroupAggregationConcat, 20).await?;
// => Combined stats: tasks_processed, avg_processing_time_ms per worker

parallel_update routes each key to its shard via consistent hashing: 10,000 messages fan out across 20 workers without the caller managing any routing logic. parallel_map broadcasts a query to every shard and collects results. parallel_reduce does the same but aggregates the responses using a configurable strategy (concat, sum, merge). This maps directly to distributed ML (partition model parameters across shards, push gradient updates through parallel_update, collect the full parameter set via parallel_map) or any workload that benefits from partitioned state with scatter-gather queries.


TupleSpace: Linda’s Associative Memory for Coordination

During my PhD work on JavaNow, I was blown away by the simplicity of Linda’s tuple space model for writing data flow based applications for coordination with different actors. The actors communicate through direct message passing, tuple spaces provide associative shared memory where producers write tuples, consumers read or take them with blocking or non-blocking patterns. This decouples components in three dimensions: spatial (actors don’t need references to each other), temporal (producers and consumers don’t need to run simultaneously), and pattern-based (consumers retrieve data by structure, not by address).

from plexspaces import actor, handler, host
import json

@actor
class OrderProducer:
    @handler("create_order")
    def create_order(self, order_id: str, items: list) -> dict:
        # Write a tuple — any consumer can pick it up
        host.ts_write(json.dumps(["order", order_id, "pending", items]))
        return {"status": "created", "order_id": order_id}

@actor
class OrderProcessor:
    @handler("process_next")
    def process_next(self) -> dict:
        # Take the next pending order (destructive read — removes from space)
        pattern = json.dumps(["order", None, "pending", None])  # Wildcards
        result = host.ts_take(pattern)
        if result:
            data = json.loads(result)
            order_id = data[1]
            # Process order, then write completion tuple
            host.ts_write(json.dumps(["order", order_id, "completed", data[3]]))
            return {"processed": order_id}
        return {"status": "no_pending_orders"}

I use TupleSpace heavily for dataflow pipelines: each stage writes results as tuples, and downstream stages pick them up by pattern. Stages can run at different speeds, on different nodes, in different languages. The tuple space absorbs the mismatch.


Batteries Included: Everything You Need, Built In

At every company I’ve worked at, the first three months after adopting a framework go to integrating storage, messaging, and locks. PlexSpaces ships all of these as built-in services in the same codebase, no extra infrastructure, no service mesh.

What’s in the Box

ServiceBackendsWhat It Does
Key-Value StoreSQLite, PostgreSQL, Redis, DynamoDBDistributed KV storage with TTL
Blob StorageMinIO/S3, GCS, Azure BlobLarge object storage with presigned URLs
Distributed LocksSQLite, PostgreSQL, Redis, DynamoDBLease-based mutual exclusion
Process GroupsBuilt-inErlang pg2-style group messaging and pub/sub
ChannelsInMemory, Redis, Kafka, NATS, SQS, SQLite, UDPQueue and topic messaging
Object RegistrySQLite, PostgreSQL, DynamoDBService discovery with TTL + gossip
ObservabilityBuilt-inMetrics (Prometheus), tracing (OpenTelemetry), structured logging
SecurityBuilt-inJWT auth (HTTP), mTLS (gRPC), tenant isolation, secret masking

PlexSpaces uses adapters pattern to plug different implementation of channels, object-registry, tuple-space based on config. For example, PlexSpaces auto-selects the best available backend for channel using a priority chain and availability (Kafka -> SQS -> NATS -> ProcessGroup -> UDP Multicast -> InMemory). Start developing with in-memory channels, deploy to production with Kafka without code changes. Actors using non-memory channels also support graceful shutdown: they stop accepting new messages but complete in-progress work.

Multi-Tenancy: Enterprise-Grade Isolation

PlexSpaces enforces two-level tenant isolation. The tenant_id comes from JWT tokens (HTTP) or mTLS certificates (gRPC). The namespace provides sub-tenant isolation for environments/applications. All queries filter by tenant automatically at the repository layer. This gives you secure multi-tenant deployments without trusting application code to enforce boundaries.

Example: Payment Processing with Built-In Services

from plexspaces import actor, handler, host

@actor(facets=["durability", "metrics"])
class PaymentProcessor:
    @handler("process_refund")
    def process_refund(self, tx_id: str, amount: int) -> dict:
        # Distributed lock prevents duplicate refunds
        lock_version = host.lock_acquire(f"refund:{tx_id}", 5000)
        if not lock_version:
            return {"error": "could_not_acquire_lock"}

        try:
            # Store refund record in built-in key-value store
            host.kv_put(f"refund:{tx_id}", json.dumps({
                "amount": amount, "status": "processed"
            }))
            return {"status": "refunded", "amount": amount}
        finally:
            host.lock_release(f"refund:{tx_id}", lock_version)

No Redis cluster to manage. No DynamoDB table to provision. The framework handles it.

Process Groups: Erlang pg2-Style Communication

Process groups provide distributed pub/sub and group messaging, which is one of Erlang’s most powerful patterns. Here’s a chat room that demonstrates joining, broadcasting, and member queries:

from plexspaces import actor, handler, host

@actor
class ChatRoom:
    @handler("join")
    def join_room(self, room_name: str) -> dict:
        actor_id = host.get_actor_id()
        host.process_groups.join(room_name, actor_id)
        return {"status": "joined", "room": room_name}

    @handler("send")
    def send_message(self, room_name: str, text: str) -> dict:
        host.process_groups.publish(room_name, {"text": text})
        return {"status": "sent"}

    @handler("members")
    def get_members(self, room_name: str) -> dict:
        members = host.process_groups.get_members(room_name)
        return {"room": room_name, "members": members}

Groups support topic-based subscriptions within groups and scope automatically by tenant_id and namespace.


Polyglot Development: One Server, Many Languages

A single PlexSpaces node hosts actors written in different languages simultaneously: Python ML models, TypeScript webhook handlers, and Rust performance-critical paths sharing the same actor runtime, storage services, and observability stack:

Same WASM module deploys anywhere: no Docker images, no container registries, no “it works on my machine”:

# Build and deploy to on-premises
plexspaces-py build ml_model.py -o ml_model.wasm
curl -X POST http://on-prem:8094/api/v1/deploy \
  -F "namespace=prod" -F "actor_type=ml_model" -F "wasm=@ml_model.wasm"

# Deploy to cloud — same command, same binary
curl -X POST http://cloud:8094/api/v1/deploy \
  -F "namespace=prod" -F "actor_type=ml_model" -F "wasm=@ml_model.wasm"

Common Patterns

Over three decades, I’ve watched the same architectural patterns emerge at every company and every scale. PlexSpaces supports the most important ones natively.

Durable Workflows with Signals and Queries

Long-running processes with automatic recovery, external signals, and read-only queries — think order fulfillment, onboarding flows, or CI/CD pipelines:

from plexspaces import workflow_actor, state, run_handler, signal_handler, query_handler

@workflow_actor(facets=["durability"])
class OrderWorkflow:
    order_id: str = state(default="")
    status: str = state(default="pending")
    steps_completed: list = state(default_factory=list)

    @run_handler
    def run(self, input_data: dict) -> dict:
        """Main execution — exclusive, one at a time."""
        self.order_id = input_data.get("order_id", "")
        self.status = "validating"
        self.steps_completed.append("validation")
        self.status = "charging"
        self.steps_completed.append("payment")
        self.status = "shipping"
        self.steps_completed.append("shipment")
        self.status = "completed"
        return {"status": "completed", "order_id": self.order_id}

    @signal_handler("cancel")
    def on_cancel(self, data: dict) -> None:
        """External signals can alter workflow state."""
        self.status = "cancelled"

    @query_handler("status")
    def get_status(self) -> dict:
        """Read-only queries can run concurrently with execution."""
        return {"order_id": self.order_id, "status": self.status,
                "steps": self.steps_completed}

Staged Event-Driven Architecture (SEDA)

Chain processing stages through channels. Each stage runs at its own pace, and channels provide natural backpressure:

Leader Election

Distributed locks elect a leader with lease-based failover. The leader holds a lock and renews it periodically. If the leader crashes, the lease expires and another candidate acquires leadership:

@actor
class LeaderElection:
    candidate_id: str = state(default="")
    lock_version: str = state(default="")

    @handler("try_lead")
    def try_lead(self, candidate_id: str = None) -> dict:
        holder_id = candidate_id or self.candidate_id
        result = host.lock_acquire("", "leader-election", holder_id, "leader", 30, 0)
        if result and not result.startswith("ERROR"):
            self.lock_version = json.loads(result).get("version", result)
            return {"leader": True, "candidate_id": holder_id}
        return {"leader": False}

Resource-Based Affinity

Label actors with hardware requirements (gpu: true, memory: high) and PlexSpaces schedules them on matching nodes. This maps naturally to ML training pipelines where different stages need different hardware.

Cellular Architecture

PlexSpaces organizes nodes into cells using the SWIM protocol (gossip-based node discovery). Cells provide fault isolation, geographic distribution, and low-latency routing to the nearest cell. Nodes within a cell share channels via the cluster_name configuration, enabling UDP multicast for low-latency cluster-wide messaging.


How PlexSpaces Compares

PlexSpaces doesn’t replace any single framework, it unifies patterns from many. Here’s what it borrows from each, and what limitation of each it addresses:

FrameworkWhat PlexSpaces BorrowsLimitation PlexSpaces Addresses
Erlang/OTPGenServer, supervision, “let it crash”BEAM-only; no polyglot WASM
AkkaActor model, message passingNo longer open source; JVM-only
OrleansVirtual actors, grain lifecycle.NET-only; no tuple spaces or HPC
TemporalDurable workflows, replayRequires separate server infrastructure
RestateDurable execution, journalingNo full actor model; no HPC patterns
RayDistributed ML, parameter serversPython-centric; no durable execution
AWS LambdaServerless invocation, auto-scalingVendor lock-in; no local dev parity
Azure Durable FunctionsDurable orchestrationAzure-only; limited language support
Golem CloudWASM-based durabilityNo built-in storage/messaging/locks
DaprSidecar service mesh, virtual actorsExtra networking hop; state management limits

Key Differentiators

  • No service mesh: Built-in observability, security, and throttling eliminate the extra networking hop
  • Local-first: Same code runs on your laptop and in production. No cloud-only surprises.
  • Polyglot via WASM: Write actors in Python, Rust, TypeScript. Same deployment model.
  • Batteries included: KV store, blob storage, locks, channels, process groups — all built in
  • One abstraction: Composable facets on a unified actor, not a zoo of specialized types
  • Application server model: Deploy multiple polyglot applications to a single node
  • Research-grade + production-ready: Linda tuple spaces, MPI patterns, and Erlang supervision in a single framework

Getting Started

Install and Run

# Docker (fastest)
docker run -p 8080:8080 -p 8000:8000 -p 8001:8001 plexobject/plexspaces:latest

# From source
git clone https://github.com/bhatti/PlexSpaces.git
cd PlexSpaces && make build

Write -> Build -> Deploy -> Invoke

# greeter.py
from plexspaces import actor, state, handler

@actor
class GreeterActor:
    greetings_count: int = state(default=0)

    @handler("greet")
    def greet(self, name: str = "World") -> dict:
        self.greetings_count += 1
        return {"message": f"Hello, {name}!", "total": self.greetings_count}
plexspaces-py build greeter.py -o greeter.wasm
curl -X POST http://localhost:8094/api/v1/deploy \
  -F "namespace=default" -F "actor_type=greeter" -F "wasm=@greeter.wasm"
curl -X POST "http://localhost:8080/api/v1/actors/default/default/greeter?invocation=call" \
  -H "Content-Type: application/json" -d '{"action":"greet","name":"PlexSpaces"}'
# => {"message": "Hello, PlexSpaces!", "total": 1}

Explore more in the examples directory: bank accounts with durability, task queues with distributed locks, leader election, chat rooms with process groups, and more.


Lessons Learned

After decades of distributed systems, I keep returning to the same truths:

  • Efficiency matters. Respect the transport layer. Binary protocols with schemas outperform JSON for high-throughput systems.
  • Contracts prevent chaos. Define APIs before implementations. Generate code from schemas.
  • Simplicity defeats complexity. Every framework that collapsed like EJB, SOAP, CORBA did under the weight of accidental complexity. One powerful abstraction beats ten specialized ones.
  • Developer experience decides adoption. If your framework requires 100 lines of boilerplate for a counter, developers will choose the one that needs 15.
  • Local and production must match. Every bug I’ve seen that “only happens in production” stemmed from environmental differences.
  • Cross-cutting concerns belong in the platform. Scatter them across codebases and you get inconsistency. Centralize them in a service mesh and you get latency. Build them in.
  • Multiple coordination primitives solve multiple problems. Actors handle request-reply. Channels handle pub/sub. Tuple spaces handle coordination. Process groups handle broadcast. Real systems need all of them.

The distributed systems landscape keeps changing as WASM is maturing, AI agents are creating new coordination challenges, and enterprises are pushing back on vendor lock-in harder than ever. I believe the next generation of frameworks will converge on the patterns PlexSpaces brings together: polyglot runtimes, durable actors, built-in infrastructure, and local-first deployment. PlexSpaces distills years of lessons into a single framework. It’s the framework I wished existed at every company I’ve worked for that handles the infrastructure so I can focus on the problem.


PlexSpaces is open source at github.com/bhatti/PlexSpaces. Try the counter example and provide your feedback.

November 4, 2025

Building a Production-Grade Enterprise AI Platform with vLLM: A Complete Guide from the Trenches

Filed under: Agentic AI — admin @ 11:48 am

TL;DR: Tested open-source LLM serving (vLLM) on GCP L4 GPUs. Achieved 93% cost savings vs OpenAI GPT-4, 100% routing accuracy, and 91% cache hit rates. Prototype proves feasibility; production requires 5-7 months additional work (security, HA, ops). All code at github.com/bhatti/vllm-tutorial.

Background

Last year, our CEO mandated “AI adoption” across the organization and everyone had access to LLMs through an internal portal that used Vertex AI. However, there was a little training or best practices. I saw engineers using the most expensive models for simple queries, no cost tracking, zero observability into what was being used, and no policies around data handling. People tried AI, built some demos and got mixed results.

This mirrors what’s happening across the industry. Recent research shows 95% of AI pilots fail at large companies, and McKinsey found 42% of companies abandoned generative AI projects citing “no significant bottom line impact.” The 5% that succeed do something fundamentally different: they treat AI as infrastructure requiring proper tooling, not just API access.

This experience drove me to explore better approaches. I built prototypes using vLLM and open-source tools, tested them on GCP L4 GPUs, and documented what actually works. This blog shares those findings with real code, benchmarks, and lessons from building production-ready AI infrastructure. Every benchmark ran on actual hardware (GCP L4 GPUs), every pattern emerged from solving real problems, and all code is available at github.com/bhatti/vllm-tutorial.


Why Hosted LLM Access Isn’t Enough

Even with managed services like Vertex AI or Bedrock, enterprise AI needs additional layers that most organizations overlook:

Cost Management

  • No intelligent routing between models (GPT-4 for simple definitions that Phi-2 could handle)
  • No per-user, per-team budgets or limits
  • No cost attribution or chargeback
  • Result: Unpredictable expenses, no accountability

Observability

  • Can’t track which prompts users send
  • Can’t identify failing queries or quality degradation
  • Can’t measure actual usage patterns
  • Result: Flying blind when issues occur

Security & Governance

  • Data flows through third-party infrastructure
  • No granular access controls beyond API keys
  • Limited audit trails for compliance
  • Result: Compliance gaps, security risks

Performance Control

  • Can’t deploy custom fine-tuned models
  • No A/B testing between models
  • Limited control over routing logic
  • Result: Vendor lock-in, inflexibility

The Solution: vLLM with Production Patterns

After evaluating options, I built prototypes using vLLM—a high-performance inference engine for running open-source LLMs (Llama, Mistral, Phi) on your infrastructure. Think of vLLM as NGINX for LLMs: battle-tested, optimized runtime that makes production deployments feasible.

Why vLLM specifically?

  • PagedAttention: Revolutionary memory management enabling 22.5x higher throughput
  • Continuous batching: Automatically batches requests for maximum efficiency
  • Production-ready: Used by major companies, not experimental
  • Open source: Full control, no vendor lock-in

What I tested:

  • Intelligent model routing (complexity-based selection)
  • Budget enforcement (hard limits, not just monitoring)
  • Prefix caching (80% cost reduction)
  • Quantization (3.7x memory reduction with AWQ)
  • Complete observability (Prometheus + Grafana + Langfuse)
  • Production error handling (retries, circuit breakers, fallbacks)

System Architecture

Here’s the complete system architecture I’ve built and tested:

Production AI requires three monitoring layers:

Layer 1: Infrastructure (Prometheus + Grafana)

  • GPU utilization, memory usage
  • Request rate, error rate, latency (P50, P95, P99)
  • Integration via /metrics endpoint that vLLM exposes
  • Grafana dashboards visualize trends and trigger alerts

Layer 2: Application Metrics

  • Time to First Token (TTFT), tokens per second
  • Cost per request, model distribution
  • Budget tracking (daily, monthly limits)
  • Custom Prometheus metrics embedded in application code

Layer 3: LLM Observability (Langfuse)

  • Full prompt/response history for debugging
  • Cost attribution per user/team
  • Quality tracking over time
  • Essential for understanding what users actually do

Here’s what I’ve built and tested:


Setting Up Your Environment: GCP L4 GPU Setup

Before we dive into the concepts, let’s get your environment ready. I’m using GCP L4 GPUs because they offer the best price/performance for this workload ($0.45/hour), but the code works on any CUDA-capable GPU.

Minimum Hardware Requirements

  • NVIDIA GPU with 16GB+ VRAM (L4, T4, A10G, A100)
  • 4 CPU cores
  • 16GB RAM
  • 100GB disk space

Step 1: Create GCP L4 Instance

# Create instance with L4 GPU
gcloud compute instances create vllm-test \
  --zone=us-central1-a \
  --machine-type=g2-standard-8 \
  --accelerator=type=nvidia-l4,count=1 \
  --image-family=ubuntu-2004-lts \
  --image-project=ubuntu-os-cloud \
  --boot-disk-size=200GB \
  --boot-disk-type=pd-ssd \
  --maintenance-policy=TERMINATE

# SSH into instance
gcloud compute ssh vllm-test --zone=us-central1-a

Step 2: Install CUDA 11.8

# Update system
sudo apt update && sudo apt upgrade -y

# Install CUDA 11.8
wget https://developer.download.nvidia.com/compute/cuda/11.8.0/local_installers/cuda_11.8.0_520.61.05_linux.run
sudo sh cuda_11.8.0_520.61.05_linux.run --silent --toolkit

# Add to PATH
echo 'export PATH=/usr/local/cuda-11.8/bin:$PATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=/usr/local/cuda-11.8/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc
source ~/.bashrc

# Verify
nvidia-smi  # Should show your L4 GPU
nvcc --version  # Should show CUDA 11.8

Troubleshooting: If nvidia-smi doesn’t work, reboot the instance: sudo reboot

Step 3: Install Python Dependencies

# Install Python 3.10
sudo apt install -y python3.10 python3.10-venv python3-pip

# Clone the repository
git clone https://github.com/bhatti/vllm-tutorial.git
cd vllm-tutorial

# Create virtual environment
python3 -m venv venv
source venv/bin/activate

# Install dependencies
pip install --upgrade pip
pip install -r requirements.txt

Step 4: Verify Installation

# Test vLLM installation
python -c "import vllm; print(f'vLLM version: {vllm.__version__}')"

# Quick functionality test
python examples/01_basic_vllm.py

Expected output:

Loading model microsoft/phi-2...
Model loaded in 8.3 seconds

Generating response...
Generated 50 tokens in 987ms
Throughput: 41.5 tokens/sec

? vLLM is working!

Quick Start

Before we dive deep, let’s get something running:

  1. Clone the repo:
   git clone https://github.com/bhatti/vllm-tutorial.git
   cd vllm-tutorial
  1. If you have a GPU available:
   # Follow setup instructions in README
   python examples/01_basic_vllm.py
  1. No GPU? Run the benchmarks locally:
   # See the actual results from GCP L4 testing
   cat benchmarks/results/01_throughput_results.json
  1. Explore the code:

Core Concept 1: Intelligent Model Routing

The problem: Not all queries need your most expensive model.

  • “What is EBITDA?” needs a 30-word definition ? Use Phi-2 ($0.0001)
  • “Analyze Microsoft’s 10-K risk factors…” needs deep reasoning ? Use Llama-3-8B ($0.0003)

Most teams send everything to their best model, which is wasteful.

The solution: Route queries to the right model based on complexity.

The Three-Tier Routing Strategy

TierModelUse CasesCost (per 1K tokens)% of Queries
SimplePhi-2 (2.7B)Definitions, facts$0.0001 / 1K60%
MediumMistral-7BSummaries, comparisons$0.0002 / 1K30%
ComplexLlama-3-8BAnalysis, reasoning$0.0003 / 1K10%

Routing Decision Flow

Implementation: Complexity Classification

Here’s how I classify query complexity:

def classify_complexity(self, prompt: str) -> str:
    """
    Classify prompt complexity to select appropriate model

    Rules:
    - Simple: Definitions, quick facts, <50 words
    - Medium: Summaries, comparisons, 50-150 words
    - Complex: Deep analysis, multi-step reasoning, >150 words
    """
    word_count = len(prompt.split())

    # Keywords indicating complexity
    complex_keywords = [
        "analyze", "compare", "evaluate", "assess risk",
        "recommend", "predict", "forecast", "implications"
    ]

    medium_keywords = [
        "summarize", "explain", "describe", "list",
        "what are", "how does", "differences"
    ]

    has_complex = any(kw in prompt.lower() for kw in complex_keywords)
    has_medium = any(kw in prompt.lower() for kw in medium_keywords)

    # Classification logic
    if word_count > 150 or has_complex:
        return "complex"
    elif word_count > 50 or has_medium:
        return "medium"
    else:
        return "simple"

Why this works:

  • Length is a strong signal (detailed questions need detailed answers)
  • Keywords indicate intent (“analyze” needs more reasoning than “define”)
  • Conservative defaults (when in doubt, route up)

Testing Results

I tested this with 11 queries on GCP L4. Here are the actual results:

Query: "What is EBITDA?"
Classified as: simple ? Routed to: Phi-2
Cost: $0.00002038
Latency: 4,843ms (first request, includes model loading)
Quality: ? Perfect (simple definition)

Query: "Summarize Apple's Q4 2024 earnings highlights"
Classified as: medium ? Routed to: Mistral-7B
Cost: $0.00000865
Latency: 4,827ms
Quality: ? Good summary

Query: "Analyze Microsoft's 10-K risk factors and assess their potential impact on future earnings"
Classified as: complex ? Routed to: Llama-3-8B
Cost: $0.00001382
Latency: 4,836ms
Quality: ? Detailed analysis

Accuracy: 100% (11/11 queries routed correctly)
Cost savings: 30% vs routing everything to the most expensive model

Complete Router

Here’s the full intelligent router (you can find this in src/intelligent_router.py):

from typing import Dict, Optional
from dataclasses import dataclass
from vllm import LLM, SamplingParams

@dataclass
class ModelConfig:
    """Configuration for a model tier"""
    name: str
    complexity: str  # "simple", "medium", "complex"
    cost_per_1k_tokens: float
    max_tokens: int

class IntelligentRouter:
    """
    Production-ready intelligent router with:
    - Complexity-based routing
    - Budget enforcement
    - Cost tracking
    - Fallback handling
    """

    def __init__(self, daily_budget_usd: float = 100.0):
        self.daily_budget_usd = daily_budget_usd
        self.total_cost_today = 0.0

        # Model configurations
        self.models = {
            "phi-2": ModelConfig(
                name="microsoft/phi-2",
                complexity="simple",
                cost_per_1k_tokens=0.0001,
                max_tokens=1024,
            ),
            "mistral-7b": ModelConfig(
                name="mistralai/Mistral-7B-Instruct-v0.2",
                complexity="medium",
                cost_per_1k_tokens=0.0002,
                max_tokens=2048,
            ),
            "llama-3-8b": ModelConfig(
                name="meta-llama/Meta-Llama-3-8B",
                complexity="complex",
                cost_per_1k_tokens=0.0003,
                max_tokens=4096,
            ),
        }

        # Initialize LLM (in production, these would be separate instances)
        self.llm = LLM(
            model=self.models["phi-2"].name,
            trust_remote_code=True,
            gpu_memory_utilization=0.9,
        )

    def route_request(self, prompt: str, max_tokens: int = 200) -> Dict:
        """
        Route request to appropriate model

        Returns:
            Dict with 'response', 'model_used', 'cost', 'latency_ms'
        """
        # Step 1: Classify complexity
        complexity = self.classify_complexity(prompt)

        # Step 2: Select model
        model_id = self._select_model(complexity)
        model_config = self.models[model_id]

        # Step 3: Check budget
        estimated_cost = self._estimate_cost(model_config, prompt, max_tokens)
        if self.total_cost_today + estimated_cost > self.daily_budget_usd:
            # Budget exceeded - fallback to cheapest model
            model_id = "phi-2"
            model_config = self.models[model_id]

        # Step 4: Generate response
        sampling_params = SamplingParams(
            temperature=0.7,
            top_p=0.9,
            max_tokens=max_tokens,
        )

        start_time = time.time()
        outputs = self.llm.generate([prompt], sampling_params)
        latency_ms = (time.time() - start_time) * 1000

        # Step 5: Track cost
        tokens_generated = len(outputs[0].outputs[0].token_ids)
        actual_cost = self._calculate_cost(model_config, prompt, tokens_generated)
        self.total_cost_today += actual_cost

        return {
            "response": outputs[0].outputs[0].text,
            "model_used": model_id,
            "cost_usd": actual_cost,
            "latency_ms": latency_ms,
            "tokens_generated": tokens_generated,
        }

    def _select_model(self, complexity: str) -> str:
        """Select model based on complexity"""
        for model_id, config in self.models.items():
            if config.complexity == complexity:
                return model_id
        return "phi-2"  # Default fallback

    def _estimate_cost(self, config: ModelConfig, prompt: str, max_tokens: int) -> float:
        """Estimate cost before generation"""
        input_tokens = len(prompt) / 4  # Rough estimate
        total_tokens = input_tokens + max_tokens
        return (total_tokens / 1000) * config.cost_per_1k_tokens

    def _calculate_cost(self, config: ModelConfig, prompt: str, tokens_generated: int) -> float:
        """Calculate actual cost after generation"""
        input_tokens = len(prompt) / 4
        total_tokens = input_tokens + tokens_generated
        return (total_tokens / 1000) * config.cost_per_1k_tokens

How to use it:

# Initialize router with daily budget
router = IntelligentRouter(daily_budget_usd=100.0)

# Route a simple query
result = router.route_request("What is gross margin?")
print(f"Model used: {result['model_used']}")  # phi-2
print(f"Cost: ${result['cost_usd']:.6f}")     # $0.000020

# Route a complex query
result = router.route_request(
    "Analyze Tesla's competitive positioning in the EV market "
    "and provide investment recommendations based on recent trends"
)
print(f"Model used: {result['model_used']}")  # llama-3-8b
print(f"Cost: ${result['cost_usd']:.6f}")     # $0.000138

Core Concept 2: Budget Enforcement

The problem: Monitoring costs isn’t the same as preventing them.

I have seen hundreds of thousands spent on a company AI hackathon because developers were using expensive models needlessly.

The solution: Hard limits that reject requests before they burn your budget.

The Three Levels of Budget Control

from dataclasses import dataclass
from datetime import datetime

@dataclass
class BudgetConfig:
    """Budget configuration with multiple enforcement levels"""
    max_cost_per_request: float = 0.50        # Level 1: prevent accidents
    daily_budget_usd: float = 100.0           # Level 2: daily cap
    monthly_budget_usd: float = 3000.0        # Level 3: monthly cap
    warning_threshold_pct: float = 0.80       # Warn at 80%

class BudgetEnforcer:
    """Hard budget enforcement - prevents spending, not just monitors"""
    
    def __init__(self, config: BudgetConfig):
        self.config = config
        self.daily_spend = 0.0
        self.monthly_spend = 0.0
        # ... implementation
    
    def check_budget(self, estimated_cost: float) -> Dict:
        """Check BEFORE generating - this is the key difference"""
        
        # Level 1: Per-request limit
        if estimated_cost > self.config.max_cost_per_request:
            return {"action": "reject", "reason": "Request too expensive"}
        
        # Level 2: Daily budget
        if self.daily_spend + estimated_cost > self.config.daily_budget_usd:
            return {"action": "downgrade", "reason": "Daily limit approaching"}
        
        # Level 3: Monthly budget
        if self.monthly_spend + estimated_cost > self.config.monthly_budget_usd:
            return {"action": "downgrade", "reason": "Monthly limit approaching"}
        
        return {"action": "allow"}

Best Practices

  • Set conservative limits initially
  • Monitor budget utilization trends
  • Implement graceful degradation
  • Track who’s using what

Core Concept 3: Prefix Caching

Problem: You’re paying to process the same content repeatedly.

In enterprise AI, you typically have a structure like this:

[Fixed System Prompt - 500 tokens]
You are a financial analyst AI assistant specializing in:
- Earnings report analysis
- SEC filing interpretation
- Market sentiment analysis
...

[User Query - 50 tokens]
What is EBITDA?

[Response - 100 tokens]
EBITDA stands for...

Total tokens: 650 (500 system + 50 query + 100 response)
What you pay for: All 650 tokens, every single request

The Solution: Prefix Caching

vLLM has a feature called “prefix caching” that solves this elegantly:

How to Enable It

from vllm import LLM

# WITHOUT prefix caching
llm = LLM(
    model="microsoft/phi-2",
    trust_remote_code=True,
)

# WITH prefix caching (80% cost reduction!)
llm = LLM(
    model="microsoft/phi-2",
    trust_remote_code=True,
    enable_prefix_caching=True,  # <-- That's it!
)

Testing Results

I tested this on GCP L4 with our end-to-end integration test. Here are the actual numbers:

Test setup:

  • Fixed system prompt: 500 tokens
  • 11 different user queries: 15-290 tokens each
  • Model: Phi-2 (2.7B)

Results WITHOUT prefix caching:

Request 1: $0.00010188 (full cost)
Request 2: $0.00010188 (full cost)
Request 3: $0.00010188 (full cost)
...
Total: $0.00112068 (11 × $0.00010188)

Results WITH prefix caching:

Request 1: $0.00002038 (full cost - establishes cache)
Request 2: $0.00000414 (80% cheaper - uses cache!)
Request 3: $0.00000409 (80% cheaper)
Request 4: $0.00000865 (80% cheaper)
...
Total: $0.00010031

Savings: $0.00102037 (91% reduction!)
Cache hit rate: 90.9% (10/11 requests)

Here is what just happened:

  • Same 11 queries
  • Same model
  • Same responses
  • One parameter change
  • 91% cost reduction

Best use cases:

  • RAG systems (fixed context, many questions): 80% savings
  • Template generation (fixed format, variable content): 70% savings
  • Conversations (history grows, new turns added): 50% savings

When it doesn’t help:

  • Every request is unique (no repeated prefix)
  • Prefix changes frequently (cache invalidated)
  • Very short queries (overhead dominates)

Rule of thumb: If you have a fixed prefix >200 tokens reused across requests, enable prefix caching.


Core Concept 4: Quantization

The problem: The models you want don’t fit in the GPUs you can afford.

  • Llama-3-70B in full precision: Requires 140GB GPU memory
  • Your budget: Maybe a 24GB L4 GPU
  • The gap: 116GB short

The solution: Use fewer bits per number with minimal quality loss, e.g., converting FP16 into INT8.

Quantization Schemes

MethodMemoryCompressionQuality LossWorks On
FP16 (baseline)19.3 GB0%All GPUs
AWQ5.2 GB3.7×~2%L4, A100
FP8~9.7 GB~1%H100 only

I’ve tested three quantization approaches on GCP L4:

1. FP8 (8-bit floating point)

  • Compression: 2x (FP16 ? FP8)
  • Quality: ~99% of original
  • Speed: Same or faster (better memory bandwidth)
  • Limitation: Requires H100 GPU (NOT supported on L4)

2. AWQ (Activation-aware Weight Quantization)

  • Compression: 3.7x (FP16 ? W4A16)
  • Quality: ~98% of original
  • Speed: Slightly slower than FP16
  • Limitation: Requires pre-quantized model

3. GPTQ (Post-training quantization)

  • Compression: 3.5x (FP16 ? INT4)
  • Quality: ~97% of original
  • Speed: Similar to AWQ
  • Limitation: Longer quantization process

Benchmark Results

I ran quantization benchmarks on GCP L4 with Phi-2. Here’s what I measured (from benchmarks/04_quantization_comparison.py):

# Benchmark code
class QuantizationBenchmark:
    def benchmark_quantization(self, quantization: str):
        """
        Test quantization scheme

        Args:
            quantization: "none" (FP16), "fp8", "awq", or "gptq"
        """
        llm_kwargs = {
            "model": "microsoft/phi-2",
            "trust_remote_code": True,
            "gpu_memory_utilization": 0.9,
            "max_model_len": 1024,
        }

        # Add quantization if specified
        if quantization != "none":
            llm_kwargs["quantization"] = quantization

        # Load model
        start = time.time()
        llm = LLM(**llm_kwargs)
        load_time = time.time() - start

        # Measure memory
        gpu_memory = torch.cuda.memory_allocated() / 1e9  # GB

        # Benchmark generation
        prompt = "Explain quantum computing in simple terms"
        sampling_params = SamplingParams(max_tokens=100)

        start = time.time()
        outputs = llm.generate([prompt], sampling_params)
        latency_ms = (time.time() - start) * 1000

        return {
            "quantization": quantization,
            "memory_gb": gpu_memory,
            "load_time_sec": load_time,
            "latency_ms": latency_ms,
            "tokens_per_sec": 100 / (latency_ms / 1000),
        }

How to Use AWQ Quantization

The easiest approach is using pre-quantized models from HuggingFace:

from vllm import LLM

# Option 1: Use pre-quantized AWQ model
llm = LLM(
    model="TheBloke/Mistral-7B-Instruct-v0.2-AWQ",  # Pre-quantized!
    quantization="awq",
    trust_remote_code=True,
    gpu_memory_utilization=0.9,
)

# That's it! 3.7x smaller, ready to use

Available AWQ models (from TheBloke on HuggingFace):

  • Llama-2-7B-AWQ
  • Llama-2-13B-AWQ
  • Mistral-7B-Instruct-v0.2-AWQ
  • CodeLlama-7B-AWQ
  • Mixtral-8x7B-AWQ

Memory savings example:

# Mistral-7B in FP16
llm_fp16 = LLM(model="mistralai/Mistral-7B-Instruct-v0.2")
# Memory: ~16GB VRAM
# Fits on: A100-40GB, L4 (barely)

# Mistral-7B in AWQ
llm_awq = LLM(
    model="TheBloke/Mistral-7B-Instruct-v0.2-AWQ",
    quantization="awq"
)
# Memory: ~4.3GB VRAM
# Fits on: T4-16GB, L4 (comfortably), even RTX 3090

# Savings: 72% memory reduction!

When to Use Quantization

? Use quantization when:

  • You’re memory-constrained (model doesn’t fit)
  • You want to use cheaper GPUs
  • Quality loss <2% is acceptable
  • You’re deploying at scale (cost matters)

? Skip quantization when:

  • You have unlimited GPU budget (rare!)
  • You need absolute maximum quality
  • Model already fits comfortably
  • You’re still prototyping (optimize later)

My recommendation: Start with AWQ for all production deployments. The cost savings alone justify it, and quality loss is negligible for most tasks.


Core Concept 5: Complete Observability

The problem: When your AI system breaks, you need to know what, when, why, and who.

The solution: Three monitoring layers.

The Three Layers of AI Observability

Layer 1: Infrastructure (What Prometheus tracks)

GPU metrics:

  • Memory usage (prevent out-of-memory)
  • Utilization (optimize capacity)
  • Temperature (hardware health)

Service metrics:

  • Request rate (traffic patterns)
  • Error rate (system health)
  • Latency percentiles (user experience)

Layer 2: Application Metrics (AI-specific)

  • Time to First Token (TTFT)
  • Inter-Token Latency (ITL)
  • Tokens per second
  • Cost per request
  • Model distribution
  • Tool: Custom metrics in Prometheus

Layer 3: LLM Observability (Content-level)

  • What prompts are users sending?
  • What responses are being generated?
  • Cost attribution per user/team
  • Quality trends over time
  • Tool: Langfuse or Arize Phoenix

Custom Application Metrics

Here’s how I export custom metrics from the vLLM application – Layer 2 (from src/observability_monitoring.py):

from prometheus_client import Counter, Histogram, Gauge, generate_latest
from typing import Dict
import time

class VLLMMetrics:
    """
    Production metrics for vLLM serving

    Tracks:
    - Request counts (total, success, failure)
    - Latency distributions (P50, P95, P99)
    - Token throughput
    - Cost tracking
    - Model distribution
    """

    def __init__(self):
        # Request counters
        self.requests_total = Counter(
            'vllm_requests_total',
            'Total number of requests',
            ['model', 'status']
        )

        # Latency histogram
        self.latency = Histogram(
            'vllm_latency_ms',
            'Request latency in milliseconds',
            ['model'],
            buckets=[10, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
        )

        # Token metrics
        self.tokens_generated = Counter(
            'vllm_tokens_generated_total',
            'Total tokens generated',
            ['model']
        )

        self.tokens_per_second = Gauge(
            'vllm_tokens_per_second',
            'Current tokens per second',
            ['model']
        )

        # Cost tracking
        self.cost_usd = Counter(
            'vllm_cost_usd_total',
            'Total cost in USD',
            ['model']
        )

        self.daily_cost = Gauge(
            'vllm_daily_cost_usd',
            'Cost today in USD'
        )

        # GPU memory
        self.gpu_memory_used = Gauge(
            'vllm_gpu_memory_used_gb',
            'GPU memory used in GB'
        )

        self.gpu_memory_total = Gauge(
            'vllm_gpu_memory_total_gb',
            'Total GPU memory in GB'
        )

        # Cache metrics
        self.cache_hit_rate = Gauge(
            'vllm_cache_hit_rate',
            'Prefix cache hit rate'
        )

    def record_request(
        self,
        model: str,
        latency_ms: float,
        tokens: int,
        cost_usd: float,
        success: bool,
        cached: bool = False
    ):
        """Record request metrics"""

        # Update counters
        status = "success" if success else "failure"
        self.requests_total.labels(model=model, status=status).inc()

        if success:
            # Latency
            self.latency.labels(model=model).observe(latency_ms)

            # Tokens
            self.tokens_generated.labels(model=model).inc(tokens)
            tokens_per_sec = tokens / (latency_ms / 1000)
            self.tokens_per_second.labels(model=model).set(tokens_per_sec)

            # Cost
            self.cost_usd.labels(model=model).inc(cost_usd)

    def update_gpu_memory(self):
        """Update GPU memory metrics"""
        if torch.cuda.is_available():
            used_gb = torch.cuda.memory_allocated() / 1e9
            total_gb = torch.cuda.get_device_properties(0).total_memory / 1e9

            self.gpu_memory_used.set(used_gb)
            self.gpu_memory_total.set(total_gb)

    def export_metrics(self) -> str:
        """Export Prometheus metrics"""
        return generate_latest().decode('utf-8')

# Usage in FastAPI
from fastapi import FastAPI

app = FastAPI()
metrics = VLLMMetrics()

@app.post("/generate")
async def generate(request: GenerateRequest):
    start = time.time()

    try:
        # Generate response
        result = llm.generate(request.prompt)

        # Record success metrics
        latency_ms = (time.time() - start) * 1000
        metrics.record_request(
            model="phi-2",
            latency_ms=latency_ms,
            tokens=len(result.tokens),
            cost_usd=calculate_cost(result),
            success=True,
        )

        return result

    except Exception as e:
        # Record failure
        latency_ms = (time.time() - start) * 1000
        metrics.record_request(
            model="phi-2",
            latency_ms=latency_ms,
            tokens=0,
            cost_usd=0,
            success=False,
        )
        raise

@app.get("/metrics")
async def get_metrics():
    """Prometheus scrape endpoint"""
    metrics.update_gpu_memory()
    return Response(
        content=metrics.export_metrics(),
        media_type="text/plain"
    )

What this tracks:

  • ? Request rate (by model, by status)
  • ? Latency distribution (with percentiles)
  • ? Token throughput (tokens/sec)
  • ? Cost tracking (per model, daily total)
  • ? GPU memory usage
  • ? Cache hit rates

Integration code for Langfuse – Layer 3 (from examples/05_llm_observability.py):

from langfuse import Langfuse
import os

# Initialize Langfuse
langfuse = Langfuse(
    public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
    secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
    host=os.getenv("LANGFUSE_HOST", "http://localhost:3001"),
)

def generate_with_observability(prompt: str, user_id: str, metadata: Dict = None):
    """Generate response with full Langfuse tracing"""

    # Create trace
    trace = langfuse.trace(
        name="financial_analysis",
        user_id=user_id,
        metadata=metadata or {},
    )

    # Start generation span
    generation = trace.generation(
        name="vllm_generate",
        model="microsoft/phi-2",
        input=prompt,
        metadata={
            "quantization": "awq",
            "max_tokens": 200,
        }
    )

    # Generate
    start = time.time()
    result = llm.generate(prompt)
    latency_ms = (time.time() - start) * 1000

    # Calculate cost
    tokens_in = len(prompt) / 4
    tokens_out = len(result.tokens)
    cost_usd = ((tokens_in + tokens_out) / 1000) * 0.0001

    # End span with metrics
    generation.end(
        output=result.text,
        usage={
            "input_tokens": int(tokens_in),
            "output_tokens": tokens_out,
            "total_tokens": int(tokens_in + tokens_out),
        },
        metadata={
            "latency_ms": latency_ms,
            "cost_usd": cost_usd,
            "model": "phi-2",
        }
    )

    return result

# Usage
result = generate_with_observability(
    prompt="Analyze Apple's Q4 earnings",
    user_id="analyst_001",
    metadata={
        "team": "equity_research",
        "department": "finance",
    }
)

You can see following in Langfuse dashboard:

  • Every prompt and response
  • Cost per request, per user, per team
  • Latency trends over time
  • Token usage patterns
  • Quality scores (if you add feedback)
  • Prompt versions (track what works)

Alerting Strategy

You can configure Langfuse with alerting with various severity such as:

Critical (PagerDuty/Phone):

  • Service down
  • Error rate >10%
  • Daily budget exceeded by 50%
  • GPU out of memory

Warning (Slack):

  • Error rate >5%
  • P95 latency >1000ms
  • Daily budget at 80%
  • GPU memory >95%

Info (Email):

  • Daily usage summary
  • Cost reports
  • Quality metrics

Observability isn’t optional for production AI—it’s essential.


Core Concept 6: Production Error Handling

Your AI system will fail. GPUs crash, networks drop, users send garbage, budgets get exceeded.

Error Handling Pattern Flow

Five essential patterns:

Pattern 1: Retry with Exponential Backoff

Here is a retry logic (from examples/07_advanced_error_handling.py):

from typing import Callable
from dataclasses import dataclass
import time

@dataclass
class RetryConfig:
    """Retry configuration"""
    max_retries: int = 3
    initial_delay: float = 1.0
    max_delay: float = 60.0
    exponential_base: float = 2.0

def retry_with_backoff(config: RetryConfig = RetryConfig()):
    """
    Decorator: Retry with exponential backoff

    Example:
        @retry_with_backoff()
        def generate_text(prompt):
            return llm.generate(prompt)
    """
    def decorator(func: Callable) -> Callable:
        def wrapper(*args, **kwargs):
            delay = config.initial_delay

            for attempt in range(config.max_retries):
                try:
                    return func(*args, **kwargs)

                except Exception as e:
                    if attempt == config.max_retries - 1:
                        raise  # Last attempt, re-raise

                    error_type = classify_error(e)

                    # Don't retry on invalid input
                    if error_type == ErrorType.INVALID_INPUT:
                        raise

                    print(f"??  Attempt {attempt + 1} failed: {error_type.value}")
                    print(f"   Retrying in {delay:.1f}s...")
                    time.sleep(delay)

                    # Exponential backoff
                    delay = min(delay * config.exponential_base, config.max_delay)

            raise RuntimeError(f"Failed after {config.max_retries} retries")

        return wrapper
    return decorator

# Usage
@retry_with_backoff(RetryConfig(max_retries=3, initial_delay=1.0))
def generate_with_retry(prompt: str):
    """Generate with automatic retry on failure"""
    return llm.generate(prompt)

# This will retry up to 3 times with exponential backoff
result = generate_with_retry("Analyze earnings report")

Pattern 2: Circuit Breaker

When a service starts failing repeatedly, stop calling it:

from datetime import datetime, timedelta
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing, reject requests
    HALF_OPEN = "half_open"  # Testing recovery

class CircuitBreaker:
    """
    Circuit breaker for fault tolerance

    Prevents cascading failures by stopping calls to
    failing services
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        timeout: int = 60,
        expected_exception: type = Exception
    ):
        self.failure_threshold = failure_threshold
        self.timeout = timeout
        self.expected_exception = expected_exception

        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED

    def call(self, func: Callable, *args, **kwargs):
        """Execute function with circuit breaker protection"""

        if self.state == CircuitState.OPEN:
            # Check if timeout elapsed
            if datetime.now() - self.last_failure_time > timedelta(seconds=self.timeout):
                self.state = CircuitState.HALF_OPEN
                print("? Circuit breaker: HALF_OPEN (testing recovery)")
            else:
                raise RuntimeError("Circuit breaker OPEN - service unavailable")

        try:
            result = func(*args, **kwargs)

            # Success - reset if recovering
            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.CLOSED
                self.failure_count = 0
                print("? Circuit breaker: CLOSED (service recovered)")

            return result

        except self.expected_exception as e:
            self.failure_count += 1
            self.last_failure_time = datetime.now()

            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
                print(f"? Circuit breaker: OPEN (threshold {self.failure_threshold} reached)")

            raise

# Usage
circuit_breaker = CircuitBreaker(failure_threshold=5, timeout=60)

def generate_protected(prompt: str):
    """Generate with circuit breaker protection"""
    return circuit_breaker.call(llm.generate, prompt)

# If llm.generate fails 5 times, circuit breaker opens
# Requests fail fast for 60 seconds
# Then one test request (half-open)
# If successful, normal operation resumes

This prevents:

  • Thundering herd problem
  • Resource exhaustion
  • Long timeouts on every request

Pattern 3: Rate Limiting

Protect your system from overload:

import time

class RateLimiter:
    """
    Token bucket rate limiter

    Limits requests per second to prevent overload
    """

    def __init__(self, max_requests: int, time_window: float = 1.0):
        self.max_requests = max_requests
        self.time_window = time_window
        self.tokens = max_requests
        self.last_update = time.time()

    def acquire(self, tokens: int = 1) -> bool:
        """Try to acquire tokens, return True if allowed"""

        now = time.time()
        elapsed = now - self.last_update

        # Refill tokens based on elapsed time
        self.tokens = min(
            self.max_requests,
            self.tokens + (elapsed / self.time_window) * self.max_requests
        )
        self.last_update = now

        if self.tokens >= tokens:
            self.tokens -= tokens
            return True
        else:
            return False

    def wait_for_token(self, tokens: int = 1):
        """Wait until token is available"""
        while not self.acquire(tokens):
            time.sleep(0.1)

# Usage
rate_limiter = RateLimiter(max_requests=100, time_window=1.0)

@app.post("/generate")
async def generate(request: GenerateRequest):
    # Check rate limit
    if not rate_limiter.acquire():
        raise HTTPException(
            status_code=429,
            detail="Rate limit exceeded (100 req/sec)"
        )

    # Process request
    result = llm.generate(request.prompt)
    return result

Why this matters:

  • Prevents DoS (accidental or malicious)
  • Protects GPU from overload
  • Ensures fair usage

Pattern 4: Fallback Strategies

When primary fails, don’t just error—degrade gracefully:

def generate_with_fallback(prompt: str) -> str:
    """
    Try multiple strategies before failing

    Strategy 1: Primary model (Llama-3-8B)
    Strategy 2: Cached response (if available)
    Strategy 3: Simpler model (Phi-2)
    Strategy 4: Template response
    """

    # Try primary model
    try:
        return llm_primary.generate(prompt)

    except Exception as e:
        print(f"??  Primary model failed: {e}")

        # Fallback 1: Check cache
        cached_response = cache.get(prompt)
        if cached_response:
            print("? Returning cached response")
            return cached_response

        # Fallback 2: Try simpler model
        try:
            print("? Falling back to Phi-2")
            return llm_simple.generate(prompt)

        except Exception as e2:
            print(f"??  Fallback model also failed: {e2}")

            # Fallback 3: Template response
            return (
                "I apologize, but I'm unable to process your request right now. "
                "Please try again in a few minutes, or contact support if the issue persists."
            )

# User never sees "Internal Server Error"
# They always get SOME response

Graceful degradation examples:

  • Can’t generate full analysis? Return summary
  • Can’t use complex model? Use simple model
  • Can’t generate? Return cached response
  • Everything failing? Return polite error message

Pattern 5: Timeout Handling

Don’t let requests hang forever:

import signal

class TimeoutError(Exception):
    pass

def timeout_handler(signum, frame):
    raise TimeoutError("Request timed out")

def generate_with_timeout(prompt: str, timeout_seconds: int = 30):
    """Generate with timeout"""

    # Set timeout
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(timeout_seconds)

    try:
        result = llm.generate(prompt)

        # Cancel timeout
        signal.alarm(0)
        return result

    except TimeoutError:
        print(f"? Request timed out after {timeout_seconds}s")
        return "Request timed out. Please try a shorter prompt."

# Or using asyncio
import asyncio

async def generate_with_timeout_async(prompt: str, timeout_seconds: int = 30):
    """Generate with async timeout"""

    try:
        result = await asyncio.wait_for(
            llm.generate_async(prompt),
            timeout=timeout_seconds
        )
        return result

    except asyncio.TimeoutError:
        return "Request timed out. Please try a shorter prompt."

Why timeouts matter:

  • Prevent resource leaks
  • Free up GPU for other requests
  • Give users fast feedback

Combined Example

Here’s how I combine all patterns:

from fastapi import FastAPI, HTTPException
from circuitbreaker import CircuitBreaker, CircuitBreakerError

app = FastAPI()

# Initialize components
circuit_breaker = CircuitBreaker(failure_threshold=5, timeout=60)
rate_limiter = RateLimiter(max_requests=100, time_window=1.0)
cache = ResponseCache(ttl=3600)

@app.post("/generate")
@retry_with_backoff(max_retries=3)
async def generate(request: GenerateRequest):
    """
    Generate with full error handling:
    - Rate limiting
    - Circuit breaker
    - Retry with backoff
    - Timeout
    - Fallback strategies
    - Caching
    """

    # Rate limiting
    if not rate_limiter.acquire():
        raise HTTPException(status_code=429, detail="Rate limit exceeded")

    # Check cache first
    cached = cache.get(request.prompt)
    if cached:
        return {"text": cached, "cached": True}

    try:
        # Circuit breaker protection
        result = circuit_breaker.call(
            generate_with_timeout,
            request.prompt,
            timeout_seconds=30
        )

        # Cache successful response
        cache.set(request.prompt, result)

        return {"text": result, "status": "success"}

    except CircuitBreakerError:
        # Circuit breaker open - return fallback
        return {
            "text": "Service temporarily unavailable. Using cached response.",
            "status": "degraded",
            "fallback": True
        }

    except TimeoutError:
        raise HTTPException(status_code=504, detail="Request timed out")

    except Exception as e:
        # Log error
        logger.error(f"Generation failed: {e}")

        # Return graceful error
        return {
            "text": "I apologize, but I'm unable to process your request.",
            "status": "error",
            "fallback": True
        }

What this provides:

  • ? Prevents overload (rate limiting)
  • ? Fast failure (circuit breaker)
  • ? Automatic recovery (retry)
  • ? Resource protection (timeout)
  • ? Graceful degradation (fallback)
  • ? Performance (caching)

Deployment Recommendations

While my testing remained at POC level, these patterns prepare for production deployment:

Before deploying:

Load Testing

  • Test with expected peak load (10-100x normal traffic)
  • Measure P95 latency under load (<500ms target)
  • Verify error rate stays <1%
  • Confirm GPU memory stable (no leaks)

Production Deployment Checklist

Before going live, verify:

Infrastructure:

  • [ ] GPU drivers installed and working (nvidia-smi)
  • [ ] Docker and Docker Compose installed
  • [ ] Sufficient disk space (200GB+ for models)
  • [ ] Network configured (firewall rules, security groups)
  • [ ] SSL/TLS certificates (for HTTPS)

Configuration:

  • [ ] Model name set correctly in .env
  • [ ] Quantization configured (AWQ recommended)
  • [ ] GPU memory utilization set (0.9 typical)
  • [ ] Prefix caching enabled (ENABLE_PREFIX_CACHING=True)
  • [ ] Budget limits configured
  • [ ] Log level appropriate (info for prod)

Monitoring:

  • [ ] Prometheus scraping vLLM metrics
  • [ ] Grafana dashboard imported and working
  • [ ] Alerts configured in alert_rules.yml
  • [ ] Alert destinations set (PagerDuty, Slack, email)
  • [ ] Langfuse set up (if using LLM observability)

Testing:

  • [ ] Health check returns 200 OK
  • [ ] Can generate completions via API
  • [ ] Metrics endpoint returning data
  • [ ] Error handling works (try invalid input)
  • [ ] Budget limits enforced (if configured)
  • [ ] Load test passed (see next section)

Security:

  • [ ] API authentication enabled
  • [ ] Rate limiting configured
  • [ ] HTTPS enforced (no HTTP)
  • [ ] CORS policies set
  • [ ] Input validation in place
  • [ ] Secrets not in git (use env variables)

Operations:

  • [ ] Backup strategy for logs
  • [ ] Model cache backed up
  • [ ] Runbook written (how to handle incidents)
  • [ ] On-call rotation defined
  • [ ] SLAs documented
  • [ ] Disaster recovery plan

Real-World Results

Testing on GCP L4 GPUs with 11 queries produced these validated results:

End-to-End Integration Test Results

Test configuration:

  • Model: Phi-2 (2.7B parameters)
  • Quantization: None (FP16 baseline)
  • Prefix caching: Enabled
  • Budget: $10/day
  • Hardware: GCP L4 GPU

Results:

MetricValue
Total Requests11
Success Rate100% (11/11) ?
Total Tokens Generated2,200
Total Cost$0.000100
Average Latency5,418ms
Cache Hit Rate90.9% ?
Budget Utilization0.001%

Model distribution:

  • Phi-2: 54.5% (6 requests)
  • Llama-3-8B: 27.3% (3 requests)
  • Mistral-7B: 18.2% (2 requests)

What this proves:
? Intelligent routing works (3 models selected correctly)
? Budget enforcement works (under budget, no overruns)
? Prefix caching works (91% hit rate = huge savings)
? Multi-model support works (distributed correctly)
? Observability works (all metrics collected)

Cost Comparison

Let me show you the exact cost calculations:

Per-request costs (from actual test):

Request 1 (uncached): $0.00002038
Requests 2-11 (cached): $0.00000414 average

Total: $0.00010031 for 11 requests
Average: $0.0000091 per request

Extrapolated monthly costs (10,000 requests/day):

ConfigurationDaily CostMonthly CostSavings
Without caching$0.91$27.30Baseline
With caching (91% hit rate)$0.18$5.4680%
With quantization (AWQ)$0.09$2.7390%
All optimizations$0.09$2.7390%

Add in infrastructure costs:

GCP L4 GPU: $0.45/hour = $328/month

Total monthly cost:
- Infrastructure: $328
- API costs: $2.73
- Total: $330.73/month for 10,000 requests/day

Compare to OpenAI:

OpenAI GPT-4:
- Input: $0.03 per 1K tokens
- Output: $0.06 per 1K tokens
- Average request: 100 tokens in + 100 tokens out = $0.009
- 10,000 requests/day = $90/day = $2,700/month

Savings: $2,369/month (88% cheaper!)

Benchmark Results Summary

Here are all the benchmark results from GCP L4:

1. Throughput Benchmark (benchmarks/01_throughput_comparison.py)

Batch SizeTokens/SecLatency (ms)Speedup
141.59871x
4165.82474x
8331.61248x
16663.26216x
32934.49922.5x

Key insight: Batching provides massive throughput improvements (22.5x!)

2. Memory Efficiency (benchmarks/02_memory_efficiency.py)

Batch SizeMemory Used (GB)Overhead
119.30Baseline
419.33+0.16%
819.38+0.41%
1619.45+0.78%
3219.58+1.45%

Key insight: PagedAttention keeps memory growth near zero even with large batches

3. Cost Analysis (benchmarks/03_cost_analysis.py)

ScenarioCost/Monthvs GPT-4
OpenAI GPT-4$666Baseline
OpenAI GPT-3.5$15-98%
vLLM Phi-2 (FP16)$324-51%
vLLM + AWQ$87-87%
vLLM + AWQ + Caching$65-90%
All optimizations$45-93%

Key insight: Self-hosting with vLLM is 93% cheaper than OpenAI GPT-4

4. Quantization (benchmarks/04_quantization_comparison.py)

SchemeMemory (GB)CompressionQuality Loss
FP1619.31x0%
AWQ5.23.7x~2%

Key insight: AWQ provides 3.7x compression with minimal quality loss

What validated:
? Intelligent routing correctly classified 100% of queries
? Budget enforcement prevented overruns
? Prefix caching delivered promised 80% savings
? Multi-model support distributed load appropriately
? Observability captured all metrics accurately

What Surprised Me

Good surprises:

  1. Cache hit rates higher than expected – I expected 70%, got 91%
  2. Quantization quality loss minimal – Barely noticeable in real use
  3. vLLM stability – Zero crashes during testing
  4. Cost savings magnitude – 93% cheaper than GPT-4 is huge

Challenges:

  1. FP8 not supported on L4 – Had to use AWQ instead (still great)
  2. First request slow – Model loading takes 8 seconds (then fast)
  3. Large context memory usage – 2K tokens works, 4K+ needs more GPU

ROI Calculation (50,000 requests/day)

Option A: OpenAI GPT-4

Cost per request: $0.009
Daily: $450
Monthly: $13,500
Annual: $162,000

Option B: vLLM on GCP L4 (our solution)

Infrastructure: $328/month
API costs (with optimizations): $13.65/month
Monthly total: $341.65
Annual: $4,100

Savings: $157,900/year (97%)

Break-even:

Setup time: 2 days engineering ($2,000)
Maintenance: 4 hours/month ($200/month)

Year 1:
  Savings: $157,900
  Costs: $2,000 setup + $2,400 maintenance = $4,400
  Net: $153,500 saved

ROI: 3,500% in year 1

At scale (500,000 requests/day):

OpenAI GPT-4: $1,350,000/year
vLLM solution: $41,000/year

Savings: $1,309,000/year (97%)

Production Readiness Checklist

Based on testing, here’s what enterprise deployment requires:

Security & Compliance:

  • Authentication/authorization at API level
  • Data encryption (rest and transit)
  • PII detection and redaction capabilities
  • Audit logs for compliance (GDPR, HIPAA)
  • Network security (VPC, firewalls, no public exposure)

Operational Excellence:

  • Comprehensive monitoring (3 layers: infra, app, LLM)
  • Alerting strategy (critical/warning/info tiers)
  • Structured logging with aggregation
  • Backup/recovery procedures tested
  • Incident response runbook documented

Performance & Scale:

  • Load testing validates capacity
  • P95 latency meets SLAs (<500ms)
  • Success rate >99.9% under load
  • Auto-scaling strategy defined
  • Capacity planning for 2x, 5x, 10x growth

Cost Governance:

  • Hard budget limits (daily, monthly)
  • Per-user and per-team tracking
  • Cost dashboards for visibility
  • Automated alerts at 80%, 100%
  • Chargeback reports for finance

Quality Assurance:

  • Automated test suite (unit, integration, e2e)
  • Error handling verified (retries, circuit breakers)
  • Fallback strategies tested
  • Chaos engineering (simulate failures)
  • SLA monitoring automated

Final Thoughts

After building and testing this platform, I understand why enterprise AI differs from giving developers ChatGPT access and why 95% of initiatives fail. Here is why these layers matter:

  • Cost tracking isn’t about being cheap—it’s about accountability. Finance won’t approve next year’s AI budget without ROI proof.
  • Intelligent routing prevents the death spiral: early excitement ? everyone uses the expensive model ? costs spiral ? finance pulls the plug ? initiative dies.
  • Observability builds trust. When executives ask “Is AI working?”, you need data: success rates, cost per department, quality trends. Without metrics, you get politics and cancellation.
  • Error handling and budgets are professional table stakes. Enterprises can’t have systems that randomly fail or spend unpredictably.

Here are things missing from the prototype:

  • Security: No SSO, PII detection, audit logs for compliance, encryption at rest, security review
  • High Availability: Single instance, no load balancer, no failover, no disaster recovery
  • Operations: No CI/CD, secrets management, log aggregation, incident playbooks
  • Scale: No auto-scaling, multi-region, or load testing beyond 100 concurrent
  • Governance: No approval workflows, per-user limits, content filtering, A/B testing

I have learned that vLLM works, open models are competitive, the tooling is mature. This POC proves that the patterns work and the savings are real. The 5% that succeed treat AI as infrastructure requiring proper tooling. The 95% that fail treat it as magic requiring only faith.

Try it yourself: All code at github.com/bhatti/vllm-tutorial. Clone it, test it, prove it works in your environment. Then build the business case for production investment.

October 30, 2025

Agentic AI for Personal Productivity: Building a Daily Minutes Assistant with RAG, MCP, and ReAct

Filed under: Agentic AI — admin @ 8:20 pm

Over the last year, I have been applying Agentic AI to various problems at work and to improve personal productivity. For example, every morning, I faced the same challenge: information overload.

My typical morning looked like this:

  • ? Check emails and sort out what’s important
  • ? Check my calendar and figure out which ones are critical
  • ? Skim HackerNews, TechCrunch, newsletters for any important insight
  • ? Check Slack for any critical updates
  • ?? Look up weather ? Should I bring an umbrella or jacket?
  • ? Already lost 45 minutes just gathering information!

I needed an AI assistant that could digest all this information while I shower, then present me with a personalized 3-minute brief highlighting what actually matters. Also, following were key constraints for this assistant:

  • ? Complete privacy – My emails and calendar shouldn’t leave my laptop and I didn’t want to run any MCP servers in cloud that could expose my private credentials
  • ? Zero ongoing costs – Running complex Agentic workflow on the hosted environments could easily cost me hundreds of dollars a month
  • ? Fast iteration – Test changes instantly during development
  • ? Flexible deployment – Start local, deploy to cloud when ready

I will walk through my journey of building Daily Minutes with Claude Code – a fully functional agentic AI system that runs on my laptop using local LLMs, saves me 30 minutes every morning.


Agentic Building Blocks

I applied following building blocks to create this system:

  1. MCP (Model Context Protocol) – connecting to data sources discoverable by AI
  2. RAG (Retrieval-Augmented Generation) – give AI long-term memory
  3. ReAct Pattern – teach AI to reason before acting
  4. RLHF (Reinforcement Learning from Human Feedback) – teach AI from my preferences
  5. LangGraph – orchestrate complex multi-agent workflows
  6. 3-Layer Architecture – building easily extensible systems

Full source code: github.com/bhatti/daily-minutes

Let me walk you through how I built each piece, the problems I encountered, and how I solved them.


High-level Architecture

After several iterations, I landed on a clean 3-layer architecture:

Why this architecture worked for me:

Layer 1 (Data Sources) – I used MCP to make connectors pluggable. When I later wanted to add RSS feeds, I just registered a new tool – no changes to the AI logic.

Layer 2 (Intelligence) – This is where the magic happens. The ReAct agent reasons about what data it needs, LangGraph orchestrates fetching from multiple sources in parallel, RAG provides historical context, and RLHF learns from my feedback.

Layer 3 (UI) – I kept the UI simple and fast. It reads from a database cache, so it loads instantly – no waiting for AI to process.

How the Database Cache Works

This is a key architectural decision that made the UI lightning-fast:

# src/services/startup_service.py
async def preload_daily_data():
    """Background job that generates brief and caches in database."""

    # 1. Fetch all data in parallel (LangGraph orchestration)
    data = await langgraph_orchestrator.fetch_all_sources()

    # 2. Generate AI brief (ReAct agent with RAG)
    brief = await brief_generator.generate(
        emails=data['emails'],
        calendar=data['calendar'],
        news=data['news'],
        weather=data['weather']
    )

    # 3. Cache everything in SQLite
    await db.set_cache('daily_brief_data', brief.to_dict(), ttl=3600)  # 1 hour TTL
    await db.set_cache('news_data', data['news'], ttl=3600)
    await db.set_cache('emails_data', data['emails'], ttl=3600)

    logger.info("? All data preloaded and cached")

# src/ui/components/daily_brief.py
def render_daily_brief_section():
    """UI just reads from cache - no AI processing!"""

    # Fast read from database (milliseconds, not seconds)
    if 'data' in st.session_state and st.session_state.data.get('daily_brief'):
        brief_data = st.session_state.data['daily_brief']
        _display_persisted_brief(brief_data)  # Instant!
    else:
        st.info("Run `make preload` to generate your first brief.")

Why this architecture rocks:

  • ? UI loads in <500ms (reading from SQLite cache)
  • ? Background refresh (run make preload or schedule with cron)
  • ? Persistent (brief survives app restarts)
  • ? Testable (can test UI without LLM calls)

Part 1: Setting Up My Local AI Stack

First, I needed to get Ollama running locally. This took me about 30 minutes.

Installing Ollama

# On macOS (what I use)
brew install ollama

# Start the service
ollama serve

# Pull the models I chose
ollama pull qwen2.5:7b         # Main LLM - fast on my M3 Mac
ollama pull nomic-embed-text   # For RAG embeddings

Why I chose Qwen 2.5 (7B):

  • ? Runs fast on my M3 MacBook Pro (no GPU needed)
  • ? Good reasoning capabilities for summarization
  • ? Small enough to iterate quickly (responses in 2-3 seconds)
  • ? Free and private – data never leaves my laptop

Later, I can swap to GPT-4 or Claude with just a config change when I deploy to production.

Testing My Setup

I wanted to make sure Ollama was working before going further:

# Quick test
PYTHONPATH=. python -c "
import asyncio
from src.services.ollama_service import get_ollama_service

async def test():
    ollama = get_ollama_service()
    result = await ollama.generate('Explain RAG in one sentence.')
    print(result)

asyncio.run(test())
"

# Output I got:
# RAG (Retrieval-Augmented Generation) enhances LLM responses by retrieving
# relevant information from a knowledge base before generating answers.

? First milestone: Local AI working!


Part 2: Building MCP Connectors

Instead of hard coding data fetching like this:

# ? My first attempt (brittle)
async def get_daily_data():
    news = await fetch_hackernews()
    weather = await fetch_weather()
    # Later I wanted to add RSS feeds... had to modify this function
    # Then I wanted Slack... modified again
    # This was getting messy fast!

I decided to use MCP (Model Context Protocol) to register data sources as “tools” so that the AI can discover and call by name:

Building News Connector

I started with HackerNews since I check it every morning:

# src/connectors/hackernews.py
class HackerNewsConnector:
    """Fetches top stories from HackerNews API."""

    async def execute_async(self, max_stories: int = 10):
        """The main method MCP will call."""
        # 1. Fetch top story IDs
        response = await self.client.get(
            "https://hacker-news.firebaseio.com/v0/topstories.json"
        )
        story_ids = response.json()[:max_stories]

        # 2. Fetch each story (I fetch these in parallel for speed)
        articles = []
        for story_id in story_ids:
            story = await self._fetch_story(story_id)
            articles.append(self._convert_to_article(story))

        return articles

Key learning: Keep connectors simple. They should do ONE thing: fetch data and return it in a standard format.

Registering with MCP Server

Then I registered this connector with my MCP server:

# src/services/mcp_server.py
class MCPServer:
    """The tool registry that AI agents query."""

    def _register_tools(self):
        # Register HackerNews
        self.tools["fetch_hackernews"] = MCPTool(
            name="fetch_hackernews",
            description="Fetch top tech stories from HackerNews with scores and comments",
            parameters={
                "max_stories": {
                    "type": "integer",
                    "description": "How many stories to fetch (1-30)",
                    "default": 10
                }
            },
            executor=HackerNewsConnector()
        )

This allows my AI to discover this tool and call it without me writing any special integration code!

Testing MCP Discovery

# I tested if the AI could discover my tools
PYTHONPATH=. python -c "
from src.services.mcp_server import get_mcp_server

mcp = get_mcp_server()
print('Available tools:')
for tool in mcp.list_tools():
    print(f'  ? {tool[\"name\"]}: {tool[\"description\"]}')
"

# Output I got:
# Available tools:
#   ? fetch_hackernews: Fetch top tech stories from HackerNews...
#   ? get_current_weather: Get current weather conditions...
#   ? fetch_rss_feeds: Fetch articles from configured RSS feeds...

Later, when I wanted to add RSS feeds, I just created a new connector and registered it. The AI automatically discovered it – no changes needed to my ReAct agent or LangGraph workflows!


Part 3: Building RAG Pipeline

As LLM have limited context window, RAG (Retrieval-Augmented Generation) can be used to create an AI semantic memory by:

  1. Converting text to vectors (embeddings)
  2. Storing vectors in a database (ChromaDB)
  3. Searching by meaning, not just keywords

Building RAG Service

I then implemented RAG service as follows:

# src/services/rag_service.py
class RAGService:
    """Semantic memory using ChromaDB."""

    def __init__(self):
        # Initialize ChromaDB (stores on disk)
        self.client = chromadb.Client(Settings(
            persist_directory="./data/chroma_data"
        ))

        # Create collection for my articles
        self.collection = self.client.get_or_create_collection(
            name="daily_minutes"
        )

        # Ollama for creating embeddings
        self.ollama = get_ollama_service()

    async def add_document(self, content: str, metadata: dict):
        """Store a document with its vector embedding."""

        # 1. Convert text to vector (this is the magic!)
        embedding = await self.ollama.create_embeddings(content)

        # 2. Store in ChromaDB with metadata
        self.collection.add(
            documents=[content],
            embeddings=[embedding],
            metadatas=[metadata],
            ids=[hashlib.md5(content.encode()).hexdigest()]
        )

    async def search(self, query: str, max_results: int = 5):
        """Semantic search - find by meaning!"""

        # 1. Convert query to vector
        query_embedding = await self.ollama.create_embeddings(query)

        # 2. Find similar documents (cosine similarity)
        results = self.collection.query(
            query_embeddings=[query_embedding],
            n_results=max_results
        )

        return results

I then tested it:

# I stored an article about EU AI regulations
await rag.add_document(
    content="European Union announces comprehensive AI safety regulations "
            "focusing on transparency, accountability, and privacy protection.",
    metadata={"type": "article", "topic": "ai_safety"}
)

# Later, I searched using different words
results = await rag.search("privacy rules for artificial intelligence")

This shows that RAG isn’t just storing text – it understands meaning through vector mathematics.

What I Store in RAG

Over time, I started storing other data like emails, todos, events, etc:

# 1. News articles (for historical context)
await rag.add_article(article)

# 2. Action items from emails
await rag.add_todo(
    "Complete security training by Nov 15",
    source="email",
    priority="high"
)

# 3. Meeting context
await rag.add_document(
    "Q4 Planning Meeting - need to prepare budget estimates",
    metadata={"type": "meeting", "date": "2025-02-01"}
)

# 4. User preferences (this feeds into RLHF later!)
await rag.add_document(
    "User marked 'AI safety' topics as important",
    metadata={"type": "preference", "category": "ai_safety"}
)

With this AI memory, it can answer questions like:

  • “What do I need to prepare for tomorrow’s meeting?”
  • “What AI safety articles did I read this week?”
  • “What are my pending action items?”

Part 4: Building the ReAct Agent

In my early prototyping, the implementation just executed blindly:

# ? First attempt - no thinking!
async def generate_brief():
    news = await fetch_all_news()  # Fetches everything
    summary = await llm.generate(f"Summarize: {news}")
    return summary

This wasted time fetching data I didn’t need. I wanted my AI to reason first, then act so I applied ReAct (Reasoning + Acting), which works in a loop:

  1. THOUGHT: AI reasons about what to do next
  2. ACTION: AI executes a tool/function
  3. OBSERVATION: AI observes the result
  4. Repeat until goal achieved

Implementing My ReAct Agent

Here is how it ReAct agent was built:

# src/agents/react_agent.py
class ReActAgent:
    """Agent that thinks before acting."""

    async def run(self, goal: str):
        """Execute goal using ReAct loop."""
        steps = []
        observations = []

        for step_num in range(1, self.max_steps + 1):
            # 1. THOUGHT: Ask AI what to do next
            thought = await self._generate_thought(goal, steps, observations)

            # Check if we're done
            if "FINAL ANSWER" in thought:
                return self._extract_answer(thought)

            # 2. ACTION: Parse what action to take
            action = self._parse_action(thought)
            # Example: {"action": "call_tool", "tool": "fetch_hackernews"}

            # 3. EXECUTE: Run the action via MCP
            observation = await self._execute_action(action)
            observations.append(observation)

            # Record this step for debugging
            steps.append({
                "thought": thought,
                "action": action,
                "observation": observation
            })

        return {"steps": steps, "answer": "Max steps reached"}

The hardest part was writing the prompts that made the AI reason properly:

async def _generate_thought(self, goal, steps, observations):
    """Generate next reasoning step."""

    prompt = f"""Goal: {goal}

Previous steps:
{self._format_steps(steps)}

Available actions:
- query_rag(query): Search my semantic memory
- call_tool(name, params): Execute an MCP tool
- FINAL ANSWER: When you have everything needed

Think step-by-step. What should I do next?

Format your response as:
THOUGHT: <your reasoning>
ACTION: <action to take>
"""

    return await self.ollama.generate(prompt, temperature=0.7)

I added debug logging to see the AI’s reasoning:

? Goal: Generate my daily brief

Step 1:
  ? THOUGHT: I need to gather news, check weather, and see user preferences
  ? ACTION: call_tool("fetch_hackernews", max_stories=10)
  ?? OBSERVATION: Fetched 10 articles about AI, privacy, and tech

Step 2:
  ? THOUGHT: Got news. User preferences would help prioritize.
  ? ACTION: query_rag("user interests and preferences")
  ?? OBSERVATION: User cares about AI safety, security, privacy

Step 3:
  ? THOUGHT: Should filter articles to user's interests
  ? ACTION: call_tool("get_current_weather", location="Seattle")
  ?? OBSERVATION: 70°F, Partly cloudy

Step 4:
  ? THOUGHT: I have news (filtered by user interests), weather. Ready to generate.
  ? ACTION: FINAL ANSWER
  ? Generated personalized brief highlighting AI safety articles

Part 5: Adding RLHF

Initially, my AI scored all emails the same way:

? "Newsletter: 10 CSS Tips" ? Importance: 0.5
? "URGENT: Production outage!" ? Importance: 0.5

So I used RLHF to teach my AI what I care about.

Implementing RLHF Scoring

I added a mixin to my email model:

# src/models/email.py
class ImportanceScoringMixin:
    """Learn from user feedback."""

    importance_score: float = 0.5  # AI's base score
    boost_labels: Set[str] = set()  # Words user marked important
    filter_labels: Set[str] = set()  # Words user wants to skip

    def apply_rlhf_boost(self, content_text: str) -> float:
        """Adjust score based on learned preferences."""
        adjusted = self.importance_score
        content_lower = content_text.lower()

        # Boost if content matches important keywords
        for label in self.boost_labels:
            if label.lower() in content_lower:
                adjusted += 0.1  # Bump up priority!

        # Penalize if content matches skip keywords
        for label in self.filter_labels:
            if label.lower() in content_lower:
                adjusted -= 0.2  # Push down priority!

        # Keep in valid range [0, 1]
        return max(0.0, min(1.0, adjusted))

Note: Code examples are simplified for clarity.
See GitHub for the full production implementation.

Adding Feedback UI

In my Streamlit dashboard, I added ?/? buttons:

# User sees an email
for email in emails:
    col1, col2, col3 = st.columns([8, 1, 1])

    with col1:
        st.write(f"**{email.subject}**")
        st.info(email.snippet)

    with col2:
        if st.button("?", key=f"important_{email.id}"):
            # Extract what made this important
            keywords = await extract_keywords(email.subject + email.body)
            # Add to boost labels
            user_profile.boost_labels.update(keywords)
            st.success(f"? Learned: You care about {', '.join(keywords)}")

    with col3:
        if st.button("?", key=f"skip_{email.id}"):
            # Learn to deprioritize these
            keywords = await extract_keywords(email.subject)
            user_profile.filter_labels.update(keywords)
            st.success(f"? Will deprioritize: {', '.join(keywords)}")

Part 6: Orchestrating with LangGraph

Instead of fetching contents from all data sources sequential for the daily minutes:

# ? Sequential execution - SLOW!
news = await fetch_news()      # 5 seconds
emails = await fetch_emails()  # 3 seconds
calendar = await fetch_calendar()  # 2 seconds
weather = await fetch_weather()  # 1 second
# Total: 11 seconds just waiting! ?

I used LangGraph to define workflows as graphs with parallel execution:

Key insight: Parallel fetch reduced the fetch time for downloading data from various sources.

Building My Workflow

# src/services/langgraph_orchestrator.py
from langgraph.graph import StateGraph, END

class LangGraphOrchestrator:
    def _create_workflow(self):
        """Define my workflow graph."""
        workflow = StateGraph(WorkflowState)

        # Add nodes (processing steps)
        workflow.add_node("analyze", self._analyze_request)
        workflow.add_node("fetch_news", self._fetch_news)
        workflow.add_node("fetch_emails", self._fetch_emails)
        workflow.add_node("fetch_calendar", self._fetch_calendar)
        workflow.add_node("search_rag", self._search_context)
        workflow.add_node("generate_summary", self._generate_summary)

        # Define edges (execution flow)
        workflow.set_entry_point("analyze")

        # Parallel fetch (all happen at once!)
        workflow.add_edge("analyze", "fetch_news")
        workflow.add_edge("analyze", "fetch_emails")
        workflow.add_edge("analyze", "fetch_calendar")

        # All converge to RAG search
        workflow.add_edge("fetch_news", "search_rag")
        workflow.add_edge("fetch_emails", "search_rag")
        workflow.add_edge("fetch_calendar", "search_rag")

        # Sequential processing
        workflow.add_edge("search_rag", "generate_summary")
        workflow.add_edge("generate_summary", END)

        return workflow.compile()

Note: WorkflowState is a shared dictionary that nodes pass data through – like a clipboard for the workflow. The analyze node parses the user’s request and decides which data sources are needed.

Implementing Node Functions

Each node is just an async function:

async def _fetch_news(self, state: WorkflowState):
    """Fetch news in parallel."""
    try:
        articles = await self.mcp.execute_tool(
            "fetch_hackernews",
            {"max_stories": 10}
        )
        state["news_articles"] = articles
    except Exception as e:
        state["errors"].append(f"News fetch failed: {e}")
        state["news_articles"] = []

    return state

async def _search_context(self, state: WorkflowState):
    """Search RAG for relevant context."""
    query = state["user_request"]
    results = await self.rag.search(query, max_results=5)

    # Build context string
    context = "\n".join([r['content'] for r in results])
    state["context"] = context

    return state

Running the Workflow

# Execute the complete workflow
result = await orchestrator.run("Generate my daily brief")

# I get back:
{
    "news_articles": [...],      # 10 articles
    "emails": [...],              # 5 unread
    "calendar_events": [...],     # 3 events today
    "context": "...",             # RAG context
    "summary": "...",             # Generated brief
    "processing_time": 5.2        # Seconds (not 11!)
}

The LLM Factory Pattern – How I Made It Cloud-Ready

Following code snippet shows how does the system seamlessly switch between local Ollama and cloud providers:

# src/services/llm_factory.py
def get_llm_service():
    """Factory pattern - works with any LLM provider."""
    provider = os.getenv("LLM_PROVIDER", "ollama")

    if provider == "ollama":
        return OllamaService(
            base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
            model=os.getenv("OLLAMA_MODEL", "qwen2.5:7b")
        )
    elif provider == "openai":
        return OpenAIService(
            api_key=os.getenv("OPENAI_API_KEY"),
            model=os.getenv("OPENAI_MODEL", "gpt-4-turbo")
        )
    elif provider == "google":
        # Like in my previous Vertex AI article!
        return VertexAIService(
            project_id=os.getenv("GCP_PROJECT_ID"),
            model="gemini-1.5-flash"
        )

    raise ValueError(f"Unknown provider: {provider}")

# All services implement the same interface:
class BaseLLMService:
    async def generate(self, prompt: str, **kwargs) -> str:
        """Generate text from prompt."""
        raise NotImplementedError

    async def create_embeddings(self, text: str) -> List[float]:
        """Create vector embeddings."""
        raise NotImplementedError

The ReAct agent, RAG service, and Brief Generator all use get_llm_service() – they don’t care which provider is running!


Part 7: The Challenges I Faced

Building this system wasn’t smooth. Here are the biggest challenges:

Challenge 1: LLM Generating Vague Summaries

Problem: My early briefs were terrible:

? "Today's news features a mix of technology updates and various topics."

This was useless! I needed specifics.

Solution: I rewrote my prompts with explicit rules:

# ? Better prompt with strict rules
prompt = f"""Generate a daily brief following these STRICT rules:

PRIORITY ORDER (most important first):
1. Urgent emails or action items
2. Today's calendar events
3. Market/business news
4. Tech news

TLDR FORMAT (exactly 3 bullets, be SPECIFIC):
* Bullet 1: Most urgent email/action (include WHO, WHAT, WHEN)
   Example: "Client escalation from Acme Corp affecting 50K users - response needed by 2pm"

* Bullet 2: Most important calendar event today (include TIME and WHAT TO PREPARE)
   Example: "2pm: Board meeting - prepare Q4 revenue slides"

* Bullet 3: Top market/business news (include NUMBERS/SPECIFICS)
   Example: "Federal Reserve raises rates 0.5% to 5.25% - affects tech hiring"

AVOID THESE PHRASES (they're too vague):
? "mix of updates"
? "various topics"
? "continues to make progress"
? "interesting developments"

USE SPECIFIC DETAILS:
? Names (people, companies)
? Numbers (percentages, dollar amounts, deadlines)
? Times (when something happened or needs to happen)

Content to summarize:
{content}

Generate: TLDR (3 bullets), Summary (5-6 detailed sentences), Key Insights (5 bullets)
"""

Result: Went from vague ? specific, actionable briefs!

Challenge 2: TLDR Bullets Rendering on Same Line

Problem: My UI showed bullets in one paragraph:

? • Critical email... • Meeting at 2pm... • Market news...

Root cause: Streamlit’s st.info() doesn’t preserve newlines.

Solution: Split and render each bullet separately:

# ? Doesn't work
st.info(tldr)

# ? Works!
tldr_lines = [line.strip() for line in tldr.split('\n') if line.strip()]
for bullet in tldr_lines:
    st.markdown(bullet)

Challenge 3: AI Prioritizing News Over Personal Tasks

Problem: My brief focused on tech news, ignored my urgent emails:

? TLDR bullet 1: "OpenAI releases GPT-5" (who cares?)
   TLDR bullet 2: "Crypto market surges" (not relevant to me)
   TLDR bullet 3: "Client escalation requires response" (BURIED!)

Solution: I restructured my prompt to explicitly label priority:

# src/services/brief_scheduler.py
async def _generate_daily_brief(emails, calendar, news, weather):
    """Generate prioritized daily brief with structured prompt."""

    # Separate market vs tech news (market is higher priority)
    market_news = [n for n in news if 'market' in n.tags]
    tech_news = [n for n in news if 'market' not in n.tags]

    # Sort emails by RLHF-boosted importance score
    important_emails = sorted(
        emails,
        key=lambda e: e.apply_rlhf_boost(e.subject + e.snippet),
        reverse=True
    )[:5]  # Top 5 only

    # Build structured prompt with clear priority
    prompt = f"""
**SECTION 1: IMPORTANT EMAILS (HIGHEST PRIORITY - use for TLDR bullet #1)**
{format_emails(important_emails)}

**SECTION 2: TODAY'S CALENDAR (SECOND PRIORITY - use for TLDR bullet #2)**
{format_calendar(calendar)}

**SECTION 3: MARKET NEWS (THIRD PRIORITY - use for TLDR bullet #3)**
{format_market_news(market_news)}

**SECTION 4: TECH NEWS (LOWEST PRIORITY - summarize briefly)**
{format_tech_news(tech_news)}

**SECTION 5: WEATHER**
{format_weather(weather)}

Generate a daily brief following this EXACT priority order:
1. Email action items FIRST
2. Calendar events SECOND
3. Market/business news THIRD
4. Tech news LAST (brief mention only)

TLDR must have EXACTLY 3 bullets using content from sections 1, 2, 3 (not section 4).
"""

    return await llm.generate(prompt)

Result: My urgent email moved to bullet #1 where it belongs! The AI now respects the priority structure.

Challenge 4: RAG Returning Irrelevant Results

Problem: Semantic search sometimes returned weird matches:

Query: "AI safety regulations"
Result: Article about "safe AI models for healthcare" (wrong context!)

Solution: I added metadata filtering and better embeddings:

# Store with rich metadata
await rag.add_document(
    content=article.title,
    metadata={
        "type": "article",
        "category": "ai_safety",  # For filtering!
        "tags": ["regulation", "eu", "policy"],
        "date": "2025-01-28",
        "importance": "high"
    }
)

# Search with filters
results = await rag.search(
    "AI regulations",
    filter_metadata={
        "category": "ai_safety",
        "importance": "high"
    }
)

Result: Much more relevant results!

Challenge 5: Handling API Failures

Problem: MCP connectors may fail to fetch data from underlying data source.

Solution: I used graceful degradation where brief is generated with available data and error messages/last-updated is marked for failed sources)


Part 8: Future Improvements

This work is by no means done but I am sharing a proof of concept that I have built so far. Here is what still needs work:

Current Limitations

1. Email/Calendar Improvements

  • ? Have: I have basic OAuth support for emails and calendar events and mock testing
  • ? Missing: Solid OAuth support for Gmail and Google Calendar integration

2. RLHF Needs More Sophisticated Learning

  • ? Have: Current system allows simple keyword matching (if email contains “security” ? boost)
  • ? Missing: Context-aware learning (distinguish “security update” vs “security breach”)
  • Improvement Needed:
  # Current: Simple keyword match
  if "security" in email:
      score += 0.1

  # Better: Contextual understanding
  if embeddings_similar(email, user.important_emails):
      score += contextual_boost  # Uses semantic similarity!

3. ReAct Agent Sometimes Over-Thinks

  • ? Have: AI reasons before acting
  • ? Problem: Sometimes takes 4-5 steps when 2 would suffice
  • Fix Needed: Better stopping criteria in prompts

4. No Multi-User Support (Yet)

  • ? Have: Works great for me
  • ? Missing: Can’t handle multiple users with different preferences
  • Future: Add user profiles, tenant isolation

5. Brief Generation Can Be Slow (30-60 seconds)

  • ? Have: Parallel data fetching (fast)
  • ? Bottleneck: LLM generation with Qwen 2.5 on CPU
  • Options:
  • Use smaller model (faster but less capable)
  • Deploy to cloud with faster GPT-4

6. Missing Data Sources

  • ? Have: Basic data for news, email and calendar
  • ? Slack Integration: Add Slack to monitor important channels and surface urgent threads in daily brief
  • ? Social Media Integration: Add social media feed to monitor trending topics or news

Part 9: How to Extend This System

I designed this to be easily extensible. Here’s how you can add new features:

Adding a New Data Source (Example: Slack)

Step 1: Create the connector

# src/connectors/slack.py
class SlackConnector:
    """Fetch recent messages from Slack channels."""

    async def execute_async(self, channel: str, max_messages: int = 10):
        # 1. Connect to Slack API
        client = WebClient(token=os.getenv("SLACK_BOT_TOKEN"))

        # 2. Fetch recent messages
        response = await client.conversations_history(
            channel=channel,
            limit=max_messages
        )

        # 3. Convert to standard format
        messages = []
        for msg in response['messages']:
            messages.append(SlackMessage(
                text=msg['text'],
                user=msg['user'],
                channel=channel,
                timestamp=msg['ts']
            ))

        return messages

Step 2: Register with MCP (automatic discovery!)

# src/services/mcp_server.py
def _register_tools(self):
    # ... existing tools ...

    # Add Slack
    self.tools["get_slack_messages"] = MCPTool(
        name="get_slack_messages",
        description="Fetch recent Slack messages from a channel",
        parameters={
            "channel": {"type": "string", "description": "Channel name"},
            "max_messages": {"type": "integer", "default": 10}
        },
        executor=SlackConnector()
    )

Step 3: AI automatically discovers it!

# Your ReAct agent will now see:
# "Available tools: fetch_hackernews, get_slack_messages, ..."
# No changes needed to ReAct logic!

Step 4: Update brief prompt to include Slack

# src/services/brief_scheduler.py
prompt = f"""
**IMPORTANT EMAILS**: {emails}
**CALENDAR**: {calendar}
**SLACK HIGHLIGHTS**: {slack_messages}  # New!
**NEWS**: {news}

Generate brief prioritizing: Email > Calendar > Slack > News
"""

Part 10: Local Development vs Cloud

One of my favorite aspects of this architecture: develop locally, deploy to cloud with 1 config change.

Development (What I Use Daily)

# .env.development
LLM_PROVIDER=ollama
LLM_OLLAMA_BASE_URL=http://localhost:11434
LLM_OLLAMA_MODEL=qwen2.5:7b
DATABASE_URL=sqlite:///./data/daily_minutes.db
REDIS_URL=redis://localhost:6379

Benefits I experience daily:

  • ? Free: Zero API costs (I iterate 50+ times/day)
  • ? Fast: No network latency, responses in 2-3 seconds
  • ? Private: My emails never touch the internet
  • ? Offline: Works on planes, cafes without WiFi

Trade-offs I accept:

  • ?? Slower than GPT-4
  • ?? Less capable reasoning (7B vs 175B+ parameters)
  • ?? Manual updates (pull new Ollama models myself)

Production

# .env.production
LLM_PROVIDER=openai  # Just change this line!
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4-turbo
DATABASE_URL=postgresql://...  # Scalable DB
REDIS_URL=redis://prod-cluster:6379  # Distributed cache

The magic: Same code, different LLM!

# src/services/llm_factory.py
def get_llm_service():
    """Factory pattern - works with any LLM."""
    provider = os.getenv("LLM_PROVIDER", "ollama")

    if provider == "ollama":
        return OllamaService()
    elif provider == "openai":
        return OpenAIService()
    elif provider == "anthropic":
        return ClaudeService()
    elif provider == "google":
        return VertexAIService()  # Like in my previous article!

    raise ValueError(f"Unknown provider: {provider}")

Part 11: Testing Everything

I used TDD extensively to build each feature so that it’s easy to debug if something is not working:

Unit Tests

# Test MCP tool registration
pytest tests/unit/test_mcp_server.py -v

# Test RAG semantic search
pytest tests/unit/test_rag_service.py -v

# Test ReAct reasoning
pytest tests/unit/test_react_agent.py -v

# Test RLHF scoring
pytest tests/unit/test_rlhf_scoring.py -v

# Run all unit tests
pytest tests/unit/ -v
# 516 passed in 45.23s ?

Integration Tests

Also, in some cases unit tests couldn’t fully validate so I wrote integration tests to test persistence logic with sqlite database or generating real analysis from news:

# tests/integration/test_brief_quality.py
async def test_tldr_has_three_bullets():
    """TLDR must have exactly 3 bullets."""
    brief = await db.get_cache('daily_brief_data')
    tldr = brief.get('tldr', '')

    bullets = [line for line in tldr.split('\n') if line.strip().startswith('•')]

    assert len(bullets) == 3, f"Expected 3 bullets, got {len(bullets)}"
    assert "email" in bullets[0].lower() or "urgent" in bullets[0].lower()
    assert "calendar" in bullets[1].lower() or "meeting" in bullets[1].lower()

async def test_no_generic_phrases():
    """Brief should not contain vague phrases."""
    brief = await db.get_cache('daily_brief_data')
    summary = brief.get('summary', '')

    bad_phrases = ["mix of updates", "various topics", "continues to"]
    for phrase in bad_phrases:
        assert phrase not in summary.lower(), f"Found generic phrase: {phrase}"

Manual Testing (My Daily Workflow)

# 1. Fetch data and generate brief
make preload

# Output I see:
# ? Fetching news from HackerNews... (10 articles)
# ? Fetching weather... (70°F, Sunny)
# ? Analyzing articles with AI... (15 articles)
# ? Generating daily brief... (Done in 18.3s)
# ? Brief saved to database

# 2. Launch UI
streamlit run src/ui/streamlit_app.py

# 3. Check brief quality
# - Is TLDR specific? (not vague)
# - Are priorities correct? (email > calendar > news)
# - Are action items extracted? (from emails)
# - Did RLHF work? (boosted my preferences)

Note: You can schedule preload via cron, e.g., I run it at 6am daily so that brief is ready when I wake up.


Conclusion

Building this Daily Minutes assistant changed how I start my day by giving me a personalized 3-minute brief highlighting what truly matters. Agentic AI excels at automating complex workflows that require judgment, not just execution. The ReAct agent reasons through prioritization. RAG provides contextual memory across weeks of interactions. RLHF learns from my feedback, getting smarter about what I care about. LangGraph orchestrates parallel execution across multiple data sources. These building blocks work together to handle decisions that traditionally needed human attention.

I’m sharing this as a proof of concept, not a finished product. The code works, saves me real time, and demonstrates these techniques effectively. But I’m still iterating. The OAuth integration and error handling needs improvements. The RLHF scoring could be more sophisticated. The ReAct agent sometimes overthinks simple tasks. I’m adding these improvements gradually, testing each change against my daily routine.

The real lesson? Start small, validate with real use, then scale with confidence. I used Claude Code to build this in spare time over a couple weeks. You can do the same—clone the repo, adapt it to your workflow, and see where agentic AI saves you time.

Try It Yourself

# Clone my repo
git clone https://github.com/bhatti/daily-minutes
cd daily-minutes

# Install dependencies
pip install -r requirements.txt

# Setup Ollama
ollama pull qwen2.5:7b
ollama pull nomic-embed-text

# Generate your first brief
make preload

# Launch dashboard
streamlit run src/ui/streamlit_app.py

Resources

October 21, 2025

Pragmatic Agentic AI: How I Rebuilt Years of FinTech Infrastructure with ReAct, RAG, and Free Local Models

Filed under: Agentic AI,Computing — Tags: , , , , — admin @ 1:00 pm

I spent over a decade in FinTech building the systems traders rely on every day like high-performance APIs streaming real-time charts, technical indicator calculators processing millions of data points per second, and comprehensive analytical platforms ingesting SEC 10-Ks and 10-Qs into distributed databases. We used to parse XBRL filings, ran news/sentiment analysis on earnings calls using early NLP models to detect market anomalies.

Over the past couple of years, I’ve been building AI agents and creating automated workflows that tackle complex problems using agentic AI. I’m also revisiting challenges I hit while building trading tools for fintech companies. For example, the AI I’m working with now reasons about which analysis to run. It grasps context, retrieves information on demand, and orchestrates complex workflows autonomously. It applies Black-Scholes when needed, switches to technical analysis when appropriate, and synthesizes insights from multiple sources—no explicit rules required.

The best part is that I’m running this entire system on my laptop using Ollama and open-source models. Zero API costs during development. When I need production scale, I can switch to cloud APIs with a few lines of code. I will walk you through this journey of rebuilding financial analysis with agentic AI – from traditional algorithms to thinking machines and from rigid pipelines to adaptive workflows.

Why This Approach Changes Everything

Traditional financial systems process data. Agentic AI systems understand objectives and figure out how to achieve them. That’s the fundamental difference that took me a while to fully grasp. And unlike my old systems that required separate codebases for each type of analysis, this one uses the same underlying patterns for everything.

The Money-Saving Secret: Local Development with Ollama

Here’s something that would have saved my startup thousands: you can build and test sophisticated AI systems entirely locally using Ollama. No API keys, no usage limits, no surprise bills.

# This runs entirely on your machine - zero external API calls
from langchain_ollama import OllamaLLM as Ollama

# Local LLM for development and testing
dev_llm = Ollama(
    model="llama3.2:latest",      # 3.2GB model that runs on most laptops
    temperature=0.7,
    base_url="http://localhost:11434"  # Your local Ollama instance
)

# When ready for production, switch to cloud providers
from langchain_openai import ChatOpenAI

prod_llm = ChatOpenAI(
    model="gpt-4",
    temperature=0.7
)

# The beautiful part? Same interface, same code
def analyze_stock(llm, ticker):
    # This function works with both local and cloud LLMs
    prompt = f"Analyze {ticker} stock fundamentals"
    return llm.invoke(prompt)

During development, I run hundreds of experiments daily without spending a cent. Once the prompts and workflows are refined, switching to cloud APIs is literally changing one line of code.

Understanding ReAct: How AI Learns to Think Step-by-Step

ReAct (Reasoning and Acting) was the first pattern that made me realize we weren’t just building chatbots anymore. Let me show you exactly how it works with real code from my system.

The Human Thought Process We’re Mimicking

When I manually analyzed stocks, my mental process looked something like this:

  1. “I need to check if Apple is overvalued”
  2. “Let me get the current P/E ratio”
  3. “Hmm, 28.5 seems high, but what’s the industry average?”
  4. “Tech sector average is 25, so Apple is slightly premium”
  5. “But wait, what’s their growth rate?”
  6. “15% annual growth… that PEG ratio of 1.9 suggests fair value”
  7. “Let me check recent news for any red flags…”

ReAct agents follow this exact pattern. Here’s the actual implementation:

class ReActAgent:
    """ReAct Agent that demonstrates reasoning traces"""
    
    # This is the actual prompt from the project
    REACT_PROMPT = """You are a financial analysis agent that uses the ReAct framework to solve problems.

You have access to the following tools:

{tools_description}

Use the following format EXACTLY:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, must be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

Begin! Remember to ALWAYS follow the format exactly.

Question: {question}
Thought: {scratchpad}"""

    def _parse_response(self, response: str) -> Tuple[str, str, str, bool]:
        """Parse LLM response to extract thought, action, and input"""
        response = response.strip()
        
        # Check for final answer
        if "Final Answer:" in response:
            parts = response.split("Final Answer:")
            thought = parts[0].strip()
            final_answer = parts[1].strip()
            return thought, "final_answer", final_answer, True
        
        # Parse using regex from actual implementation
        thought_match = re.search(r"Thought:\s*(.+?)(?=Action:|$)", response, re.DOTALL)
        action_match = re.search(r"Action:\s*(.+?)(?=Action Input:|$)", response, re.DOTALL)
        input_match = re.search(r"Action Input:\s*(.+?)(?=Observation:|$)", response, re.DOTALL)
        
        thought = thought_match.group(1).strip() if thought_match else "Thinking..."
        action = action_match.group(1).strip() if action_match else "unknown"
        action_input = input_match.group(1).strip() if input_match else ""
        
        return thought, action, action_input, False

I can easily trace through reasoning to debug how AI reached its conclusion.

RAG: Solving the Hallucination Problem Once and For All

Early in my experiments, I had to deal with a bit of hallucinations when querying financial data with AI so I applied RAG (Retrieval-Augmented Generation) to give AI access to a searchable library of documents.

How RAG Actually Works

You can think of RAG like having a research assistant who, instead of relying on memory, always checks the source documents before answering:

class RAGEngine:
    """
    This engine solved my hallucination problems by grounding 
    all responses in actual documents. It's like giving the AI
    access to your company's document database.
    """
    
    def __init__(self):
        # Initialize embeddings - this converts text to searchable vectors
        # Using Ollama's local embedding model (free!)
        self.embeddings = OllamaEmbeddings(
            model="nomic-embed-text:latest"  # 274MB model, runs fast
        )
        
        # Text splitter - crucial for handling large documents
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=512,      # Small enough for context window
            chunk_overlap=50,    # Overlap prevents losing context at boundaries
            separators=["\n\n", "\n", ". ", " "]  # Smart splitting
        )
        
        # Vector store - where we keep our searchable documents
        self.vector_store = FAISS.from_texts(["init"], self.embeddings)
    
    def load_financial_documents(self, ticker: str):
        """
        In production, this would load real 10-Ks, 10-Qs, earnings calls.
        For now, I'm using sample documents to demonstrate the concept.
        """
        
        # Imagine these are real SEC filings
        documents = [
            {
                "content": f"""
                {ticker} Q3 2024 Earnings Report
                
                Revenue: $94.9 billion, up 6% year over year
                iPhone revenue: $46.2 billion
                Services revenue: $23.3 billion (all-time record)
                
                Gross margin: 45.2%
                Operating cash flow: $28.7 billion
                
                CEO Tim Cook: "We're incredibly pleased with our record 
                September quarter results and strong momentum heading into
                the holiday season."
                """,
                "metadata": {
                    "source": "10-Q Filing",
                    "date": "2024-10-31",
                    "document_type": "earnings_report",
                    "ticker": ticker
                }
            },
            # ... more documents
        ]
        
        # Process each document
        for doc in documents:
            # Split into chunks
            chunks = self.text_splitter.split_text(doc["content"])
            
            # Create document objects with metadata
            for i, chunk in enumerate(chunks):
                metadata = doc["metadata"].copy()
                metadata["chunk_id"] = i
                metadata["total_chunks"] = len(chunks)
                
                # Add to vector store
                self.vector_store.add_texts(
                    texts=[chunk],
                    metadatas=[metadata]
                )
        
        print(f"? Loaded {len(documents)} documents for {ticker}")
    
    def answer_with_sources(self, question: str) -> Dict[str, Any]:
        """
        This is where RAG shines - every answer comes with sources
        """
        # Find relevant document chunks
        relevant_docs = self.vector_store.similarity_search_with_score(
            question, 
            k=5  # Top 5 most relevant chunks
        )
        
        # Build context from retrieved documents
        context_parts = []
        sources = []
        
        for doc, score in relevant_docs:
            # Only use highly relevant documents (score < 0.5)
            if score < 0.5:
                context_parts.append(doc.page_content)
                sources.append({
                    "content": doc.page_content[:100] + "...",
                    "source": doc.metadata.get("source"),
                    "date": doc.metadata.get("date"),
                    "relevance_score": float(score)
                })
        
        context = "\n\n---\n\n".join(context_parts)
        
        # Generate answer grounded in retrieved context
        prompt = f"""Based on the following verified documents, answer the question.
        If the answer is not in the documents, say "I don't have that information."
        
        Documents:
        {context}
        
        Question: {question}
        
        Answer (cite sources):"""
        
        response = self.llm.invoke(prompt)
        
        return {
            "answer": response,
            "sources": sources,
            "confidence": len(sources) / 5  # Simple confidence metric
        }

MCP-Style Tools: Extending AI Capabilities Beyond Text

Model Context Protocol (MCP) helped me to build a flexible tool system. Instead of hardcoding every capability, we give the AI tools it can discover and use:

class BaseTool(ABC):
    """
    Every tool self-describes its capabilities.
    This is like giving the AI an instruction manual for each tool.
    """
    
    @abstractmethod
    def get_schema(self) -> ToolSchema:
        """Define what this tool does and how to use it"""
        pass
    
    @abstractmethod
    def execute(self, **kwargs) -> Any:
        """Actually run the tool"""
        pass

class StockDataTool(BaseTool):
    """
    Real example: This tool replaced my entire market data microservice
    """
    
    def get_schema(self) -> ToolSchema:
        return ToolSchema(
            name="stock_data",
            description="Fetch real-time stock market data including price, volume, and fundamentals",
            category=ToolCategory.DATA_RETRIEVAL,
            parameters=[
                ToolParameter(
                    name="ticker",
                    type="string", 
                    description="Stock symbol like AAPL or GOOGL",
                    required=True
                ),
                ToolParameter(
                    name="metrics",
                    type="array",
                    description="Specific metrics to retrieve",
                    required=False,
                    default=["price", "volume", "pe_ratio"],
                    enum=["price", "volume", "pe_ratio", "market_cap", 
                          "dividend_yield", "beta", "rsi", "moving_avg_50"]
                )
            ],
            returns="Dictionary containing requested stock metrics",
            examples=[
                {"ticker": "AAPL", "metrics": ["price", "pe_ratio"]},
                {"ticker": "TSLA", "metrics": ["price", "volume", "rsi"]}
            ]
        )
    
    def execute(self, **kwargs) -> Dict[str, Any]:
        """
        This connects to real market data APIs.
        In my old system, this was a 500-line service.
        """
        ticker = kwargs["ticker"].upper()
        metrics = kwargs.get("metrics", ["price", "volume"])
        
        # Using yfinance for real market data
        import yfinance as yf
        stock = yf.Ticker(ticker)
        info = stock.info
        
        result = {"ticker": ticker, "timestamp": datetime.now().isoformat()}
        
        # Fetch requested metrics
        metric_mapping = {
            "price": lambda: info.get("currentPrice", stock.history(period="1d")['Close'].iloc[-1]),
            "volume": lambda: info.get("volume", 0),
            "pe_ratio": lambda: info.get("trailingPE", 0),
            "market_cap": lambda: info.get("marketCap", 0),
            "dividend_yield": lambda: info.get("dividendYield", 0) * 100,
            "beta": lambda: info.get("beta", 1.0),
            "rsi": lambda: self._calculate_rsi(stock),
            "moving_avg_50": lambda: stock.history(period="50d")['Close'].mean()
        }
        
        for metric in metrics:
            if metric in metric_mapping:
                try:
                    result[metric] = metric_mapping[metric]()
                except Exception as e:
                    result[metric] = f"Error: {str(e)}"
        
        return result
class ToolParameter(BaseModel):
    """Actual parameter definition from project"""
    name: str
    type: str  # "string", "number", "boolean", "object", "array"
    description: str
    required: bool = True
    default: Any = None
    enum: Optional[List[Any]] = None

class CalculatorTool(BaseTool):
    """Actual calculator implementation from project"""
    
    def execute(self, **kwargs) -> float:
        """Safely evaluate mathematical expression"""
        self.validate_input(**kwargs)
        
        expression = kwargs["expression"]
        precision = kwargs.get("precision", 2)
        
        try:
            # Security: Remove dangerous operations
            safe_expr = expression.replace("__", "").replace("import", "")
            
            # Define allowed functions (from actual code)
            safe_dict = {
                "abs": abs, "round": round, "min": min, "max": max,
                "sum": sum, "pow": pow, "len": len
            }
            
            # Add math functions
            import math
            for name in ["sqrt", "log", "log10", "sin", "cos", "tan", "pi", "e"]:
                if hasattr(math, name):
                    safe_dict[name] = getattr(math, name)
            
            result = eval(safe_expr, {"__builtins__": {}}, safe_dict)
            
            return round(result, precision)
            
        except Exception as e:
            raise ValueError(f"Calculation error: {e}")

Orchestrating Everything with LangGraph

This is where all the pieces come together. LangGraph allows coordinating multiple agents and tools in sophisticated workflows:

class FinancialAnalysisWorkflow:
    """
    This workflow replaces what used to be multiple microservices,
    message queues, and orchestration layers. It's beautiful.
    """
    
    def _build_graph(self) -> StateGraph:
        """
        Define how different analysis components work together
        """
        workflow = StateGraph(AgentState)
        
        # Add all our analysis nodes
        workflow.add_node("collect_data", self.collect_market_data)
        workflow.add_node("technical_analysis", self.run_technical_analysis)
        workflow.add_node("fundamental_analysis", self.run_fundamental_analysis)
        workflow.add_node("sentiment_analysis", self.analyze_sentiment)
        workflow.add_node("options_analysis", self.analyze_options)
        workflow.add_node("portfolio_optimization", self.optimize_portfolio)
        workflow.add_node("rag_research", self.search_documents)
        workflow.add_node("react_reasoning", self.reason_about_data)
        workflow.add_node("generate_report", self.create_final_report)
        
        # Entry point
        workflow.set_entry_point("collect_data")
        
        # Define the flow - some parallel, some sequential
        workflow.add_edge("collect_data", "technical_analysis")
        workflow.add_edge("collect_data", "fundamental_analysis")
        workflow.add_edge("collect_data", "sentiment_analysis")
        
        # These can run in parallel
        workflow.add_conditional_edges(
            "collect_data",
            self.should_run_options,  # Only if options are relevant
            {
                "yes": "options_analysis",
                "no": "rag_research"
            }
        )
        
        # Everything feeds into reasoning
        workflow.add_edge(["technical_analysis", "fundamental_analysis", 
                          "sentiment_analysis", "options_analysis"], 
                          "react_reasoning")
        
        # Reasoning leads to report
        workflow.add_edge("react_reasoning", "generate_report")
        
        # End
        workflow.add_edge("generate_report", END)
        
        return workflow
    
    def analyze_stock_comprehensive(self, ticker: str, investment_amount: float = 10000):
        """
        This single function replaces what used to be an entire team's
        worth of manual analysis.
        """
        initial_state = {
            "ticker": ticker,
            "investment_amount": investment_amount,
            "timestamp": datetime.now(),
            "messages": [],
            "market_data": {},
            "technical_indicators": {},
            "fundamental_metrics": {},
            "sentiment_scores": {},
            "options_data": {},
            "portfolio_recommendation": {},
            "documents_retrieved": [],
            "reasoning_trace": [],
            "final_report": "",
            "errors": []
        }
        
        # Run the workflow
        try:
            result = self.app.invoke(initial_state)
            return self._format_comprehensive_report(result)
        except Exception as e:
            # Graceful degradation
            return self._run_basic_analysis(ticker, investment_amount)
class WorkflowNodes:
    """Collection of workflow nodes from actual project"""
    
    def collect_market_data(self, state: AgentState) -> AgentState:
        """Node: Collect market data using tools"""
        print("? Collecting market data...")
        
        ticker = state["ticker"]
        
        try:
            # Use actual stock data tool from project
            tool = self.tool_registry.get_tool("stock_data")
            market_data = tool.execute(
                ticker=ticker,
                metrics=["price", "volume", "market_cap", "pe_ratio", "52_week_high", "52_week_low"]
            )
            
            state["market_data"] = market_data
            
            # Add message to history
            state["messages"].append(
                AIMessage(content=f"Collected market data for {ticker}")
            )
            
        except Exception as e:
            state["error"] = f"Failed to collect market data: {str(e)}"
            state["market_data"] = {}
        
        return state

Here is a screenshot from the example showing workflow analysis:

Production Considerations: From Tutorial to Trading Floor

This tutorial demonstrates core concepts, but let me be clear – production deployment in financial services requires significantly more rigor. Having deployed similar systems in regulated environments, here’s what you’ll need to consider:

The Reality of Production Deployment

Production financial systems require months of parallel running and validation. In my experience, you’ll need:

class ProductionValidation:
    """
    Always run new systems parallel to existing ones
    """
    def validate_against_legacy(self, ticker: str):
        # Run both systems
        legacy_result = self.legacy_system.analyze(ticker)
        agent_result = self.agent_system.analyze(ticker)
        
        # Compare results
        discrepancies = self.compare_results(legacy_result, agent_result)
        
        # Log everything for audit
        self.audit_log.record({
            "ticker": ticker,
            "timestamp": datetime.now(),
            "legacy": legacy_result,
            "agent": agent_result,
            "discrepancies": discrepancies,
            "approved": len(discrepancies) == 0
        })
        
        # Require human review for discrepancies
        if discrepancies:
            return self.escalate_to_human(discrepancies)
        
        return agent_result

Integrating Traditional Financial Algorithms

While this tutorial uses general-purpose LLMs, production systems should combine AI with proven financial algorithms:

class HybridAnalyzer:
    """
    Combine traditional algorithms with AI reasoning
    """
    def analyze_options(self, ticker: str, strike: float, expiry: str):
        # Use traditional Black-Scholes for pricing
        traditional_price = self.black_scholes_pricer.calculate(
            ticker, strike, expiry
        )
        
        # Use AI for market context
        ai_context = self.agent.analyze_market_conditions(ticker)
        
        # Combine both
        if ai_context["volatility_regime"] == "high":
            # AI detected unusual conditions, adjust model
            adjusted_price = traditional_price * (1 + ai_context["vol_adjustment"])
            confidence = "low - unusual market conditions"
        else:
            adjusted_price = traditional_price
            confidence = "high - normal market conditions"
        
        return {
            "model_price": traditional_price,
            "adjusted_price": adjusted_price,
            "confidence": confidence,
            "reasoning": ai_context["reasoning"]
        }

Fitness Functions for Financial Accuracy

Financial data cannot tolerate hallucinations. Implement strict validation:

class FinancialFitnessValidator:
    """
    Reject hallucinated or impossible financial data
    """
    def validate_metrics(self, ticker: str, metrics: Dict):
        validations = {
            "pe_ratio": lambda x: -100 < x < 1000,
            "price": lambda x: x > 0,
            "market_cap": lambda x: x > 0,
            "dividend_yield": lambda x: 0 <= x <= 20,
            "revenue_growth": lambda x: -100 < x < 200
        }
        
        for metric, validator in validations.items():
            if metric in metrics:
                value = metrics[metric]
                if not validator(value):
                    raise ValueError(f"Invalid {metric}: {value} for {ticker}")
        
        # Cross-validation
        if "pe_ratio" in metrics and "earnings" in metrics:
            calculated_pe = metrics["price"] / metrics["earnings"]
            if abs(calculated_pe - metrics["pe_ratio"]) > 1:
                raise ValueError("P/E ratio doesn't match price/earnings")
        
        return True

Leverage Your Existing Data

If you have years of financial data in databases, you don’t need to start over. Use RAG to make it searchable:

# Convert your SQL database to vector-searchable documents
existing_data = sql_query("SELECT * FROM financial_reports")
rag_engine.add_documents([
    {"content": row.text, "metadata": {"date": row.date, "ticker": row.ticker}}
    for row in existing_data
])

Human-in-the-Loop

No matter how sophisticated your agents become, financial decisions affecting real money require human oversight. Build it in from day one:

  • Confidence thresholds that trigger human review
  • Clear audit trails showing agent reasoning
  • Easy override mechanisms
  • Gradual automation based on proven accuracy
class HumanInTheLoopWorkflow:
    """
    Ensure human review for critical decisions
    """
    def execute_trade_recommendation(self, recommendation: Dict):
        # Auto-approve only for low-risk, small trades
        if (recommendation["risk_score"] < 0.3 and 
            recommendation["amount"] < 10000):
            return self.execute(recommendation)
        
        # Require human approval for everything else
        approval_request = {
            "recommendation": recommendation,
            "agent_reasoning": recommendation["reasoning_trace"],
            "confidence": recommendation["confidence_score"],
            "risk_assessment": self.assess_risks(recommendation)
        }
        
        # Send to human reviewer
        human_decision = self.request_human_review(approval_request)
        
        if human_decision["approved"]:
            return self.execute(recommendation)
        else:
            self.log_rejection(human_decision["reason"])

Cost Management and Budget Controls

During development, Ollama gives you free local inference. In production, costs add up quickly so you need to build proper controls for calculating cost of analysis:

  • GPT-4: ~$30 per million tokens
  • Claude-3: ~$20 per million tokens
  • Local Llama: Free but needs GPU infrastructure
class CostController:
    """
    Prevent runway costs in production
    """
    def __init__(self, daily_budget: float = 100.0):
        self.daily_budget = daily_budget
        self.costs_today = 0.0
        self.cost_per_token = {
            "gpt-4": 0.00003,  # $0.03 per 1K tokens
            "claude-3": 0.00002,
            "llama-local": 0.0  # Free but has compute cost
        }
    
    def check_budget(self, estimated_tokens: int, model: str):
        estimated_cost = estimated_tokens * self.cost_per_token.get(model, 0)
        
        if self.costs_today + estimated_cost > self.daily_budget:
            # Switch to local model or cache
            return "use_local_model"
        
        return "proceed"
    
    def track_usage(self, tokens_used: int, model: str):
        cost = tokens_used * self.cost_per_token.get(model, 0)
        self.costs_today += cost
        
        # Alert if approaching limit
        if self.costs_today > self.daily_budget * 0.8:
            self.send_alert(f"80% of daily budget used: ${self.costs_today:.2f}")

Caching Is Essential

Caching is crucial for both performance and cost effectiveness when running expensive analysis using LLMs.

class CachedRAGEngine(RAGEngine):
    """
    Caching reduced our costs by 70% and improved response time by 5x
    """
    
    def __init__(self):
        super().__init__()
        self.cache = Redis(host='localhost', port=6379, db=0)
        self.cache_ttl = 3600  # 1 hour for financial data
    
    def retrieve_with_cache(self, query: str, k: int = 5):
        # Create cache key from query
        cache_key = f"rag:{hashlib.md5(query.encode()).hexdigest()}"
        
        # Check cache first
        cached = self.cache.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # If not cached, retrieve and cache
        docs = self.vector_store.similarity_search(query, k=k)
        
        # Cache the results
        self.cache.setex(
            cache_key, 
            self.cache_ttl,
            json.dumps([doc.to_dict() for doc in docs])
        )
        
        return docs

Fallback Strategies

A Cascading Fallback can help execute a task using a sequence of operations, ordered from the most preferred (highest quality/cost) to the least preferred (lowest quality/safest default).

class ResilientAgent:
    """
    Production agents need multiple fallback options
    """
    
    def analyze_with_fallbacks(self, ticker: str):
        strategies = [
            ("primary", self.run_full_analysis),
            ("fallback_1", self.run_simplified_analysis),
            ("fallback_2", self.run_basic_analysis),
            ("emergency", self.return_cached_or_default)
        ]
        
        for strategy_name, strategy_func in strategies:
            try:
                result = strategy_func(ticker)
                result["strategy_used"] = strategy_name
                return result
            except Exception as e:
                logger.warning(f"Strategy {strategy_name} failed: {e}")
                continue
        
        return {"error": "All strategies failed", "ticker": ticker}

Observability and Monitoring

Track token usage, latency, accuracy, and costs immediately. What you don’t measure, you can’t improve.

class ObservableWorkflow:
    """
    You need to know what your AI is doing in production
    """
    
    def __init__(self):
        self.metrics = PrometheusMetrics()
        self.tracer = JaegerTracer()
    
    def execute_with_observability(self, state: AgentState):
        with self.tracer.start_span("workflow_execution") as span:
            span.set_tag("ticker", state["ticker"])
            
            # Track token usage
            tokens_start = self.llm.get_num_tokens(state)
            
            # Execute workflow
            result = self.workflow.invoke(state)
            
            # Record metrics
            tokens_used = self.llm.get_num_tokens(result) - tokens_start
            self.metrics.record_tokens(tokens_used)
            self.metrics.record_latency(span.duration)
            
            # Log for debugging
            logger.info(f"Workflow completed", extra={
                "ticker": state["ticker"],
                "tokens": tokens_used,
                "duration": span.duration,
                "strategy": result.get("strategy_used", "primary")
            })
            
            return result

Closing Thoughts

This tutorial demonstrates how agentic AI transforms financial analysis from rigid pipelines to adaptive, thinking systems. The combination of ReAct reasoning, RAG grounding, tool use, and workflow orchestration creates capabilities that surpass traditional approaches in flexibility and ease of development.

Start Simple, Build Incrementally:

  • Week 1: Basic ReAct agent to understand reasoning loops
  • Week 2: Add tools for external capabilities
  • Week 3: Implement RAG to ground responses in real data
  • Week 4: Orchestrate with workflows
  • Develop everything locally with Ollama first – it’s free and private

The point of agentic AI is automation. Here’s the pragmatic approach:

Automate in Tiers:

  • Tier 1 (Fully Automated): Data collection, technical calculations, report generation
  • Tier 2 (Auto + Audit): Sentiment analysis, risk scoring, anomaly detection
  • Tier 3 (Human Required): Large trades, strategy changes, regulatory decisions

Clear Escalation Rules:

ESCALATE_IF = {
    "confidence_below": 0.8,
    "amount_above": 100000,
    "regulatory_flag": True,
    "anomaly_detected": True
}

Reinforcement Learning:

Instead of permanent human-in-the-loop, use RL to train agents that learn from feedback:

class ReinforcementLearningLoop:
    """
    Gradually reduce human involvement through learning
    """

    def ai_based_reinforcement(self, decision, outcome):
        """AI learns from market outcomes directly"""
        # Did the prediction match reality?
        reward = self.calculate_reward(decision, outcome)

        if decision["action"] == "buy" and outcome["price_change"] > 0.02:
            reward = 1.0  # Good decision
        elif decision["action"] == "hold" and abs(outcome["price_change"]) < 0.01:
            reward = 0.5  # Correct to avoid volatility
        else:
            reward = -0.5  # Poor decision

        # Update agent weights/prompts based on reward
        self.agent.update_policy(decision["context"], reward)

    def human_feedback_learning(self, decision, human_override=None):
        """Learn from human corrections when they occur"""
        if human_override:
            # Human disagreed - strong learning signal
            self.agent.record_correction(
                agent_decision=decision,
                human_decision=human_override,
                weight=10.0  # Human feedback weighted heavily
            )
        else:
            # Human agreed (implicitly by not overriding)
            self.agent.reinforce_decision(decision, weight=1.0)

    def adaptive_automation_threshold(self):
        """Dynamically adjust when human review is needed"""
        recent_accuracy = self.get_recent_accuracy(days=30)

        if recent_accuracy > 0.95:
            self.confidence_threshold *= 0.9  # Require less human review
        elif recent_accuracy < 0.85:
            self.confidence_threshold *= 1.1  # Require more human review

        return self.confidence_threshold

This approach reduces human involvement over time: use that feedback to train, gradually automate decisions where the agent consistently agrees with humans, and only escalate novel situations or low-confidence decisions.


Complete code at github.com/bhatti/agentic-ai-tutorial. Start local, validate thoroughly, scale confidently.

Powered by WordPress