Navigate

.NET & C#

A complete Automate HTTP Server client for .NET — typed records, endpoint discovery, readiness gating, cancellation, and WPF/WinUI integration patterns for Windows desktop apps.

.NET 8+ (works on .NET 6 with minor syntax changes). System.Net.Http and System.Text.Json only — no packages.

This pairs naturally with the Cephable .NET / WPF SDK: the SDK gives your app adaptive input, and this client gives it the on-device assistant.


Models

// CephableModels.cs
using System.Text.Json.Serialization;

namespace Cephable.Automate;

public sealed record CephableBackend(
    [property: JsonPropertyName("flavorId")] string? FlavorId,
    [property: JsonPropertyName("accelerator")] string Accelerator,
    [property: JsonPropertyName("cpuFallback")] bool CpuFallback);

public sealed record CephableHealth(
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("service")] string Service,
    [property: JsonPropertyName("appVersion")] string AppVersion,
    [property: JsonPropertyName("platform")] string Platform,
    [property: JsonPropertyName("architecture")] string Architecture,
    [property: JsonPropertyName("workflowStatus")] string WorkflowStatus,
    [property: JsonPropertyName("activeRequestId")] string? ActiveRequestId,
    [property: JsonPropertyName("modelName")] string? ModelName,
    [property: JsonPropertyName("workerRunning")] bool WorkerRunning,
    [property: JsonPropertyName("busy")] bool Busy,
    [property: JsonPropertyName("workspace")] string Workspace,
    [property: JsonPropertyName("backend")] CephableBackend? Backend,
    [property: JsonPropertyName("contextSize")] int? ContextSize)
{
    public bool IsReady => !Busy && WorkflowStatus is "idle" or "terminated";
}

public sealed record CephableStep(
    [property: JsonPropertyName("index")] int Index,
    [property: JsonPropertyName("title")] string Title,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("toolName")] string? ToolName,
    [property: JsonPropertyName("resultSummary")] string? ResultSummary,
    [property: JsonPropertyName("producedFilePath")] string? ProducedFilePath,
    [property: JsonPropertyName("producedContent")] string? ProducedContent);

public sealed record CephableUsage(
    [property: JsonPropertyName("inputTokens")] int InputTokens,
    [property: JsonPropertyName("outputTokens")] int OutputTokens,
    [property: JsonPropertyName("generationMs")] double GenerationMs,
    [property: JsonPropertyName("ttftMs")] double TtftMs,
    [property: JsonPropertyName("tps")] double Tps);

public sealed record CephableRunResult(
    [property: JsonPropertyName("schemaVersion")] int SchemaVersion,
    [property: JsonPropertyName("requestId")] string RequestId,
    [property: JsonPropertyName("taskId")] string? TaskId,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("answer")] string Answer,
    [property: JsonPropertyName("finalAnswer")] string? FinalAnswer,
    [property: JsonPropertyName("errorCode")] string? ErrorCode,
    [property: JsonPropertyName("durationMs")] long DurationMs,
    [property: JsonPropertyName("model")] string Model,
    [property: JsonPropertyName("appVersion")] string AppVersion,
    [property: JsonPropertyName("backend")] CephableBackend? Backend,
    [property: JsonPropertyName("steps")] IReadOnlyList<CephableStep>? Steps,
    [property: JsonPropertyName("usage")] CephableUsage? Usage)
{
    public bool Completed => Status == "completed";

    /// <summary>The contract value when one was requested, otherwise the whole answer.</summary>
    public string Value => string.IsNullOrWhiteSpace(FinalAnswer) ? Answer : FinalAnswer!;

    public IEnumerable<CephableStep> FailedSteps =>
        (Steps ?? Array.Empty<CephableStep>()).Where(step => step.Status == "failed");

    /// <summary>Resolved absolute paths of files and folders this run created or edited.</summary>
    public IEnumerable<string> ProducedFiles =>
        (Steps ?? Array.Empty<CephableStep>())
            .Where(step => !string.IsNullOrEmpty(step.ProducedFilePath))
            .Select(step => step.ProducedFilePath!);
}

