Snippet

Host a loopback helper API

A complete ASP.NET Core loopback host and router for a native helper's pairing and capture contract.

csharpOct 6, 2026MIT

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

What it does#

Hosts a small helper API on 127.0.0.1 only. The router exposes health, pairing, capture start, capture stop, status, disconnect, and revoke operations. The backend implementation is injected, so this file stays a transport adapter.

You need#

  • .NET SDK 10
  • A project with ImplicitUsings enabled (the dotnet new default)
  • ASP.NET Core shared framework from Microsoft.NET.Sdk.Web
  • A capture backend that implements IHelperBackend

Where it goes#

Save it in the native helper project as LoopbackHelperApi.cs. Construct it with your backend and call StartAsync.

LoopbackHelperApi.cs
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

public interface IHelperBackend
{
    void ConfirmPairing(PairingConfirmRequest request);
    void StartCapture(SessionActionRequest request);
    CaptureStopResponse StopCapture(SessionActionRequest request);
    CaptureStatusResponse CaptureStatus(Guid meetingSessionId);
    void Disconnect(SessionActionRequest request);
    void Revoke(SessionActionRequest request);
}

public sealed class LoopbackHelperApi(IHelperBackend backend, string host = "127.0.0.1", int port = 6173)
    : IAsyncDisposable
{
    private WebApplication? _app;

    public async Task StartAsync(CancellationToken cancellationToken = default)
    {
        if (host is not ("127.0.0.1" or "localhost"))
        {
            throw new InvalidOperationException($"Refusing non-loopback host '{host}'.");
        }

        var builder = WebApplication.CreateSlimBuilder();
        builder.Logging.SetMinimumLevel(LogLevel.Warning);
        builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, port));

        var app = builder.Build();
        app.Run((RequestDelegate)HandleAsync);
        await app.StartAsync(cancellationToken);
        _app = app;
    }

    public async Task StopAsync(CancellationToken cancellationToken = default)
    {
        if (_app is null)
        {
            return;
        }

        await _app.StopAsync(cancellationToken);
        await _app.DisposeAsync();
        _app = null;
    }

    private async Task HandleAsync(HttpContext context)
    {
        var response = await RouteAsync(context);
        context.Response.StatusCode = response.StatusCode;
        if (response.Body is { Length: > 0 } body)
        {
            context.Response.ContentType = "application/json";
            await context.Response.Body.WriteAsync(body, context.RequestAborted);
        }
    }

    private async Task<HelperResponse> RouteAsync(HttpContext context)
    {
        try
        {
            return (context.Request.Method.ToUpperInvariant(), context.Request.Path.Value) switch
            {
                ("GET", "/health") => Json(new { status = "ok" }, 200),
                ("POST", "/pairing/confirm") => await ConfirmAsync(context),
                ("POST", "/capture/start") => await StartCaptureAsync(context),
                ("POST", "/capture/stop") => await StopCaptureAsync(context),
                ("GET", "/capture/status") => CaptureStatus(context),
                ("POST", "/pairing/disconnect") => await DisconnectAsync(context),
                ("POST", "/pairing/revoke") => await RevokeAsync(context),
                _ => new HelperResponse(404)
            };
        }
        catch (JsonException)
        {
            return Error("badRequest", 400);
        }
    }

    private async Task<HelperResponse> ConfirmAsync(HttpContext context)
    {
        backend.ConfirmPairing(await ReadJsonAsync<PairingConfirmRequest>(context));
        return new HelperResponse(200);
    }

    private async Task<HelperResponse> StartCaptureAsync(HttpContext context)
    {
        backend.StartCapture(await ReadJsonAsync<SessionActionRequest>(context));
        return new HelperResponse(202);
    }

    private async Task<HelperResponse> StopCaptureAsync(HttpContext context)
    {
        return Json(backend.StopCapture(await ReadJsonAsync<SessionActionRequest>(context)), 202);
    }

    private HelperResponse CaptureStatus(HttpContext context)
    {
        if (!Guid.TryParse(context.Request.Query["meetingSessionId"], out var meetingSessionId))
        {
            return Error("meetingSessionId is required", 400);
        }

        return Json(backend.CaptureStatus(meetingSessionId), 200);
    }

    private async Task<HelperResponse> DisconnectAsync(HttpContext context)
    {
        backend.Disconnect(await ReadJsonAsync<SessionActionRequest>(context));
        return new HelperResponse(200);
    }

    private async Task<HelperResponse> RevokeAsync(HttpContext context)
    {
        backend.Revoke(await ReadJsonAsync<SessionActionRequest>(context));
        return new HelperResponse(200);
    }

    private static async Task<T> ReadJsonAsync<T>(HttpContext context)
    {
        var value = await JsonSerializer.DeserializeAsync<T>(
            context.Request.Body,
            JsonOptions,
            context.RequestAborted);
        return value ?? throw new JsonException("Request body decoded to null.");
    }

    private static HelperResponse Json<T>(T value, int statusCode) =>
        new(statusCode, JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions));

    private static HelperResponse Error(string message, int statusCode) =>
        new(statusCode, Encoding.UTF8.GetBytes($"{{\"error\":\"{message}\"}}"));

    public async ValueTask DisposeAsync() => await StopAsync();

    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
}

public sealed record PairingConfirmRequest(Guid PairingId, string Code, string Origin, string Nonce);
public sealed record SessionActionRequest(Guid MeetingSessionId, string SessionToken, string Nonce);
public sealed record CaptureStatusResponse(string State, string? Detail = null);
public sealed record CaptureStopResponse(string State, string? Detail, CaptureArtifact? Artifact = null);
public sealed record CaptureArtifact(Guid AudioArtifactId, string FileName, string ContentType, long SizeBytes);
public sealed record HelperResponse(int StatusCode, byte[]? Body = null);

Check that it works#

Run the helper and call GET http://127.0.0.1:6173/health. The response is {"status":"ok"}. A non-loopback host configuration throws before the server starts.