Consulting

Inside the Agentic Loop

How a cloud orchestrator drives a stateful desktop application through streamed LLM tool calls. The harder part: knowing whether the work actually landed.

August 7, 2026 · ~15 min read · C# / .NET 8, Python, TypeScript

NexusAI is an AI agent that automates Autodesk Revit. A user describes what they want in plain language (renumber every door on level 3, build a curtain wall system across this facade, audit the model for unhosted families), and the agent inspects the model, writes and runs code against the Revit API, checks the result, and iterates until it is done.

The interesting engineering is not the prompt. It is the loop around the model: a long-lived server-side orchestration that has to reach into a desktop application on someone else's machine, wait, interpret what came back, and decide whether to stop. This article walks through how that loop is built and, more usefully, the decisions that turned out to matter.

About the code

The snippets below are simplified from the production source to make a pattern legible: names shortened, error paths and vendor-specific branches removed. They are illustrative, not a working system. Nothing here concerns authentication, device pairing, transport security, or the runtime's code-validation layer.

1. The shape of the problem

Revit is a stateful, single-threaded desktop application holding a document that may be hundreds of megabytes and shared by a team. The language model lives in a datacenter and has never seen that document.

The obvious product is a chatbot that writes Python for you to paste into a macro editor. That product fails in three predictable ways:

  • It is guessing. Without reading the actual model, the code references families, parameters and levels that may not exist in this project.
  • It gets no feedback. A script that throws on line 40 of 60 has already applied the first 39 lines' worth of changes.
  • The human is the retry loop. Every failure round-trips through a person copying an error message back into a chat window.

Closing all three means the model needs to inspect before it acts, act transactionally, and observe the consequences of acting, with the person as reviewer rather than courier. That is what "agentic" has to mean here; it is not a synonym for "the model calls a function."

2. Three processes, one loop

Three processes participate in every run, and the loop lives in exactly one of them.

Browser Conversation & UI Renders streamed reasoning, code and results. Carries tool calls out to the desktop and results back.
Cloud The agentic loop Owns the transcript, the model calls, the tool schema, the budgets, and every decision about whether to continue.
Desktop Host runtime Executes code against the live application API on its main thread, inside a transaction, and reports what happened.

Putting the loop in the cloud rather than the add-in was the single most consequential structural choice. A desktop binary is the slowest and most expensive deployment channel a product has: it is code-signed, it accrues reputation with security vendors, and shipping it asks every user to re-run an installer. A server deploys in minutes. So the add-in is deliberately dumb. It exposes a small set of generic primitives (execute this code, tell me what changed, export this view) and knows nothing about agents, tools, models or verification. All of that behavior lives in the cloud, where it can be changed on a Tuesday afternoon.

The consequence that shapes the code: a tool call has to cross two process boundaries and come back, possibly minutes later, while an HTTP response stream to the browser stays open the whole time.

3. The loop is an async iterator

A run is one HTTP request that streams for its entire life. On the server that is naturally an IAsyncEnumerable<StreamChunk>, serialized to the browser as server-sent events. The whole orchestration is a while loop that yields as it goes:

C# The entire control flow, minus the vendor-specific parts
public static async IAsyncEnumerable<StreamChunk> RunAgenticLoopAsync(
    AgenticSession session,
    [EnumeratorCancellation] CancellationToken ct)
{
    var messages = BuildInitialMessages(session);
    var turn = new ModelTurn();

    while (!ct.IsCancellationRequested)
    {
        var phase = ResolvePhase(session);

        // Stream one model turn straight through to the browser: reasoning,
        // prose, and the tool-call JSON while the model is still writing it.
        await foreach (var chunk in StreamModelTurnAsync(session, messages, phase, ct))
        {
            turn.Absorb(chunk);
            yield return chunk;
        }

        if (turn.ToolCalls.Count == 0)
        {
            yield return Complete(turn.Text, session);   // stop_reason == end_turn
            yield break;
        }

        var results = new List<object>();
        foreach (var call in turn.ToolCalls)
            results.Add(await ExecuteToolAsync(session, call, ct));

        messages.Add(AssistantTurn(turn));
        messages.Add(ToolResultTurn(results));
        turn = new ModelTurn();
    }
}

What is not there is the point. There is no workflow engine, no step graph, no "plan → execute → verify" state machine. The transcript is the state machine. Everything the agent knows about the run is in the messages array, which means the model, not a hand-written controller, decides what happens next, and the loop's job is reduced to transporting results and enforcing limits.