public sealed record CephableInclude(
    [property: JsonPropertyName("steps")] bool Steps = true,
    [property: JsonPropertyName("trace")] bool Trace = false,
    [property: JsonPropertyName("events")] bool Events = false);

public sealed record CephableRunRequest
{
    [JsonPropertyName("prompt")] public required string Prompt { get; init; }
    [JsonPropertyName("taskId")] public string? TaskId { get; init; }
    [JsonPropertyName("timeoutMs")] public int TimeoutMs { get; init; } = 900_000;
    [JsonPropertyName("thinkingLevel")] public string? ThinkingLevel { get; init; }
    [JsonPropertyName("additionalWorkflowPrompt")] public string? AdditionalWorkflowPrompt { get; init; }
    [JsonPropertyName("answerContract")] public string? AnswerContract { get; init; }
    [JsonPropertyName("include")] public CephableInclude Include { get; init; } = new();
    [JsonPropertyName("restrictToWorkspace")] public bool RestrictToWorkspace { get; init; }
    [JsonPropertyName("continuation")] public bool Continuation { get; init; }
    [JsonPropertyName("selectedSkillIds")] public IReadOnlyList<string>? SelectedSkillIds { get; init; }
    [JsonPropertyName("selectedMcpServerIds")] public IReadOnlyList<string>? SelectedMcpServerIds { get; init; }
    [JsonPropertyName("hitlAnswers")] public IReadOnlyDictionary<string, object>? HitlAnswers { get; init; }
    [JsonPropertyName("allowDestructiveTools")] public bool AllowDestructiveTools { get; init; }
}

public sealed record CephableModelOption(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("family")] string? Family,
    [property: JsonPropertyName("sizeCode")] string? SizeCode,
    [property: JsonPropertyName("fileSizeMb")] int? FileSizeMb,
    [property: JsonPropertyName("supportsTools")] bool SupportsTools,
    [property: JsonPropertyName("availableForDevice")] bool AvailableForDevice,
    [property: JsonPropertyName("downloaded")] bool Downloaded,
    [property: JsonPropertyName("selected")] bool Selected)
{
    public bool Usable => SupportsTools && AvailableForDevice && Downloaded;
}

public sealed record CephableCancelResult(
    [property: JsonPropertyName("stopped")] bool Stopped,
    [property: JsonPropertyName("mode")] string Mode,
    [property: JsonPropertyName("requestId")] string? RequestId,
    [property: JsonPropertyName("workflowStatus")] string WorkflowStatus);

/// <summary>The request never produced a run: 400, 401, 404, 409.</summary>
public sealed class CephableRequestException : Exception
{
    public CephableRequestException(string message, int status, string? type = null) : base(message)
    {
        Status = status;
        ErrorType = type;
    }

    public int Status { get; }
    public string? ErrorType { get; }
    public bool IsBusy => Status == 409;
    public bool IsUnauthorized => Status == 401;
}

/// <summary>A run happened and did not complete. Carries the full record.</summary>
public sealed class CephableRunException : Exception
{
    public CephableRunException(CephableRunResult result)
        : base($"Automate run {result.Status}{(result.ErrorCode is null ? "" : $" ({result.ErrorCode})")}")
        => Result = result;

    public CephableRunResult Result { get; }
}

The client

// CephableAutomateClient.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace Cephable.Automate;

