BrandGhost
Human-in-the-Loop AI Agents in C#: Approval and Resume with Vercel Eve

Human-in-the-Loop AI Agents in C#: Approval and Resume with Vercel Eve

Human-in-the-loop AI agents need more than an Approve button. They need a trustworthy connection between a person, a specific pending request, and the decision the server actually accepts. Otherwise, a helpful-looking interface can answer the wrong question, approve a different action, or report success before authorization finishes.

This article follows one harmless example: an assistant proposes adding an item to a synthetic equipment-maintenance checklist. The action only returns mock data. Nobody operates equipment, updates a real maintenance system, or declares a machine safe. That lets us examine the decision boundary without hiding a consequential operation behind tutorial code.

I maintain NexusLabs.Eve, the independent .NET client used in these examples. It is not an official Vercel SDK.

The C# examples select .NET 10 with C# 14 and nullable enabled, matching the tagged client's net10.0 and nullable baseline, use NexusLabs.Eve 0.1.0-alpha-0014, and follow the client's v0.1.0-alpha.14 source; the TypeScript tool targets the beta Eve 0.63.0 fixture baseline. These examples are source-reviewed, not newly built or executed, and neither preview label should be mistaken for a compatibility promise about later releases.

What Human-in-the-Loop AI Agents Actually Wait For

A pause is a control boundary, not an authentication mechanism. Your application still needs to know who is responding, whether that person may access the conversation, and whether their authority covers this particular decision. A tool being harmless in a demonstration does not justify leaving those distinctions out of the design.

Eve's pinned human-input protocol parks work at session.waiting after input.requested, then resumes it when an answer is accepted, as described in the 0.63.0 pause-and-resume contract. Conceptually, a browser can disappear while the decision is outstanding. The person returning later should see the same pending task, not an invented replacement.

For our checklist, separate three questions. Which equipment record is being discussed? May this person review the session? Did they explicitly choose to approve this exact mock checklist action? The first is conversation context. The second is application authorization. The third is recorded human intent.

These ideas also appear in tool approval and human-in-the-loop in Microsoft Agent Framework. That is useful background for .NET readers, but the code below uses Eve's own request and response contract rather than borrowing another framework's types.

Human-in-the-loop AI agents should not treat every interruption as approval. A clarification might ask which synthetic equipment identifier the user meant. A resource-limit prompt might ask whether work should continue. Neither decision grants permission to execute a pending tool call. Keeping those meanings separate is the first protection against an attractive but incorrect generic confirmation screen.

Require a Decision Before the Mock Action

Start at the action boundary. Eve 0.63.0 exposes defineTool from eve/tools and always from eve/tools/approval, with always() requiring approval before every call, according to the pinned tool approval documentation. We use that explicit policy rather than a classifier or a policy that sometimes bypasses the person.

Save this complete tool file as agent/tools/propose_mock_checklist.ts in an existing Eve 0.63.0 agent project with zod available. It is not a standalone server or a complete deployment configuration.

import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";

export default defineTool({
  description: "Propose a fictional checklist item; perform no real action.",
  inputSchema: z.object({
    equipmentId: z.literal("pump-demo-01"),
    item: z.literal("Review the synthetic inspection note"),
  }),
  approval: {
    request: always(),
    response: ({ responder }) => {
      const allowed = responder.principalId === "demo:reviewer-01";
      return allowed
        ? { status: "allowed" }
        : { status: "rejected", reason: "Not a synthetic checklist reviewer." };
    },
  },
  async execute({ equipmentId, item }) {
    return { equipmentId, item, mocked: true, recordedExternally: false };
  },
});

The response-policy shape, authenticated responder.principalId, and allowed versus rejected statuses come from the pinned approval-response policy contract. The identity is deliberately synthetic. A trusted route or channel must supply the authenticated responder; accepting that string from an arbitrary request body would not authenticate anyone.

This policy illustrates a server-side reviewer check, not a complete application ownership policy. The .NET boundary below separately requires that the caller owns the session. In a real design, enforce both relevant checks at the trusted boundary, and preserve the server-side decision check rather than relying solely on a hidden button.

A rejected response policy leaves the shared approval pending so an eligible responder can answer, according to the same pinned contract. That is different from the human deliberately choosing to cancel the action. One rejects the responder's authority; the other records a decision about the requested work.

Always requiring review is easy to explain and audit, but it also creates more interruptions. Conditional policies reduce interruptions while introducing policy branches that need independent validation. For this small educational example, requiring review keeps the important boundary visible.