Server-side session state exists, but it is small and exists only for things the transcript cannot express: the current script text so edits can be applied to it, counters for the retry budgets, which host application to route to, and the channel described next.

4. Suspending a turn on another machine

When the model asks to run code, the loop emits a ToolCall chunk and then has to stop and wait. The result will arrive on a completely different HTTP request, when the browser posts back what the desktop runtime returned. Two requests, one rendezvous.

A bounded channel of capacity one is the whole mechanism:

C# Cross-request rendezvous
// Exactly one tool is ever in flight per session. A bounded channel of one gives
// the writer back-pressure instead of a race: a second result waits for the reader
// rather than overwriting the first.
ToolResultChannel = Channel.CreateBounded<AgenticToolResultPayload>(
    new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.Wait });
C# Timeout and user cancellation collapse into one signal
/// Suspends the loop until a result for this tool call is posted back, the user
/// cancels, or we give up waiting. Returns null for both timeout and cancellation:
/// the loop treats "no result" identically either way.
private static async Task<AgenticToolResultPayload?> WaitForToolResultAsync(
    AgenticSession session,
    CancellationToken ct)
{
    using var timeout = new CancellationTokenSource(
        TimeSpan.FromSeconds(ToolResultTimeoutSeconds));
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);

    try
    {
        return await session.ToolResultChannel.Reader.ReadAsync(linked.Token);
    }
    catch (OperationCanceledException)
    {
        return null;
    }
}

Three details in there were learned rather than designed:

  • A channel, not a TaskCompletionSource. The reader and the writer are different requests with independent lifetimes. A channel gives back-pressure and a clean cancellation story for free; a bare TCS invites double-completion bugs.
  • The timeout is measured in minutes, not seconds. A desktop application can block on a modal dialog, a slow view regeneration, or a user who walked away. Anything aggressive here fails healthy runs.
  • The stream needs keepalives while suspended. An SSE response that emits nothing for two minutes gets closed by an intermediary proxy, and the symptom looks exactly like the agent hanging. A comment frame every fifteen seconds fixes it.

5. One loop, four model vendors

NexusAI runs on models from four vendors, and users switch between them mid-conversation. Every one of them disagrees about almost everything: how tools are declared, what a streaming event looks like, how a tool result is shaped (a tool_result content block, a function_call_output item, a functionResponse part), and whether an image may ride along with that result or has to be attached to the following user turn.

The containment strategy is a single narrow outbound contract. Each vendor's stream is normalized into one small union, and the browser only ever knows about that:

C# The only streaming vocabulary the client understands
public enum StreamChunkType
{
    Metadata,       // model info, context-compaction notices
    Thinking,       // reasoning, streamed as it is produced
    Message,        // user-facing prose
    ToolCall,       // the model wants to run something
    ToolInputDelta, // partial tool-call JSON, while the model is still writing it
    Complete,       // the run ended normally
    Error,
    AgenticUsage    // token accounting, flushed when a run ends without Complete
}

public record StreamChunk(
    StreamChunkType Type,
    string Content,
    Dictionary<string, object>? Metadata = null);

The price is that tool schemas are declared four times, once per vendor dialect. The payoff is that adding a model is a backend-only change: the frontend, the transcript format, and the stored history are untouched.

ToolInputDelta deserves a note, because it looks like a debugging artifact and is actually a product feature. Vendors will stream a tool call's arguments token by token before the call is complete. For a tool whose argument is a 300-line Python script, that means the user watches the script being written, live, in the same way they would watch a colleague type. Treating partial tool input as a first-class stream event rather than something to buffer until valid changed how the product feels more than any prompt change did.

Where the frontend gets interesting

Painting those deltas token-by-token means committing React state per chunk under flushSync, which puts you within range of React's nested-update ceiling. One write-only useEffect keyed on the message array was enough to make every delta count as a nested update and kill long runs mid-automation. Streaming UI has its own failure modes, and they are not the backend's.

6. Verification is the hard part

Everything so far is plumbing. This is the part that decides whether the product works.

When a script finishes, the runtime reports success. That success means the code ran. It does not mean the doors were renumbered correctly, that the walls landed on the right level, or that the model isn't now full of 200 elements nobody asked for. An agent that treats "no exception" as "done" is confidently wrong at scale, and in a BIM model that is expensive.

So a run produces three independent signals, and the model sees all three:

  1. The transactional outcome. Work happens inside one transaction that either commits or rolls back completely. A script that throws halfway leaves the document exactly as it was, which is also what makes aggressive retrying safe.
  2. A structured change report generated after the commit, describing what actually changed in the document.
  3. A rendered image of the affected view, fed back as vision input, when the work was geometric.