public sealed class CephableAutomateClient : IDisposable
{
    private const int DefaultPort = 4317;
    private const int PortAttempts = 12;

    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
    };

    private readonly HttpClient _http;
    private readonly string _token;
    private string? _endpoint;

    /// <param name="endpoint">Omit to discover the port Cephable actually bound.</param>
    public CephableAutomateClient(string? token = null, string? endpoint = null)
    {
        _token = token
            ?? Environment.GetEnvironmentVariable("CEPHABLE_AUTOMATE_KEY")
            ?? throw new InvalidOperationException("Set CEPHABLE_AUTOMATE_KEY or pass a token");
        _endpoint = endpoint;

        // Agent runs are long. Enforce per-call deadlines with a CancellationToken instead of a
        // client-wide timeout, so an abandoned request never outlives the run silently.
        _http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
        _http.DefaultRequestHeaders.Authorization = new("Bearer", _token);
    }

    // ── transport ────────────────────────────────────────────────────────────────

    /// <summary>
    /// Cephable moves to the next free port when its preferred one is taken, so a saved address can
    /// silently be wrong. Sweep the same window the app uses and confirm the service name.
    /// </summary>
    public async Task<string> ResolveEndpointAsync(CancellationToken cancellationToken = default)
    {
        if (_endpoint is not null) return _endpoint;

        for (var offset = 0; offset < PortAttempts; offset++)
        {
            var candidate = $"http://127.0.0.1:{DefaultPort + offset}";
            try
            {
                using var probe = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                using var linked = CancellationTokenSource.CreateLinkedTokenSource(probe.Token, cancellationToken);
                using var response = await _http.GetAsync($"{candidate}/health", linked.Token);

                // A 401 still proves a server is listening here.
                if (response.StatusCode == HttpStatusCode.Unauthorized)
                {
                    throw new CephableRequestException(
                        $"Cephable is listening on {candidate} but rejected the access key", 401, "authentication_error");
                }
                if (!response.IsSuccessStatusCode) continue;

                var health = await response.Content.ReadFromJsonAsync<CephableHealth>(JsonOptions, linked.Token);
                if (health?.Service == "cephable-agent")
                {
                    _endpoint = candidate;
                    return candidate;
                }
            }
            catch (CephableRequestException) { throw; }
            catch (HttpRequestException) { /* nothing listening here */ }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* probe timeout */ }
        }

        throw new InvalidOperationException(
            $"No Cephable Automate server answered on 127.0.0.1:{DefaultPort}-{DefaultPort + PortAttempts - 1}. " +
            "Open Cephable and enable Extensions > Cephable features > Build & Extend > Automate HTTP Server.");
    }

    private async Task<T> SendAsync<T>(
        HttpMethod method,
        string route,
        object? payload,
        CancellationToken cancellationToken)
    {
        var endpoint = await ResolveEndpointAsync(cancellationToken);
        using var request = new HttpRequestMessage(method, $"{endpoint}{route}");
        if (payload is not null) request.Content = JsonContent.Create(payload, options: JsonOptions);

        using var response = await _http.SendAsync(request, cancellationToken);
        var json = await response.Content.ReadAsStringAsync(cancellationToken);

        using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json);
        var root = document.RootElement;

        // A failed run is HTTP 500 with a COMPLETE record. Only a body without schemaVersion
        // means no run happened.
        var hasRecord = root.TryGetProperty("schemaVersion", out var version)
            && version.ValueKind == JsonValueKind.Number
            && version.GetInt32() == 1;

        if (!response.IsSuccessStatusCode && !hasRecord)
        {
            var message = $"{route} returned HTTP {(int)response.StatusCode}";
            string? type = null;
            if (root.TryGetProperty("error", out var error))
            {
                if (error.TryGetProperty("message", out var m)) message = m.GetString() ?? message;
                if (error.TryGetProperty("type", out var t)) type = t.GetString();
            }
            throw new CephableRequestException(message, (int)response.StatusCode, type);
        }

        return JsonSerializer.Deserialize<T>(json, JsonOptions)
            ?? throw new CephableRequestException($"{route} returned an empty body", (int)response.StatusCode);
    }

    // ── endpoints ────────────────────────────────────────────────────────────────

    public Task<CephableHealth> GetHealthAsync(CancellationToken cancellationToken = default) =>
        SendAsync<CephableHealth>(HttpMethod.Get, "/health", null, cancellationToken);

    public async Task<IReadOnlyList<CephableModelOption>> GetModelsAsync(CancellationToken cancellationToken = default)
    {
        var body = await SendAsync<ModelListResponse>(HttpMethod.Get, "/v1/automate/models", null, cancellationToken);
        return body.Data;
    }

    /// <summary>Pin a model for this app session. Pass an empty selector to restore the app's choice.</summary>
    public Task<SelectModelResponse> SelectModelAsync(
        string? modelName = null,
        string? family = null,
        string? sizeCode = null,
        CancellationToken cancellationToken = default) =>
        SendAsync<SelectModelResponse>(
            HttpMethod.Post,
            "/v1/automate/models/select",
            new { modelName, family, sizeCode },
            cancellationToken);

    /// <summary>Always safe to call, even when nothing is running.</summary>
    public Task<CephableCancelResult> CancelAsync(bool force = false, CancellationToken cancellationToken = default) =>
        SendAsync<CephableCancelResult>(HttpMethod.Post, "/v1/automate/cancel", new { force }, cancellationToken);

    /// <summary>Block until the assistant is free. The user's own panel runs hold the slot too.</summary>
    public async Task<CephableHealth> WaitUntilReadyAsync(
        TimeSpan? timeout = null,
        CancellationToken cancellationToken = default)
    {
        var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromMinutes(5));
        CephableHealth? last = null;
        while (DateTimeOffset.UtcNow < deadline)
        {
            last = await GetHealthAsync(cancellationToken);
            if (last.IsReady) return last;
            await Task.Delay(1000, cancellationToken);
        }
        throw new TimeoutException($"Cephable stayed busy (last status: {last?.WorkflowStatus ?? "unknown"})");
    }

    /// <summary>Run one task. Returns the record for any outcome; throws only if no run happened.</summary>
    public async Task<CephableRunResult> RunAsync(
        CephableRunRequest request,
        CancellationToken cancellationToken = default)
    {
        // Keep the client deadline above the server's so its own timeout wins.
        using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(request.TimeoutMs + 30_000));
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token, cancellationToken);
        return await SendAsync<CephableRunResult>(HttpMethod.Post, "/v1/runs", request, linked.Token);
    }

    public async Task<CephableRunResult> RunOrThrowAsync(
        CephableRunRequest request,
        CancellationToken cancellationToken = default)
    {
        var result = await RunAsync(request, cancellationToken);
        if (!result.Completed) throw new CephableRunException(result);
        return result;
    }

    public void Dispose() => _http.Dispose();

    private sealed record ModelListResponse(IReadOnlyList<CephableModelOption> Data);

    public sealed record SelectModelResponse(
        CephableModelOption? Selected,
        IReadOnlyList<CephableModelOption> Models);
}

