Everything the ollama command does goes through one HTTP API on 127.0.0.1:11434, with no key: the introduction page says "Cloud requests need an API key. Local requests do not." This post calls every endpoint you will use from a program with curl, shows what came back, then does the same from .NET three ways: a plain HttpClient, OllamaSharp through the Microsoft.Extensions.AI abstractions, and the official OpenAI client pointed at Ollama's /v1. Every response below was captured on 2026-10-30 against Ollama 0.40.11 for Windows with qwen2.5:3b and nomic-embed-text, from PowerShell 5.1 and .NET SDK 10.0.403.

Steps in this post, and what you should see
  1. GET /api/version answers {"version":"0.40.11"}: the server is up and curl can reach it.
  2. POST /api/generate with a prompt streams one JSON object per line; the last one has "done":true and the timings in nanoseconds.
  3. Add "stream": false for a single object, and compute tokens per second from eval_count and eval_duration.
  4. POST /api/chat takes the whole conversation as messages; the answer is in message.content. format, options and keep_alive go in the same body.
  5. POST /api/embed with an embedding model returns one vector of 768 floats per input.
  6. The same server answers OpenAI-shaped requests on /v1; any key is accepted and ignored.
  7. In .NET, pick raw HttpClient, OllamaSharp or the OpenAI client; the three programs below ran unchanged.

curl from PowerShell: bodies go in files

Windows ships curl.exe, and Windows PowerShell 5.1 strips the double quotes out of inline JSON passed to a native program, so every request body below sits in a small file and is sent with --data-binary "@req/name.json". The first call needs no body:

PS> curl.exe -s -i http://127.0.0.1:11434/api/version
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 30 Oct 2026 03:00:47 GMT
Content-Length: 21

{"version":"0.40.11"}

/api/generate: streaming is the default

The smallest useful body is a model and a prompt. Without "stream": false the reply is newline-delimited JSON, which the streaming page describes as "the newline-delimited JSON format", one object per token, sent as a chunked response:

PS> Get-Content .\req\generate-stream.json
{"model": "qwen2.5:3b", "prompt": "Name three primary colors. Answer in one short line."}
PS> curl.exe -s -i -N http://127.0.0.1:11434/api/generate --data-binary "@req/generate-stream.json"
HTTP/1.1 200 OK
Content-Type: application/x-ndjson
Date: Fri, 30 Oct 2026 03:05:17 GMT
Transfer-Encoding: chunked

{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.3353625Z","response":"Red","done":false}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.4087078Z","response":",","done":false}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.4154196Z","response":" Blue","done":false}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.4213503Z","response":",","done":false}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.4273096Z","response":" Green","done":false}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:05:17.4333491Z","response":"","done":true,"done_reason":"stop","context":[...],"total_duration":331629300,...

-N stops curl from buffering, so the tokens appear as they are produced. The last object carries an empty response, done_reason, a context array of token ids and the timings. With "stream": false the whole answer comes back as one object with a Content-Length, and the timing fields are easier to read:

PS> Get-Content .\req\generate.json
{"model": "qwen2.5:3b", "prompt": "Name three primary colors. Answer in one short line.", "stream": false}
PS> curl.exe -s http://127.0.0.1:11434/api/generate --data-binary "@req/generate.json" -o res\generate.json; Get-Content res\generate.json | ConvertFrom-Json | Select-Object done_reason, total_duration, load_duration, prompt_eval_count, prompt_eval_duration, eval_count, eval_duration | Format-List

done_reason          : stop
total_duration       : 58292500
load_duration        : 13770700
prompt_eval_count    : 40
prompt_eval_duration : 6822000
eval_count           : 6
eval_duration        : 33433999

The usage page states the unit: "All timing values are measured in nanoseconds." Tokens per second is eval_count divided by eval_duration in seconds, and a six-token answer is too short a sample; the same model answering 200 tokens (num_predict 200, so done_reason is length) gave the steadier figure:

done_reason=length eval_count=200 eval_duration=1243616000 -> 160.8 tokens per second

/api/chat: the conversation is the request

The server keeps nothing between calls. Each request carries the system message and every earlier turn, and the model answers the last one in context:

{
  "model": "qwen2.5:3b",
  "stream": false,
  "messages": [
    {"role": "system", "content": "You are a terse assistant. Answer in one sentence."},
    {"role": "user", "content": "What is the capital of France?"},
    {"role": "assistant", "content": "The capital of France is Paris."},
    {"role": "user", "content": "And roughly how many people live there?"}
  ]
}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:01:22.9415292Z","message":{"role":"assistant","content":"Paris has a population of about 2.2 million within the city limits and up to 2.8 million within the urban area."},"done":true,"done_reason":"stop",...

"There" was resolved to Paris from the history. Three fields change what you get back. "format": "json" makes message.content a JSON string; the API reference adds that it is "important to also instruct the model to respond in JSON", so the prompt names the keys:

{
  "model": "qwen2.5:3b",
  "stream": false,
  "format": "json",
  "messages": [
    {"role": "user", "content": "Give the capital and the currency of Japan as JSON with the keys capital and currency."}
  ]
}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:01:23.1519567Z","message":{"role":"assistant","content":"{\"capital\": \"Tokyo\", \"currency\": \"JPY\"}"},"done":true,"done_reason":"stop",...

The keys followed the prompt both times; the values did not, a second call gave Japanese yen for the currency. That is the default temperature at work, and the second field fixes it. options carries the model parameters per request, and with temperature 0 two identical calls gave the identical answer:

  "options": {"temperature": 0, "num_ctx": 4096},
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:01:23.4869017Z","message":{"role":"assistant","content":"Red, Blue, Green"},"done":true,"done_reason":"stop","total_duration":63286300,"load_duration":16689400,"prompt_eval_count":40,"prompt_eval_cached_count":39,"prompt_eval_duration":6231000,"eval_count":6,"eval_duration":32021000}
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:01:23.5637953Z","message":{"role":"assistant","content":"Red, Blue, Green"},"done":true,"done_reason":"stop","total_duration":59634500,"load_duration":14968000,"prompt_eval_count":40,"prompt_eval_cached_count":39,"prompt_eval_duration":6481000,"eval_count":6,"eval_duration":32741999}

prompt_eval_cached_count is new: 39 of the 40 prompt tokens came from the cache on the repeat. num_ctx 4096 is this PC's default and caused no reload; a different value makes the model reload with the new context size. The third field, keep_alive, decides how long the model stays in memory after the answer; the FAQ says a value of 0 "will unload the model immediately after generating a response", and it did:

PS> curl.exe -s http://127.0.0.1:11434/api/chat --data-binary "@req/chat-keepalive0.json"
{"model":"qwen2.5:3b","created_at":"2026-10-30T03:01:23.8305612Z","message":{"role":"assistant","content":"OK."},"done":true,"done_reason":"stop",...
PS> Start-Sleep -Seconds 2; ollama ps
NAME           ID              SIZE     PROCESSOR          CONTEXT    RUNNER      UNTIL
qwen2.5:14b    7cdf5a0187d5    12 GB    51%/49% CPU/GPU    4096       llamacpp    About a minute from now

qwen2.5:3b is gone from the list; the other model loaded at the time stayed. The next request loads it again, which costs a second or two.

/api/embed: one vector per input

Embeddings need an embedding model; input is a string or an array of strings. The response is one long line, 9,631 bytes for one sentence, so it was saved to a file and summarised:

PS> Get-Content .\req\embed.json
{"model": "nomic-embed-text", "input": "Ollama runs language models on your own computer."}
PS> curl.exe -s http://127.0.0.1:11434/api/embed --data-binary "@req/embed.json" -o res\embed.json; (Get-Item res\embed.json).Length
9631
PS> (Get-Content res\embed.json -Raw).Substring(0, 160)
{"model":"nomic-embed-text","embeddings":[[0.012113785,0.057205096,-0.15038756,-0.032113526,0.030615745,-0.021908637,0.014494811,-0.033710256,-0.02876831,0.0318
model=nomic-embed-text vectors=1 length=768 first three=0.012113785, 0.057205096, -0.15038756 prompt_eval_count=13

Two inputs gave vectors=2 lengths=768,768. The older /api/embeddings endpoint still exists; the reference marks it as superseded by /api/embed. The first embed call took 14 seconds, all of it load_duration, because the model had to be loaded beside a 12 GB one that was half in system RAM; later calls took milliseconds.

/v1: the OpenAI-compatible endpoint

The same server answers requests in the OpenAI shape under /v1, which is what lets any OpenAI client library talk to Ollama by changing its base address. The compatibility page says of the key: "The client requires an API key value, but Ollama ignores it." Both calls below answered 200, one with a made-up bearer token and one with no Authorization header at all:

PS> curl.exe -s http://127.0.0.1:11434/v1/chat/completions -H "Content-Type: application/json" -H "Authorization: Bearer this-key-is-ignored" --data-binary "@req/openai-chat.json"
{"id":"chatcmpl-527","object":"chat.completion","created":1793329329,"model":"qwen2.5:3b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"Red, Blue, Green"},"finish_reason":"stop"}],"usage":{"prompt_tokens":40,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens":6,"total_tokens":46}}
PS> curl.exe -s -i http://127.0.0.1:11434/v1/chat/completions -H "Content-Type: application/json" --data-binary "@req/openai-chat.json"
HTTP/1.1 200 OK
Content-Type: application/json
Date: Fri, 30 Oct 2026 03:02:09 GMT
Content-Length: 345

Streaming on /v1 is server-sent events rather than NDJSON: data: lines with a delta each, a final chunk with "finish_reason":"stop", then data: [DONE]. /v1/models lists what ollama list shows, with created set to the model's modified time, and /v1/embeddings returns the same 768 floats as /api/embed in the OpenAI envelope:

data: {"id":"chatcmpl-425","object":"chat.completion.chunk","created":1793329329,"model":"qwen2.5:3b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"Red"},"finish_reason":null}]}
data: {"id":"chatcmpl-425","object":"chat.completion.chunk","created":1793329329,"model":"qwen2.5:3b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
object=list data=1 length=768 first three=0.012113785, 0.057205096, -0.15038756 usage={"prompt_tokens":13,"total_tokens":13}

One limit the compatibility page states plainly: "The OpenAI API does not have a way of setting the context size for a model." For num_ctx and the other options, use the native endpoints.

The errors, as the server sends them

A model that is not pulled is a 404 with a one-line JSON body on both native endpoints, and 0.40.11 no longer adds a hint to pull it. A body with no model field gets the same 404 with an empty name, although the errors page lists 400 for "missing parameters":

PS> curl.exe -s -i http://127.0.0.1:11434/api/generate --data-binary "@req/error-nope.json"
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Fri, 30 Oct 2026 03:02:09 GMT
Content-Length: 34

{"error":"model 'nope' not found"}
PS> Get-Content .\req\error-nomodel.json
{"prompt": "Hello"}
PS> curl.exe -s -i http://127.0.0.1:11434/api/generate --data-binary "@req/error-nomodel.json"
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Fri, 30 Oct 2026 03:02:09 GMT
Content-Length: 30

{"error":"model '' not found"}

On /v1 the same mistakes come back in the OpenAI error envelope: a 404 with "type":"not_found_error" for the missing model, and a 400 when the body has no messages:

{"error":{"message":"model 'nope' not found","type":"not_found_error","param":null,"code":null}}
{"error":{"message":"[] is too short - 'messages'","type":"invalid_request_error","param":null,"code":null}}

The 403 is the one that catches browser code. A request with an Origin header that is not on the allowed list is refused with an empty body, so there is no error text to search for; the same request from an allowed origin answers with the CORS header. curl and HttpClient send no Origin and are never refused this way.

PS> curl.exe -s -i -H "Origin: http://app.example.test" http://127.0.0.1:11434/api/version
HTTP/1.1 403 Forbidden
Date: Fri, 30 Oct 2026 03:02:09 GMT
Content-Length: 0

PS> curl.exe -s -i -H "Origin: http://localhost:5173" http://127.0.0.1:11434/api/version
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Content-Type: application/json; charset=utf-8
Vary: Origin

The default list, as the tray app printed it at startup, allows localhost and 127.0.0.1 on any port plus app://, file://, tauri:// and the VS Code schemes; anything else needs OLLAMA_ORIGINS. A wrong HTTP method is a plain-text 405 with an Allow header, HEAD, GET for /api/tags.

From .NET, three ways

Plain HttpClient and System.Text.Json, no package. The point of the streaming version is HttpCompletionOption.ResponseHeadersRead, which hands you the stream before the body is complete, and then one JsonDocument per line:

using System.Net.Http.Json;
using System.Text.Json;

var baseUrl = Environment.GetEnvironmentVariable("OLLAMA_URL") ?? "http://127.0.0.1:11434";
var model = args.Length > 0 ? args[0] : "qwen2.5:3b";

using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromMinutes(5) };

var body = new
{
    model,
    messages = new[]
    {
        new { role = "system", content = "You are a terse assistant. Answer in one sentence." },
        new { role = "user", content = "Why is the sky blue?" }
    },
    stream = true
};

using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat") { Content = JsonContent.Create(body) };
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);

if (!response.IsSuccessStatusCode)
{
    Console.WriteLine($"HTTP {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
    return 1;
}

using var reader = new StreamReader(await response.Content.ReadAsStreamAsync());
var lines = 0;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.Length == 0) continue;
    lines++;
    using var doc = JsonDocument.Parse(line);
    var root = doc.RootElement;
    Console.Write(root.GetProperty("message").GetProperty("content").GetString());

    if (root.GetProperty("done").GetBoolean())
    {
        var evalCount = root.GetProperty("eval_count").GetInt64();
        var evalSeconds = root.GetProperty("eval_duration").GetInt64() / 1e9;
        Console.WriteLine();
        Console.WriteLine($"lines={lines} done_reason={root.GetProperty("done_reason").GetString()} " +
                          $"eval_count={evalCount} tokens/s={evalCount / evalSeconds:F1}");
    }
}
return 0;
PS> dotnet run
The sky appears blue due to the scattered blue light from the Sun more than the other colors.
lines=20 done_reason=stop eval_count=20 tokens/s=153.9

The five-minute timeout matters: the default 100 seconds is shorter than a cold load of a large model plus a long answer. The error branch prints the body, because Ollama's 404 says which model is missing and EnsureSuccessStatusCode would throw that away.

OllamaSharp through Microsoft.Extensions.AI. Two packages, OllamaSharp 5.5.0 and Microsoft.Extensions.AI 10.11.0, and the client is used only through the abstractions, so the calling code does not mention Ollama after the first line:

using Microsoft.Extensions.AI;
using OllamaSharp;

var endpoint = new Uri(Environment.GetEnvironmentVariable("OLLAMA_URL") ?? "http://127.0.0.1:11434");

IChatClient chat = new OllamaApiClient(endpoint, "qwen2.5:3b");

List<ChatMessage> history =
[
    new(ChatRole.System, "You are a terse assistant. Answer in one sentence."),
    new(ChatRole.User, "Why is the sky blue?")
];

ChatResponse first = await chat.GetResponseAsync(history);
Console.WriteLine($"assistant: {first.Text}");
Console.WriteLine($"usage: input={first.Usage?.InputTokenCount} output={first.Usage?.OutputTokenCount} finish={first.FinishReason}");

history.AddMessages(first);
history.Add(new(ChatRole.User, "And why is it red at sunset?"));

Console.Write("assistant (streamed): ");
await foreach (ChatResponseUpdate update in chat.GetStreamingResponseAsync(history))
{
    Console.Write(update.Text);
}
Console.WriteLine();

IEmbeddingGenerator<string, Embedding<float>> embedder = new OllamaApiClient(endpoint, "nomic-embed-text");
var embeddings = await embedder.GenerateAsync(["Ollama runs language models on your own computer."]);
var vector = embeddings[0].Vector.Span;
Console.WriteLine($"embedding: length={vector.Length} first three={vector[0]}, {vector[1]}, {vector[2]}");
PS> dotnet run
assistant: The sky appears blue due to the dominant scattering of sunlight by air molecules, primarily blue light being scattered more than other colors.
usage: input=30 output=26 finish=stop
assistant (streamed): Red light is scattered less and travels further, reaching the observer's eye at sunset, making the sky appear red.
embedding: length=768 first three=0.012113785, 0.057205096, -0.15038756

history.AddMessages(first) appends the assistant turn, so the second question is answered in context, and the same client type serves as the embedding generator. The three floats match the curl call exactly.

The OpenAI client at /v1. OpenAI 2.14.0 with Microsoft.Extensions.AI.OpenAI 10.11.0 for the AsIChatClient() bridge. The key string is required by the client and ignored by the server; the endpoint is the only change from code written for OpenAI:

using System.ClientModel;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using OpenAI.Embeddings;

var openAi = new OpenAIClient(
    new ApiKeyCredential("ollama"),
    new OpenAIClientOptions { Endpoint = new Uri(Environment.GetEnvironmentVariable("OLLAMA_V1_URL") ?? "http://127.0.0.1:11434/v1") });

ChatClient chat = openAi.GetChatClient("qwen2.5:3b");

ChatCompletion completion = await chat.CompleteChatAsync(
    new SystemChatMessage("You are a terse assistant. Answer in one sentence."),
    new UserChatMessage("Why is the sky blue?"));
Console.WriteLine($"assistant: {completion.Content[0].Text}");
Console.WriteLine($"usage: input={completion.Usage.InputTokenCount} output={completion.Usage.OutputTokenCount} finish={completion.FinishReason} model={completion.Model}");

Console.Write("assistant (streamed): ");
await foreach (StreamingChatCompletionUpdate update in chat.CompleteChatStreamingAsync(new UserChatMessage("Name three primary colors. Answer in one short line.")))
{
    foreach (var part in update.ContentUpdate)
    {
        Console.Write(part.Text);
    }
}
Console.WriteLine();

IChatClient meai = chat.AsIChatClient();
ChatResponse viaMeai = await meai.GetResponseAsync("Say OK.");
Console.WriteLine($"IChatClient over /v1: {viaMeai.Text}");

EmbeddingClient embeddings = openAi.GetEmbeddingClient("nomic-embed-text");
OpenAIEmbedding embedding = await embeddings.GenerateEmbeddingAsync("Ollama runs language models on your own computer.");
ReadOnlyMemory<float> vector = embedding.ToFloats();
Console.WriteLine($"embedding: length={vector.Length} first three={vector.Span[0]}, {vector.Span[1]}, {vector.Span[2]}");
PS> dotnet run
assistant: The sky appears blue due to the dominant scattering of sunbeam by大气分子.
usage: input=30 output=17 finish=Stop model=qwen2.5:3b
assistant (streamed): Red, Blue, Yellow
IChatClient over /v1: Ok.
embedding: length=768 first three=0.012113785, 0.057205096, -0.15038756

That first run is kept as captured: qwen2.5:3b at its default temperature switched to Chinese in mid-sentence, and two further runs of the same program answered in English, with the three colours coming out as Yellow twice and Green once. A sample that must be stable needs temperature 0, which on /v1 is the client's own Temperature option. finish=Stop is the OpenAI enum rather than Ollama's stop, and the embedding floats are the same three again.

Which one to use: raw HttpClient when you want no dependency and the native fields such as prompt_eval_cached_count; OllamaSharp when the rest of the app is written against IChatClient; the OpenAI client when the code already exists for OpenAI and the model is the only thing that changes.

Frequently asked

Does the Ollama API need an API key?
No. Local requests to 127.0.0.1:11434 need no key, and the OpenAI-compatible /v1 endpoint accepts any Authorization header or none; both calls answered 200 here. Only requests to Ollama's cloud need a key. The OpenAI client libraries require a key string, so give them any word.
What is the difference between /api/generate and /api/chat?
/api/generate takes one prompt and returns one completion; /api/chat takes the whole conversation as a messages array with system, user and assistant roles and returns the next assistant message. Both stream one JSON object per line by default, accept stream, format, options and keep_alive, and report the same timing fields in nanoseconds.
How do I call Ollama from C#?
Three ways were run here: a plain HttpClient posting JSON to /api/chat and reading the NDJSON stream line by line; OllamaSharp 5.5.0 used through the Microsoft.Extensions.AI IChatClient and IEmbeddingGenerator interfaces; and the official OpenAI 2.14.0 client with its Endpoint set to http://127.0.0.1:11434/v1 and any key string. All three returned the same embedding values for the same input.

Whether the model behind these numbers ran on the GPU is answered in Is Ollama Using My GPU?; the port and the OLLAMA_HOST variable are in Ollama Default Port 11434; the 404 as .NET reports it is decoded in Response status code does not indicate success: 404.