Quickstart: C#

This quickstart uses HttpClient and System.Text.Json, both part of .NET 8. No packages are needed.

Create a console app

dotnet new console -n SynticHello && cd SynticHello

Send a request

Replace Program.cs with:

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
 
var http = new HttpClient { BaseAddress = new Uri("https://app.syntic.ai/v1/"), Timeout = TimeSpan.FromMinutes(5) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SYNTIC_API_KEY"));
 
var body = new JsonObject
{
    ["model"] = "Amara S1.2",
    ["stream"] = false,
    ["max_tokens"] = 300,
    ["messages"] = new JsonArray
    {
        new JsonObject { ["role"] = "system", ["content"] = "Answer in two sentences." },
        new JsonObject { ["role"] = "user", ["content"] = "Why do leaves change colour in autumn?" },
    },
};
 
using var response = await http.PostAsJsonAsync("chat/completions", body);
var data = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
if (!response.IsSuccessStatusCode)
{
    var requestId = response.Headers.TryGetValues("x-syntic-request-id", out var ids) ? ids.First() : "?";
    throw new Exception($"{(int)response.StatusCode} {data["error"]} (request {requestId})");
}
Console.WriteLine(data["choices"]![0]!["message"]!["content"]);
Console.WriteLine($"tokens: {data["usage"]!["total_tokens"]}");

Note the trailing slash on BaseAddress and no leading slash on "chat/completions"; together they resolve to https://app.syntic.ai/v1/chat/completions.

Run it

dotnet run

Stream the reply

Ask HttpClient to return as soon as headers arrive, then read the body line by line:

var streamBody = new JsonObject
{
    ["model"] = "Amara Quick",
    ["messages"] = new JsonArray { new JsonObject { ["role"] = "user", ["content"] = "List four uses for honey." } },
};
 
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
    Content = JsonContent.Create(streamBody),
};
using var streamed = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
streamed.EnsureSuccessStatusCode();
 
using var reader = new StreamReader(await streamed.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
{
    if (!line.StartsWith("data: ")) continue;
    var payload = line["data: ".Length..];
    if (payload == "[DONE]") break;
    var evt = JsonNode.Parse(payload)!;
    if (evt["error"] is not null) throw new Exception($"stream failed: {evt["error"]?["code"]}");
    var piece = evt["choices"]?[0]?["delta"]?["content"]?.GetValue<string>();
    if (piece is not null) Console.Write(piece);
}
Console.WriteLine();

Pass a CancellationToken to SendAsync and ReadLineAsync to stop a stream; cancelling closes the connection.

Call a function

var tools = JsonNode.Parse("""
[{"type": "function", "function": {
  "name": "parcel_status",
  "description": "Delivery status for a parcel tracking number.",
  "parameters": {"type": "object", "properties": {"tracking": {"type": "string"}}, "required": ["tracking"]}}}]
""")!;
 
var messages = new JsonArray { new JsonObject { ["role"] = "user", ["content"] = "Where is parcel ZX-4410?" } };
 
async Task<JsonNode> Ask()
{
    var b = new JsonObject { ["model"] = "Amara S1.2", ["stream"] = false, ["tools"] = tools.DeepClone(), ["messages"] = messages.DeepClone() };
    using var r = await http.PostAsJsonAsync("chat/completions", b);
    r.EnsureSuccessStatusCode();
    return JsonNode.Parse(await r.Content.ReadAsStringAsync())!;
}
 
var reply = await Ask();
var msg = reply["choices"]![0]!["message"]!;
if (msg["tool_calls"] is JsonArray calls)
{
    messages.Add(msg.DeepClone());                         // the assistant turn, unchanged
    foreach (var call in calls)
    {
        var args = JsonNode.Parse(call!["function"]!["arguments"]!.GetValue<string>());
        var result = """{"status":"out for delivery","eta":"14:00-16:00"}""";   // replace with a real lookup
        messages.Add(new JsonObject { ["role"] = "tool", ["tool_call_id"] = call["id"]!.GetValue<string>(), ["content"] = result });
    }
    reply = await Ask();
}
Console.WriteLine(reply["choices"]![0]!["message"]!["content"]);

Using an OpenAI-compatible .NET library

Libraries that let you set a custom endpoint can also be used. Point them at https://app.syntic.ai/v1. Check one thing first: whether the library sends "stream": false on non-streaming calls. If it leaves the field out, the API streams the reply and a non-streaming method fails to parse it; use the library’s streaming methods, or plain HttpClient as above.

Handle errors

StatusWhat to do
400, 413Fix the request; error names the problem.
401, 403Check the key and any origin or scope restrictions.
402quota_exhausted: see Handle 402.
429Wait for response.Headers.RetryAfter, then retry.
500, 502, 503Retry with exponential back-off.

For retries in .NET, a DelegatingHandler or the Microsoft.Extensions.Http.Resilience package can apply the rules from Retries with back-off to every request.

Create one HttpClient (or use IHttpClientFactory) and reuse it. Creating a new client per request exhausts sockets under load.

Next steps

Was this page helpful?