Using it

// Program.cs
using Cephable.Automate;

using var cephable = new CephableAutomateClient();

try
{
    var health = await cephable.GetHealthAsync();
    Console.WriteLine($"Cephable {health.AppVersion} · {health.ModelName} · {health.Backend?.Accelerator}");

    await cephable.WaitUntilReadyAsync();

    var result = await cephable.RunOrThrowAsync(new CephableRunRequest
    {
        Prompt = "Count the PDF files in my Documents folder.",
        TaskId = "demo-1",
        AnswerContract = "End with FINAL ANSWER: <integer>",
        TimeoutMs = 300_000,
    });

    Console.WriteLine($"answer: {result.Value}");
    foreach (var step in result.Steps ?? [])
        Console.WriteLine($"  {step.Index}. {step.Title} [{step.ToolName}] -> {step.Status}");
    Console.WriteLine($"{result.DurationMs}ms · {result.Usage?.OutputTokens ?? 0} output tokens");
}
catch (CephableRunException error)
{
    Console.Error.WriteLine($"The run did not finish: {error.Result.Status} {error.Result.ErrorCode}");
    foreach (var step in error.Result.FailedSteps)
        Console.Error.WriteLine($"  {step.ToolName}: {step.ResultSummary}");
}
catch (CephableRequestException error) when (error.IsBusy)
{
    Console.Error.WriteLine("Cephable is busy with another run.");
}
catch (CephableRequestException error) when (error.IsUnauthorized)
{
    Console.Error.WriteLine("The access key was rejected. Copy it again from Extensions.");
}

Structured output with a contract

using System.Text.Json;
using System.Text.RegularExpressions;

public sealed record FolderReport(int FileCount, string LargestFile);

