.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:
- 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 callCancelAsynctoo. - 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;
}