Classify Human-in-the-Loop AI Agents by Framework Kind

The pinned C# client projects the framework-owned wire kind into EveInputRequest.Kind, with Question, ToolApproval, SessionLimit, and Unknown values, as its request model and decoder show. In the example, frameworkKind is a local variable holding that property, not an additional SDK API.

Do not infer semantics from Display, option labels, or an action's tool name: those are not the authoritative kind discriminator, as the tagged request documentation warns. Two prompts may both look like yes/no confirmations while representing completely different decisions.

Think of classification as selecting an interaction, not selecting an answer. A question requires an answer workflow. Tool approval requires an explicit approve-or-cancel workflow. A session limit requires a separate continue-or-stop workflow. No branch should translate a generic "yes" into whichever action happens to appear first.

The transport foundation can use a caller-owned HTTP client, as the tagged client overview documents. For the surrounding application's lifetime and dependency-injection concerns, IHttpClientFactory patterns provide relevant context without changing the human-decision protocol.

Select the Requested Approval, Never the First Prompt

Selection should start with a request identifier the person actually reviewed. The application must obtain the corresponding pending requests from trusted session state, not from a caller-supplied list. The caller supplies a decision and request identifier; the server supplies the facts against which they are checked.

Save this complete supporting file as ApprovalBoundary.cs in the C# project. It has no hidden helper dependencies. Its synthetic identity record is an executable fake for illustrating checks, not an authentication provider.

using System;
using System.Collections.Generic;
using System.Linq;
using NexusLabs.Eve;

namespace MaintenanceDemo;

public sealed record DemoCaller(string PrincipalId, bool IsAuthenticated);
public enum HumanDecision { Approve, Cancel }

public sealed class ApprovalBoundary
{
    public EveInputResponse Prepare(
        DemoCaller caller,
        string sessionOwner,
        IReadOnlyList<EveInputRequest> pending,
        string requestedId,
        HumanDecision decision)
    {
        if (!caller.IsAuthenticated ||
            caller.PrincipalId != sessionOwner ||
            caller.PrincipalId != "demo:reviewer-01")
        {
            throw new UnauthorizedAccessException("Reviewer or owner check failed.");
        }

        EveInputRequest request = pending.Single(
            candidate => candidate.RequestId == requestedId);
        EveInputRequestKind frameworkKind = request.Kind;
        switch (frameworkKind)
        {
            case EveInputRequestKind.Question:
                throw new InvalidOperationException("Use the question-answer workflow.");
            case EveInputRequestKind.SessionLimit:
                throw new InvalidOperationException("Use the session-limit workflow.");
            case EveInputRequestKind.ToolApproval:
                break;
            default:
                throw new NotSupportedException(
                    $"Unhandled input kind: {request.RawKind ?? "(missing)"}");
        }

        string optionId = decision switch
        {
            HumanDecision.Approve => "approve",
            HumanDecision.Cancel => "cancel",
            _ => throw new ArgumentOutOfRangeException(nameof(decision)),
        };
        if (!request.Options.Any(option => option.Id == optionId))
        {
            throw new InvalidOperationException("The chosen option is unavailable.");
        }

        return new EveInputResponse(request.RequestId, optionId: optionId);
    }
}

The response constructor accepts requestId, optional optionId, and optional text, as defined by the tagged response model; the approve and cancel identifiers follow the pinned approval options. Checking option availability validates the already-classified request; it does not guess its kind from its options.

There is deliberately no default decision. The caller must pass an explicit value representing the person's choice. Missing and duplicate matches fail rather than silently choosing another request. That makes stale selections visible and prevents a batch of pending questions from accidentally becoming a batch of approvals.

The fake caller must be constructed by trusted demonstration code using known synthetic identities. In an application, derive authentication and ownership from verified server-side identity and storage, not browser fields. Authentication and authorization in ASP.NET Core explains that broader distinction; an approval endpoint still needs its own resource-level ownership check.

For human-in-the-loop AI agents, a useful decision record includes the verified principal, session identifier, request identifier, explicit decision, and timestamp. Record submission separately from acceptance. Avoid retaining unnecessary prompt or equipment details, and do not call the submission "approved" before the authoritative result arrives.

The trusted host also needs to bind the owner and pending snapshot to the exact EveSession it passes into the service. Checking a real owner's identity against a different conversation's pending list would satisfy the fake's comparisons while violating the actual resource boundary. Treat the session, owner, and snapshot as one server-loaded context, not three independently editable form values.