static async Task<FolderReport> GetFolderReportAsync(CephableAutomateClient cephable, string folder)
{
    var result = await cephable.RunOrThrowAsync(new CephableRunRequest
    {
        Prompt = $"Inspect {folder}. Count the files and identify the largest one.",
        AnswerContract =
            """End your reply with one line: FINAL ANSWER: {"fileCount": <integer>, "largestFile": "<file name>"}""",
    });

    // A small local model will occasionally wrap the value in a code fence.
    var raw = Regex.Replace(result.Value.Trim(), @"^```(?:json)?\s*|\s*```$", "");
    return JsonSerializer.Deserialize<FolderReport>(raw, new JsonSerializerOptions(JsonSerializerDefaults.Web))
        ?? throw new InvalidOperationException($"The assistant did not honor the contract. Answer was: {raw}");
}

WPF / WinUI integration

Never call RunAsync on the UI thread without cancellation — a run can take minutes. Bind a view model to it, give the user a Stop button wired to CancelAsync, and gate on readiness.

public sealed partial class AssistantViewModel : ObservableObject, IDisposable
{
    private readonly CephableAutomateClient _cephable = new();
    private CancellationTokenSource? _active;

    [ObservableProperty] private string _prompt = "";
    [ObservableProperty] private string _answer = "";
    [ObservableProperty] private string _statusText = "Idle";
    [ObservableProperty] private bool _isRunning;

    [RelayCommand]
    private async Task RunAsync()
    {
        if (IsRunning) return;
        _active = new CancellationTokenSource();
        IsRunning = true;
        StatusText = "Waiting for Cephable…";

        try
        {
            await _cephable.WaitUntilReadyAsync(TimeSpan.FromMinutes(1), _active.Token);
            StatusText = "Running…";

            var result = await _cephable.RunAsync(new CephableRunRequest
            {
                Prompt = Prompt,
                RestrictToWorkspace = true,
                TimeoutMs = 300_000,
            }, _active.Token);

            Answer = result.Value;
            StatusText = result.Completed ? $"Done in {result.DurationMs / 1000}s" : $"{result.Status}: {result.ErrorCode}";
        }
        catch (CephableRequestException error) when (error.IsBusy)
        {
            StatusText = "Cephable is busy — try again in a moment.";
        }
        catch (OperationCanceledException)
        {
            StatusText = "Stopped.";
        }
        finally
        {
            IsRunning = false;
            _active?.Dispose();
            _active = null;
        }
    }

    [RelayCommand]
    private async Task StopAsync()
    {
        // Cancel the run inside Cephable first, then abandon our request. Cancelling only the
        // HttpClient call would leave the run holding the single inference slot.
        await _cephable.CancelAsync(force: false);
        _active?.Cancel();
    }

    public void Dispose() => _cephable.Dispose();
}

Two rules for desktop apps:

  1. Cancel the run, not just the request. _active.Cancel() only abandons your HTTP call; the run keeps going inside Cephable and blocks the next one. Always call CancelAsync too.
  2. Serialize your own callers. Only one run happens at a time. If several features in your app can trigger a run, funnel them through one queue — and still handle 409, because the user can start a run from the Cephable panel at any moment.

Windows service / scheduled task

For unattended work, discover the endpoint, confine to the workspace, keep destructive tools off, and always cancel on failure.

using var cephable = new CephableAutomateClient();

try
{
    await cephable.WaitUntilReadyAsync(TimeSpan.FromMinutes(10), stoppingToken);

    var result = await cephable.RunAsync(new CephableRunRequest
    {
        Prompt = "Read every .csv in this folder and write results.md summarizing the totals.",
        TaskId = $"nightly-{DateTime.UtcNow:yyyy-MM-dd}",
        RestrictToWorkspace = true,      // unattended: keep it in the sandbox
        AllowDestructiveTools = false,
        TimeoutMs = 900_000,
        Include = new CephableInclude(Steps: true, Trace: false, Events: false),
    }, stoppingToken);

    _logger.LogInformation("Run {Id} {Status} in {Ms}ms; produced {Files}",
        result.RequestId, result.Status, result.DurationMs, string.Join(", ", result.ProducedFiles));
}
catch (Exception error)
{
    _logger.LogError(error, "Automate run failed");
    await cephable.CancelAsync(force: true, CancellationToken.None);
    throw;
}