The epilogue pattern

The change report comes from what is internally called the epilogue: once the user-intent script commits, the runtime immediately runs a second, read-only script in the same session. That script is composed in the cloud, not written by the model, and its entire job is to describe the consequences of the first one. Its stdout is appended to the tool result the model receives on its next turn.

Writing that report is a summarization problem, because the report competes for the same token budget as everything else in the conversation. The heuristic that works: name elements individually while there are few enough to act on, and switch to grouped counts once there aren't.

Python Cloud-composed epilogue, running on the desktop after commit
def _nx_change_report():
    changed = nexus_changes().get('changed', [])
    infos, unresolved = [], 0

    for eid in changed:
        el = doc.GetElement(eid)
        if el is None:            # deleted by the script, or not introspectable
            unresolved += 1
            continue
        infos.append((_nx_idval(eid), _nx_category(el),
                      _nx_family_and_type(el), _nx_level_name(el)))

    lines = ['=== CHANGE REPORT ===']

    if len(infos) <= 15:
        # Small edit: name every element, so the next turn can address one by id.
        lines.append(str(len(infos)) + ' element(s) changed:')
        for (idv, cat, ft, lvl) in infos:
            row = '- #' + str(idv) + ' ' + cat + ' | ' + ft
            lines.append(row + (' | Level: ' + lvl if lvl else ''))
    else:
        # Bulk edit: per-type counts answer "did I create 2 walls or 240?"
        # far better than 240 lines of detail the model has no budget to read.
        groups = {}
        for info in infos:
            groups.setdefault(info[1] + '\x00' + (info[2] or ''), []).append(info)
        for g in sorted(groups.values(), key=lambda g: -len(g)):
            lines.append('- ' + str(len(g)) + 'x ' + g[0][1] + ' | ' + g[0][2]
                         + ' | e.g. #' + str(g[0][0]))

    print('\n'.join(lines))

This is the difference between an agent that claims success and an agent that can be caught being wrong. A report saying 1x Walls | Generic - 200mm when the request was for a wall per gridline is a signal the model can act on. Silence is not.

7. Deleting the verify tool

There used to be a fourth tool called evaluate_result. The model called it after each automation with a structured verdict: ok, shouldIterate, issues, iterationPlan. It looked rigorous. It was removed, and the product got better.

The problem was that the tool carried no information the model didn't already have. It had the change report, it had the screenshot, and it had to reason about acceptance anyway in order to choose its next action. Formalizing that reasoning as a tool call cost a full network round trip and a full turn's tokens to move zero new facts, and it subtly encouraged the model to narrate a decision instead of making one: verdicts of ok: true followed by another correction attempt in the next breath.

Now, on the turn after an automation returns, the model either writes its final message to the user or calls the automation tool again. Same decision, one fewer turn, and no way to pronounce a verdict without acting on it.

Rule of thumb

A tool should bring the model information it cannot otherwise obtain. A tool that only records a judgment the model has already made is ceremony, and you pay for ceremony in latency, tokens, and misalignment between what the agent says and what it does.

8. Behavior in the cloud, not the binary

The epilogue is Python composed as a string on the server and handed to the desktop runtime, which executes it using the same generic primitives it exposes to any script. Nothing about change reporting, grouping thresholds, view selection or image sizing exists in the shipped binary. That means the behavior in section 6 can be rewritten and deployed without producing a new installer, which for a signed desktop product is the difference between an afternoon and a release cycle.

Once that indirection exists, supporting a second host application is mostly a matter of registering another implementation. Everything that varies between desktop applications sits behind one interface:

C# The per-application seam
/// Everything that differs between host applications sits behind this interface.
/// Orchestration, streaming, and the model layer stay shared: a new host needs a
/// provider here plus a thin runtime exposing the same primitive contract.
public interface IHostCapabilityProvider
{
    /// Canonical host id, e.g. "revit". Matches what the runtime reports.
    string HostApp { get; }

    /// Versioned independently of the desktop binary, so capability behavior can
    /// move without a release.
    string CapabilityVersion { get; }

    /// Post-commit verification script for one automation run, composed from the
    /// host runtime's primitives. Runs read-only after the transaction commits
    /// and prints a structured report to stdout.
    string BuildTaskAutomationEpilogue();
}