There is a timing issue even with correct loading. Another eligible person might answer while the original review screen is open. Do not promise that a locally observed pending request remains pending forever. Preserve the selected request identity, submit only that decision, and reconcile the server's response. If the original request is no longer available, display that fact instead of asking the model to recreate it.

Resume with Responses Only, Then Read What Settled

EveSession.RespondAsync sends input responses without a user message, and the pinned contract makes messages and input responses mutually exclusive, as the session implementation documents. Keeping this path separate avoids turning a human decision into an ambiguous conversational follow-up.

Save this complete service file as ApprovalResume.cs, alongside the previous file. A trusted host supplies the existing session, its current pending snapshot, the verified owner and caller, and the explicit decision. The class does not create identities, load credentials, or make a decision for the person.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NexusLabs.Eve;

namespace MaintenanceDemo;

public sealed class ApprovalResume
{
    private readonly ApprovalBoundary _boundary = new();

    public async Task<EveTurnOutcome> ResolveAsync(
        EveSession session,
        DemoCaller caller,
        string sessionOwner,
        IReadOnlyList<EveInputRequest> pending,
        string requestedId,
        HumanDecision decision,
        CancellationToken cancellationToken)
    {
        EveInputResponse answer = _boundary.Prepare(
            caller, sessionOwner, pending, requestedId, decision);
        EveMessageResponse response = await session.RespondAsync(
            [answer], cancellationToken);
        EveTurnOutcome outcome = await response.GetOutcomeAsync(cancellationToken);

        foreach (EveInputResolution resolution in outcome.InputResolutions)
        {
            string meaning = resolution.Kind switch
            {
                EveInputRequestKind.Question => "Question answer",
                EveInputRequestKind.SessionLimit => "Session-limit decision",
                EveInputRequestKind.ToolApproval => "Tool-approval decision",
                _ => "Unsupported request kind",
            };
            Console.WriteLine(
                $"{meaning}: {resolution.RequestId} / {resolution.RawOutcome}");
            if (resolution.RequestId == requestedId &&
                (resolution.Kind != EveInputRequestKind.ToolApproval ||
                 resolution.Outcome == EveInputResolutionOutcome.Unknown))
            {
                throw new NotSupportedException("Unexpected approval resolution.");
            }
        }

        if (outcome.Status == EveTurnStatus.Failed)
        {
            Console.Error.WriteLine(
                $"Session {outcome.SessionId} failed; no successful result is assumed.");
            throw new InvalidOperationException("The resumed turn failed.");
        }

        Console.WriteLine($"Observed boundary: {outcome.Status}");
        Console.WriteLine(outcome.Message ?? "(No completed assistant message)");
        return outcome;
    }
}

GetOutcomeAsync aggregates the consumed events into InputResolutions, InputRequests, Message, Data, and Status, with session.failed producing EveTurnStatus.Failed, as the tagged aggregation implementation shows. A response is single-use, so this example aggregates it instead of first enumerating it and then trying to aggregate it again, following the same source contract.

The authoritative resolution preserves its request kind, outcome, optional accepted response, and original turn coordinates, according to the tagged resolution model. In particular, Answered, Approved, Denied, Ignored, and Invalid are different projected outcomes; unknown values remain available through RawOutcome, as the outcome enumeration defines.

The console output is observation, not a persistent audit store. A host can use the returned outcome to record accepted resolutions under their actual identifiers. If no matching resolution appears, the human submission is still not proven accepted. Reconcile with authoritative session events instead of manufacturing an approval because the HTTP operation returned.

Keep the submitted and accepted records linked without overwriting their meanings. For example, a person may choose Approve while the responder policy refuses their attempt. The submission record explains what they tried; a later authoritative resolution explains what eventually happened to the request. An audit that retains only the latest button label loses that distinction.

The returned Denied, Ignored, or Invalid outcome is not an approved action, even when the surrounding conversation continues; those meanings are defined by the tagged resolution outcomes. The example prints the raw outcome rather than translating every observed resolution into success. A consuming host should preserve that distinction in its UI and audit storage.

Rejection, Candidate Events, and Unknown Future Input

A failed responder attempt is not necessarily the end of the shared request. The pinned stream contract distinguishes approval.candidate outcomes such as rejected, failed, timed-out, and stale from approval.settled outcomes approved and cancelled, and neither event ends the turn, according to the tagged streaming documentation. Do not mark the entire workflow rejected merely because one unauthorized responder tried.

For human-in-the-loop AI agents, present those observations honestly. "Your attempt was rejected" describes a responder result. "This action was cancelled" describes settlement. "The resumed turn failed" describes execution status. A single green or red badge collapses information the person may need to understand what remains outstanding.

The request decoder reports an absent discriminator as Unknown with null RawKind, an unrecognized string as Unknown with its wire value, and a present non-string discriminator as a protocol error, as the tagged decoder establishes. Preserve those differences when reporting compatibility problems. Missing information is not permission to infer approval from a label.

Transport errors also remain errors. The example does not catch them and return a successful empty outcome. Mocking HttpClient and testing HTTP boundaries is useful for independently checking missing requests, unauthorized callers, rejected candidates, and failed resumed turns without contacting a live agent.

Approval Is Not Authorization or Exactly-Once Execution

Human review answers whether the person wants this action to proceed. Authorization answers whether that person may make that decision. Idempotency answers whether repeating an operation changes its effect. These questions overlap operationally, but one answer cannot substitute for another.

Our mock tool returns data and performs no external write. A later real integration would need independently designed effect safeguards, including an operation identity and a downstream deduplication contract where appropriate. Never infer exactly-once external execution from an approval button or a durable pause.

The benefit of an explicit boundary is understandable intent and traceable acceptance. The cost is more state to track: submissions, responder attempts, authoritative resolutions, and execution outcomes. That cost is preferable to hiding uncertainty behind a success notification.

Separate checks are valuable here. A boundary-level check can prove the wrong owner is refused without networking. A transport-level check can prove only the selected request identifier is submitted. A pinned server integration check can establish which resolutions actually arrive after rejection and resume. The distinction between those scopes is explored in unit and integration testing for feature slices. None of those checks has been newly executed for these article examples.

Human-in-the-loop AI agents become easier to reason about when each layer reports only what it knows. The browser records what the person selected. The trusted application checks ownership and authority. The server resolves the exact pending input. The execution result says whether work completed or failed. None of those observations alone proves all the others.

Frequently Asked Questions

What are human-in-the-loop AI agents?

They are agent workflows that involve a person at a defined decision boundary instead of assuming the model may decide everything. In this example, the person reviews a proposed synthetic checklist item. Useful human involvement requires both a clear choice and a trustworthy record of which pending request that choice addresses.

Can I approve the first pending request?

Not safely as a general rule. Select the request the person actually reviewed, then verify its framework kind and available decision. Position in a collection is not evidence of meaning, ownership, or current validity. A stale screen should fail visibly rather than redirect the choice to a different pending action.

Is answering a question the same as approving a tool?

No. A clarification supplies information; approval expresses permission for a requested action. A session-limit decision controls whether work continues. Keep these interactions separate even if their visual controls resemble each other. Shared presentation does not justify shared decision semantics.

Does a rejected responder cancel the approval?

Not necessarily: the pinned response policy can reject the responder while leaving the shared request pending, as the approval policy documentation specifies. Explain that distinction to the person instead of treating every unsuccessful attempt as cancellation of the underlying action.

Does approval guarantee exactly-once execution?

No. A human decision is not a deduplication mechanism for an external operation. Treat effect identity and downstream replay handling as separate design responsibilities. The synthetic action here avoids external effects specifically so that the example does not imply a stronger execution guarantee than it demonstrates.

How do I know the decision was accepted?

Use the server's authoritative resolution for the exact request, rather than the submitted option or a local success banner. If that observation is missing, acceptance remains unconfirmed. If execution later fails, retain both facts: a decision may have been accepted while the requested work still did not complete successfully.

Vercel Eve Tutorial for C# Developers: Build an ASP.NET Core Agent Client

Follow this Vercel Eve tutorial to connect ASP.NET Core to a pinned agent, manage transport ownership, and read equipment summaries with explicit failures.

Vercel Eve Explained: Durable AI Agents and C# Integration

Understand Vercel Eve's durable agent runtime, filesystem-first design, replay boundaries, and how C# applications connect through a version-pinned HTTP client.

Tool Approval and Human-in-the-Loop in Microsoft Agent Framework

Learn how to implement microsoft agent framework tool approval human in the loop patterns in C# to keep AI agents safe, auditable, and under human control.

An error has occurred. This application may no longer respond until reloaded. Reload