Because these scripts are generated per run rather than compiled in, every dispatch is stamped with its provenance: which host, which capability version, and a content hash of the exact script that was sent:

C# Provenance for code that is composed, not shipped
public static CapabilityScript BuildStampedTaskAutomationEpilogue(string? hostApp)
{
    var provider = GetProvider(hostApp);
    var code = provider.BuildTaskAutomationEpilogue();
    return new CapabilityScript(
        provider.HostApp, provider.CapabilityVersion, code, ContentHash(code));
}

public sealed record CapabilityScript(
    string HostApp, string Version, string Code, string ContentHash);

The hash is cheap and pays for itself the first time a user reports behavior you cannot reproduce: the audit log names the exact bytes their machine ran.

9. Why one retry counter is a bug

An agentic loop needs a stopping condition that isn't "the model got bored." The first version used a single iteration counter, which quietly conflated two completely different situations:

  • The script failed: a syntax error, a bad API call, a rolled-back transaction. Retrying means fixing code.
  • The script worked and the result isn't good enough. Retrying means improving a design.

With one budget, a stubborn typo consumes the entire allowance that existed to refine a working result, and the agent gives up right at the point where it had finally started making progress. Two counters, two caps:

C# Classify before spending anything
// A "fix" follows a run that failed; an "iteration" follows one that succeeded.
bool isFix = !session.LastTaskCommitted;

if (isFix && session.FixRetryCount >= MaxFixRetries)
    return CapReached(session, toolId, FixRetryCapPayload(MaxFixRetries));

if (!isFix && session.IterationCount >= MaxIterations)
    return CapReached(session, toolId, IterationCapPayload(MaxIterations));

if (isFix) session.FixRetryCount++;
else       session.IterationCount++;

Note that hitting a cap does not throw. It returns a normal tool result that tells the model the budget is exhausted and it should summarize honestly for the user. An agent that runs out of budget should end a conversation, not an HTTP request.

Output budgets follow the same logic

A turn that has to emit a 400-line script and a turn that has to decide "this looks right, I'm done" have wildly different output needs, and a generous cap is not free. With adaptive reasoning, a model handed 64,000 output tokens will find a use for them. So the phase of the run is inferred from session state before each turn and sizes the budget:

C# Cheap inference from state that already exists
internal static AgenticPhase ResolvePhase(AgenticSession session)
{
    // A script already exists, so this turn may rewrite or patch it.
    if (!string.IsNullOrEmpty(session.CurrentTaskCode))
        return AgenticPhase.Edit;

    // Data came back but nothing has been written yet. Reserve the budget for the
    // tool call itself; unbounded reasoning will otherwise consume all of it
    // before ever emitting one.
    if (session.LastCompletedTool == "execute_data_collection")
        return AgenticPhase.PostDataCollection;

    return AgenticPhase.Initial;
}

internal static int ResolveMaxTokens(AgenticPhase phase) => phase switch
{
    AgenticPhase.Initial            => HeavyPhaseMaxTokens,  // first full script
    AgenticPhase.PostDataCollection => HeavyPhaseMaxTokens,
    AgenticPhase.Edit               => HeavyPhaseMaxTokens,
    AgenticPhase.Verify             => LightPhaseMaxTokens,  // a decision, not a script
    AgenticPhase.Summary            => LightPhaseMaxTokens,
    _                               => HeavyPhaseMaxTokens,
};

The lesson generalizes past token budgets: state you are already tracking for correctness is usually enough to make good resource decisions, without adding a planner that announces its intentions in advance.

10. Two audiences for one result

A script that dumps every door in a large model produces tens of thousands of lines. The human wants all of it, scrollable, exactly as printed. The model must not receive it, because a single tool result is capable of crowding out the entire rest of the conversation.

Every result is therefore rendered twice, and the model's copy says so:

C# The user's copy is never modified
/// Two audiences, one result. The model gets a deduplicated, capped version so a
/// 60,000-row dump cannot evict the conversation; the user always sees the full,
/// unmodified output in the UI.
private static string TruncateToolResult(string result)
{
    bool didDedup;
    (result, didDedup) = DeduplicateToolResult(result);

    bool didTruncate = result.Length > MaxToolResultChars;
    if (didTruncate)
        result = result.Substring(0, MaxToolResultChars);

    return result + (didDedup, didTruncate) switch
    {
        (true,  true)  => "\n\n[Note: duplicate rows removed; output capped for AI processing.]",
        (true,  false) => "\n\n[Note: duplicate rows removed from output for AI processing.]",
        (false, true)  => "\n\n[Note: output capped for AI processing.]",
        _              => ""
    };
}

Telling the model what was done to its input matters as much as doing it. A silently truncated table reads as a complete table, and the model will reason confidently about the 300 doors it can see as though they were all 4,000. Deduplication comes first because repeated identical rows are the single most common way a result gets large without getting informative.

The same principle applies to images. Every screenshot handed to a model is scaled to that vendor's actual vision ceiling rather than a conservative global cap. An earlier flat 1024px limit saved input tokens and made annotation text in a captured view illegible, which defeats the entire purpose of showing the model its own work.

11. Editing instead of rewriting

Once a script exists, most corrections are one or two lines. Re-emitting 400 lines to change one of them is slow, expensive, and an opportunity for the model to accidentally change something else. So the automation tool accepts either a full script or a list of line-targeted edits that the server applies to the script it is already holding.

The interesting decision was what to do when a merge looks wrong: a duplicated line, an unexpected indentation change, a script that suddenly shrank by half. The first implementation rejected the edit and returned an error, forcing the model to try again. That was worse. It burned a full turn to move no information, and the heuristics were wrong often enough to block legitimate edits.

Now those validators are advisory: the merged script runs, and the concern is attached to the result as a note. If the merge really was broken, Python raises, the transaction rolls back completely, and the model gets a real error against a clean document, which is strictly more useful than a guess about a syntactic smell.

Generalization

When the runtime can undo, prefer executing and observing over pre-validating. Speculative validation costs a round trip on every false positive; atomic rollback costs nothing when you are right.

12. The cost of a growing transcript

Every turn re-sends the whole conversation. On a long automation that is the dominant cost, and it grows quadratically with the number of turns. Two mitigations carry most of the weight.

Cache the prefix. Vendors that support explicit prompt-cache breakpoints will bill a cached prefix at a fraction of the base input rate. Placing a breakpoint on the system prompt and tool schema is obvious; the one that matters for agentic loops is a breakpoint on the trailing message of each request. That converts the whole conversation so far into cache-eligible content, so the prefix grows into the cache turn by turn instead of being re-billed as new input on every iteration.

Compact, and say so. A long run will eventually exceed the context window. When the transcript is summarized, either by the vendor or locally as a fallback, the loop emits a Metadata chunk that renders as a visible "context summarized" marker in the conversation. This is a user-experience fix disguised as an infrastructure detail: an agent that silently forgets what it was told twenty minutes ago looks broken, while one that shows you where its memory was condensed looks like it is managing a hard constraint.

13. Takeaways

If you are building something similar, this is the short version of what building, and repeatedly rebuilding, this loop produced:

  • The transcript is the state machine. Resist building a workflow engine around the model. Keep server state to what the transcript genuinely cannot hold.
  • Verification is a product feature, not a test. "The code ran" is not "the work is right," and the gap between them is where trust is won or lost.
  • Tools should carry information, not opinions. If a tool only records a conclusion the model already reached, delete it.
  • Put behavior where deploys are cheap. Anything expressible as server-composed data should not be compiled into a binary you have to sign and ship.
  • Separate budgets for separate failure modes. Fixing broken code and improving a working result are different activities and must not share an allowance.
  • Every result has two audiences. Render for the model and for the human separately, and tell the model when you have edited its input.
  • Prefer executing and observing over pre-validating, whenever the runtime can atomically undo.

None of these came from a paper. All of them came from watching a real agent be confidently wrong in a real model, in front of a real user.

About the author

I'm James Allen. NexusAI is a commercial AI agent for Autodesk Revit that I designed, built and maintain end to end, from the cloud backend through the web application to the desktop add-in.

  • Backend: C# / .NET 8, ASP.NET Core, Azure App Service, SQL Server, SignalR, server-sent event streaming, Stripe billing, multi-tenant entitlements.
  • Frontend: TypeScript, Next.js, React, streaming agentic UI, MSAL authentication.
  • Desktop: C# add-in targeting .NET Framework 4.8, .NET 8 and .NET 10 from one codebase, Revit API, embedded Python runtime, signed Windows installer.
  • AI: multi-vendor orchestration across Anthropic, OpenAI, Google and xAI models, native tool use, prompt caching, context compaction, vision-based verification.

Autodesk Developer Network member, USUS0360. Microsoft Partner, ID 7092119.

I also build this kind of thing on contract for AEC technology firms, white-label, with a mutual non-circumvent signed before anything starts. Availability and the rest of the write-ups are on the consulting page.