An agent that only reads text is limited. Much of real work lives in screenshots, scanned forms, product photos, and the screens of apps that have no API. Those jobs need a model that can see the picture and act on it, and the bill has to stay reasonable when the agent runs hundreds of times a day.
GPT-6.1 Terra is now generally available in Microsoft Foundry. Microsoft positions it for high-volume visual and screen-based work, and it says Terra is quick and efficient for day-to-day professional tasks.
Sources for the facts in this post: the Foundry announcement and the Responses API documentation. The pricing is Microsoft’s published table, and I haven’t checked it against your region or contract.
The full sample is on GitHub: GPT-6-1-Terra-CSharp-Sample.
What Terra is good at
Microsoft lists four core strengths. For a C# developer, each one maps to a concrete kind of work.
- Stronger vision. Better visual reasoning picks out the details in product photos, technical drawings, floor plans, and application screens.
- Faster, more precise computer use. The model pairs what it sees with quick, accurate actions in browsers, desktop software, and apps.
- Clear writing. It summarizes what it finds in plain language and drafts emails and proposals.
- Large context. A 1.05M-token context window, with up to 922K input tokens and 128K output tokens. It takes text and image input.
Microsoft’s own examples are a good shortlist: reviewing campaign assets for cropping and text placement, matching supplier product images to catalog entries, finding a moment in a video, and completing forms. Each one starts with a picture and ends in software.
One note on video: the model takes text and images, not video files. To find a moment in a video, your app pulls frames at an interval, sends them as images, and keeps track of each frame’s timestamp itself.
Where it fits, and where it doesn’t
Terra is not the model for the hardest reasoning. Microsoft says it complements GPT-6 Astra: Astra plans open-ended, multi-step work, and Terra handles the high-volume visual and on-screen tasks inside that plan, at a lower price point.
| Use Terra for | Keep another model for |
|---|---|
| Reading screenshots, scans, and product photos | Open-ended planning across many steps |
| Form filling and routine email or proposal drafts | Long reasoning on hard technical problems |
| Retail catalog checks and listing updates | Decisions that need a strong, explained rationale |
| Finding a moment in video frames and acting on it | A fast, typed answer with no generated text (use the Decisions API) |
For the last row, Microsoft suggests pairing the two. Terra handles the visual work, and the Decisions API makes the quick call on what happens next. That is the same split I described for the Decisions API.
The developer controls
Three controls matter for a .NET app.
- Reasoning effort. You can set it, including none for latency-sensitive calls. The sample uses low.
- Verbosity. It matches the length of the response to the task. The sample uses low.
- Interim updates. When your orchestration layer supports it, Terra can send messages to the user while it keeps working on a long task.
The sample sends reasoning.effort and text.verbosity in the request body. Check the exact names and allowed values in the Responses API reference before you rely on them, because the parts I checked did not show the full parameter list.
The API is the Responses API
Terra works with the Foundry APIs you already use, through the Responses API, with no new SDK or schema changes. That matters for C#: you do not need a Terra-specific package. A plain HttpClient call is enough, which is what the sample does.
You can use the OpenAI .NET SDK instead. Microsoft’s Responses API docs show it with the OpenAI and Azure.Identity packages and a ResponsesClient pointed at your resource’s /openai/v1 endpoint, and it gives you typed requests and responses instead of hand-built JSON. Its Responses types are marked experimental, so the docs’ examples suppress the OPENAI001 warning. I kept HttpClient here because it shows exactly what goes over the wire, which helps when you’re debugging a tool loop.
The request goes to:
|
1 |
POST https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/responses |
The model field takes your deployment name, not the model name.
Authentication has two options, and the sample supports both:
- API key: sent in the api-key header.
- Microsoft Entra ID: a bearer token for the scope https://ai.azure.com/.default, from DefaultAzureCredential. Locally, az login is enough.
If you set an API key, the sample uses it. If you don’t, the sample signs in with Entra ID. Use Entra ID for anything that runs in production. When the app runs in Azure, DefaultAzureCredential picks up its managed identity, so there is no secret to store at all.
Use the Responses API for tool calling too. Microsoft’s reasoning models guide recommends it whenever you combine reasoning with function tools, because it returns reasoning items you can pass back between turns. It also documents a trap on the GPT-5.6 models: a Chat Completions request that sends tools fails unless reasoning_effort is none, because those models default to medium. The guide doesn’t list that limit for Terra, but if you’re moving an existing Chat Completions tool loop to a newer reasoning model, test the tool calls before you switch.
What it costs
Microsoft’s published prices in USD per million tokens:
| Deployment | Context | Input | Cached input | Output |
|---|---|---|---|---|
| Global Standard | Short | $1.00 | $0.05 | $5.00 |
| Global Standard | Long | $2.00 | $0.10 | $7.50 |
| Data Zone Standard (US) | Short | $1.10 | $0.055 | $5.50 |
| Data Zone Standard (EU) | Short | $1.20 | $0.06 | $6.00 |
| Data Zone Standard (APAC) | Short | $1.20 | $0.06 | $6.00 |
The announcement lists Data Zone Standard for US, EU and APAC, so you can keep data in a region without a separate model. On short context, output tokens cost about five times input tokens, so a short answer is cheap and a long generated report is not. Reasoning tokens count as output, which is one more reason to set reasoning effort on purpose.
Watch the line between short and long context. On OpenAI’s own API, a request with more than 272K input tokens is billed at the long-context rate for the whole request, not just the tokens past the line. One token over reprices the entire call. I haven’t found that threshold in Microsoft’s pricing for Foundry, so check your bill before you assume it works the same way. If an agent keeps adding screenshots to a long conversation, trim the history before it crosses the line.
Calling Terra from C#
The sample is one console app with two examples:
- Read a screenshot. Terra looks at a PNG of a web form and says what the user is trying to do, what blocks them, and which control to use next.
- Review a PDF invoice. Terra reads the invoice, calls a C# function to check the supplier, and returns a structured result that deserializes into a C# record.
Both print token usage and elapsed time. The sample files, expense-form.png and invoice.pdf, are in the GitHub repo. Contoso, Fabrikam and everything on them are made up.
To skip the setup below, clone it and go straight to the settings:
|
1 2 |
git clone https://github.com/taswar/GPT-6-1-Terra-CSharp-Sample.git cd GPT-6-1-Terra-CSharp-Sample |
To build it from scratch instead, create the project on .NET 10 and add the packages:
|
1 2 3 4 5 6 |
dotnet new console -n GPT-6-1-Terra-CSharp-Sample cd GPT-6-1-Terra-CSharp-Sample dotnet add package Azure.Identity dotnet add package Microsoft.Extensions.Configuration.UserSecrets dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables dotnet user-secrets init |
Store the settings with user secrets. The endpoint is the base URL with no path. The API key line is optional. Leave it out to use Entra ID:
|
1 2 3 |
dotnet user-secrets set “AZURE_OPENAI_ENDPOINT” “https://YOUR-RESOURCE-NAME.openai.azure.com” dotnet user-secrets set “AZURE_OPENAI_DEPLOYMENT” “gpt-6.1-terra” dotnet user-secrets set “AZURE_OPENAI_API_KEY” “your-key-here” |
For Entra ID, run az login and make sure your account has access to call the model on the resource. Check the role name in the Foundry access settings for your resource. Then check what the app will read:
|
1 |
dotnet user-secrets list |
That command prints your API key in plain text, so don’t paste its output into an issue, a log, or a screenshot.
The project file is GPT-6-1-Terra-CSharp-Sample.csproj. Keep the UserSecretsId that dotnet user-secrets init wrote into your own project file. If you copy someone else’s, your secrets won’t be found:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<Project Sdk=“Microsoft.NET.Sdk”> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <UserSecretsId>YOUR-USER-SECRETS-ID</UserSecretsId> </PropertyGroup> <ItemGroup> <PackageReference Include=“Azure.Identity” Version=“1.21.0” /> <PackageReference Include=“Microsoft.Extensions.Configuration.EnvironmentVariables” Version=“10.0.12” /> <PackageReference Include=“Microsoft.Extensions.Configuration.UserSecrets” Version=“10.0.12” /> </ItemGroup> </Project> |
Then Program.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 |
using System.Diagnostics; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Azure.Core; using Azure.Identity; using Microsoft.Extensions.Configuration; // Pick the example from the command line: dotnet run — 1, dotnet run — 2, or dotnet run — all. var choice = (args.Length > 0 ? args[0] : “all”).TrimStart(‘-‘).ToLowerInvariant(); if (choice is not (“1” or “2” or “all”)) { Console.WriteLine(“”“ Usage: dotnet run — [1 | 2 | all] 1 Read a screenshot (expense-form.png) 2 Review a PDF invoice with a tool call and structured output (invoice.pdf) all Run both (the default) ““”); return 1; } var config = new ConfigurationBuilder() .AddUserSecrets<Program>() .AddEnvironmentVariables() .Build(); var endpoint = (config[“AZURE_OPENAI_ENDPOINT”] ?? throw new InvalidOperationException( “Set AZURE_OPENAI_ENDPOINT with dotnet user-secrets, for example https://YOUR-RESOURCE-NAME.openai.azure.com”)) .TrimEnd(‘/’); var deployment = config[“AZURE_OPENAI_DEPLOYMENT”] ?? “gpt-6.1-terra”; // The API key is optional. Without one, the app signs in with Entra ID through az login. var apiKey = config[“AZURE_OPENAI_API_KEY”]; using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); var terra = new TerraClient(http, endpoint, apiKey, new DefaultAzureCredential(), deployment); if (choice is “1” or “all”) { await ScreenshotExample.RunAsync(terra, “expense-form.png”, cts.Token); } if (choice is “2” or “all”) { await InvoiceAgentExample.RunAsync(terra, “invoice.pdf”, cts.Token); } return 0; // Example 1: image analysis. Terra reads a screenshot of a web form and says what blocks the user. static class ScreenshotExample { public static async Task RunAsync(TerraClient terra, string imagePath, CancellationToken cancellationToken) { Console.WriteLine(“=== Example 1: read a screenshot ===”); if (!File.Exists(imagePath)) { throw new FileNotFoundException(“Run from the project folder, where the sample files are.”, imagePath); } var imageBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(imagePath, cancellationToken)); var body = new JsonObject { [“model”] = terra.Deployment, [“input”] = new JsonArray { new JsonObject { [“role”] = “user”, [“content”] = new JsonArray { new JsonObject { [“type”] = “input_text”, [“text”] = “”“ This is a screenshot of a web app. In four short lines: 1. What is the user trying to do? 2. Which values are already filled in? 3. What is stopping them from submitting? 4. Which control should they use next? Give its exact label. ““” }, new JsonObject { [“type”] = “input_image”, [“image_url”] = $“data:{MimeTypeFor(imagePath)};base64,{imageBase64}”, // high reads small text such as field hints; low is cheaper for a rough look. [“detail”] = “high” } } } }, [“reasoning”] = new JsonObject { [“effort”] = “low” }, [“text”] = new JsonObject { [“verbosity”] = “low” } }; var stopwatch = Stopwatch.StartNew(); var usage = new Usage(); using var response = await terra.PostAsync(body, cancellationToken); stopwatch.Stop(); usage.Add(response.RootElement); Console.WriteLine(TerraClient.ReadOutputText(response.RootElement)); Console.WriteLine(); Console.WriteLine($“[terra] {usage} elapsed={stopwatch.Elapsed.TotalSeconds:0.0}s”); Console.WriteLine(); } // The data URL needs the image’s real type. PNG, JPEG, WebP and non-animated GIF are supported. static string MimeTypeFor(string path) => Path.GetExtension(path).ToLowerInvariant() switch { “.png” => “image/png”, “.jpg” or “.jpeg” => “image/jpeg”, “.webp” => “image/webp”, “.gif” => “image/gif”, _ => throw new NotSupportedException($“Unsupported image type: {path}”) }; } // Example 2: an invoice agent. PDF input, one tool call, and a structured result in a C# record. static class InvoiceAgentExample { const string Instructions = “”“ You review supplier invoices for accounts payable. Read the attached invoice. Always call check_supplier with the supplier name exactly as printed. Set requires_approval to true if the total is over 5000, or if the supplier is not on file or not active. List anything a reviewer should look at in issues, such as math that doesn’t add up or a missing PO number. Use an empty list when there is nothing to flag. ““”; public static async Task RunAsync(TerraClient terra, string pdfPath, CancellationToken cancellationToken) { Console.WriteLine(“=== Example 2: review a PDF invoice ===”); if (!File.Exists(pdfPath)) { throw new FileNotFoundException(“Run from the project folder, where the sample files are.”, pdfPath); } var pdfBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(pdfPath, cancellationToken)); var body = new JsonObject { [“model”] = terra.Deployment, [“instructions”] = Instructions, [“input”] = new JsonArray { new JsonObject { [“role”] = “user”, [“content”] = new JsonArray { new JsonObject { [“type”] = “input_file”, [“filename”] = Path.GetFileName(pdfPath), [“file_data”] = $“data:application/pdf;base64,{pdfBase64}” }, new JsonObject { [“type”] = “input_text”, [“text”] = “Review this invoice.” } } } } }; AddToolsAndFormat(body); var stopwatch = Stopwatch.StartNew(); var usage = new Usage(); var response = await terra.PostAsync(body, cancellationToken); usage.Add(response.RootElement); // The tool loop: run every function call Terra asks for, then send the results back. // previous_response_id chains the turns on the service, including the reasoning items. for (var round = 1; round <= 5; round++) { var outputs = await RunToolCallsAsync(response.RootElement); if (outputs.Count == 0) { break; } var next = new JsonObject { [“model”] = terra.Deployment, [“previous_response_id”] = response.RootElement.GetProperty(“id”).GetString(), [“input”] = outputs }; AddToolsAndFormat(next); response.Dispose(); response = await terra.PostAsync(next, cancellationToken); usage.Add(response.RootElement); } stopwatch.Stop(); var json = TerraClient.ReadOutputText(response.RootElement); response.Dispose(); var review = JsonSerializer.Deserialize<InvoiceReview>(json, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }) ?? throw new InvalidOperationException($“The model returned no invoice review: {json}”); Console.WriteLine(); Console.WriteLine($“Supplier: {review.SupplierName} (on file: {review.SupplierOnFile})”); Console.WriteLine($“Invoice: {review.InvoiceNumber}, dated {review.InvoiceDate}, PO {review.PoNumber}”); Console.WriteLine($“Total: {review.Total:N2} {review.Currency}”); Console.WriteLine($“Needs approval: {review.RequiresApproval}”); foreach (var issue in review.Issues) { Console.WriteLine($“Issue: {issue}”); } Console.WriteLine(); Console.WriteLine($“[terra] {usage} elapsed={stopwatch.Elapsed.TotalSeconds:0.0}s”); } // Runs each function_call in the response and returns the function_call_output items to send back. static async Task<JsonArray> RunToolCallsAsync(JsonElement root) { var outputs = new JsonArray(); foreach (var call in root.GetProperty(“output”).EnumerateArray() .Where(item => item.GetProperty(“type”).GetString() == “function_call”)) { var name = call.GetProperty(“name”).GetString(); var arguments = JsonNode.Parse(call.GetProperty(“arguments”).GetString() ?? “{}”)!; Console.WriteLine($“[tool] {name}({arguments.ToJsonString()})”); var result = name switch { “check_supplier” => await CheckSupplierAsync(arguments[“supplier_name”]?.GetValue<string>() ?? “”), _ => $$“”“{“error“: “Unknown tool {{name}}“}”“” }; Console.WriteLine($“[tool] -> {result}”); outputs.Add(new JsonObject { [“type”] = “function_call_output”, [“call_id”] = call.GetProperty(“call_id”).GetString(), [“output”] = result }); } return outputs; } // A stand-in for your ERP or vendor master. Replace it with a real lookup. static Task<string> CheckSupplierAsync(string supplierName) { var suppliers = new Dictionary<string, (string Id, string Status)>(StringComparer.OrdinalIgnoreCase) { [“Fabrikam Office Supplies”] = (“V-1042”, “active”), [“Northwind Traders”] = (“V-0877”, “on hold”) }; var result = suppliers.TryGetValue(supplierName.Trim(), out var supplier) ? new JsonObject { [“found”] = true, [“supplier_id”] = supplier.Id, [“status”] = supplier.Status } : new JsonObject { [“found”] = false }; return Task.FromResult(result.ToJsonString()); } // Every request in the loop sends the same tool, reasoning effort and output schema. static void AddToolsAndFormat(JsonObject body) { body[“tools”] = new JsonArray { CheckSupplierTool() }; body[“reasoning”] = new JsonObject { [“effort”] = “low” }; // Structured output: the final answer must match the InvoiceReview schema. body[“text”] = new JsonObject { [“format”] = InvoiceReviewFormat(), [“verbosity”] = “low” }; } static JsonObject CheckSupplierTool() => new() { [“type”] = “function”, [“name”] = “check_supplier”, [“description”] = “Looks up a supplier in the vendor master. Returns whether it exists, its ID and its status.”, [“strict”] = true, [“parameters”] = new JsonObject { [“type”] = “object”, [“properties”] = new JsonObject { [“supplier_name”] = new JsonObject { [“type”] = “string”, [“description”] = “The supplier name as printed on the invoice.” } }, [“required”] = new JsonArray(“supplier_name”), [“additionalProperties”] = false } }; // Strict mode needs every property in “required” and additionalProperties set to false. static JsonObject InvoiceReviewFormat() => new() { [“type”] = “json_schema”, [“name”] = “invoice_review”, [“strict”] = true, [“schema”] = new JsonObject { [“type”] = “object”, [“properties”] = new JsonObject { [“supplier_name”] = new JsonObject { [“type”] = “string” }, [“supplier_on_file”] = new JsonObject { [“type”] = “boolean” }, [“invoice_number”] = new JsonObject { [“type”] = “string” }, [“invoice_date”] = new JsonObject { [“type”] = “string”, [“description”] = “ISO 8601 date, for example 2026-10-12.” }, [“po_number”] = new JsonObject { [“type”] = “string”, [“description”] = “Empty if the invoice has no PO number.” }, [“currency”] = new JsonObject { [“type”] = “string”, [“description”] = “ISO 4217 code, for example USD.” }, [“total”] = new JsonObject { [“type”] = “number” }, [“requires_approval”] = new JsonObject { [“type”] = “boolean” }, [“issues”] = new JsonObject { [“type”] = “array”, [“items”] = new JsonObject { [“type”] = “string” } } }, [“required”] = new JsonArray( “supplier_name”, “supplier_on_file”, “invoice_number”, “invoice_date”, “po_number”, “currency”, “total”, “requires_approval”, “issues”), [“additionalProperties”] = false } }; } // The C# shape of the structured output. Property names map to the schema in snake_case. record InvoiceReview( string SupplierName, bool SupplierOnFile, string InvoiceNumber, string InvoiceDate, string PoNumber, string Currency, decimal Total, bool RequiresApproval, List<string> Issues); // Sends requests to the Responses API with an API key or an Entra ID token. sealed class TerraClient(HttpClient http, string endpoint, string? apiKey, TokenCredential credential, string deployment) { public string Deployment => deployment; public async Task<JsonDocument> PostAsync(JsonObject body, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Post, $“{endpoint}/openai/v1/responses”); if (!string.IsNullOrWhiteSpace(apiKey)) { request.Headers.Add(“api-key”, apiKey); } else { var token = await credential.GetTokenAsync( new TokenRequestContext([“https://ai.azure.com/.default”]), cancellationToken); request.Headers.Authorization = new AuthenticationHeaderValue(“Bearer”, token.Token); } // StringContent sets a fixed Content-Length; JsonContent uses chunked encoding. request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, “application/json”); using var response = await http.SendAsync(request, cancellationToken); var text = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($“Responses API returned {(int)response.StatusCode}: {text}”); } return JsonDocument.Parse(text); } public static string ReadOutputText(JsonElement root) { if (root.TryGetProperty(“output_text”, out var convenience) && convenience.ValueKind == JsonValueKind.String) { return convenience.GetString() ?? string.Empty; } var builder = new StringBuilder(); foreach (var item in root.GetProperty(“output”).EnumerateArray()) { if (!item.TryGetProperty(“type”, out var type) || type.GetString() != “message”) { continue; } foreach (var part in item.GetProperty(“content”).EnumerateArray()) { if (part.TryGetProperty(“type”, out var partType) && partType.GetString() == “output_text”) { builder.Append(part.GetProperty(“text”).GetString()); } } } return builder.ToString(); } } // Token usage summed across every call in an example. sealed class Usage { public int Calls { get; private set; } public int Input { get; private set; } public int Cached { get; private set; } public int Output { get; private set; } public int Reasoning { get; private set; } public void Add(JsonElement root) { Calls++; if (!root.TryGetProperty(“usage”, out var usage)) { return; } Input += Read(usage, “input_tokens”); Output += Read(usage, “output_tokens”); if (usage.TryGetProperty(“input_tokens_details”, out var inputDetails)) { Cached += Read(inputDetails, “cached_tokens”); } if (usage.TryGetProperty(“output_tokens_details”, out var outputDetails)) { Reasoning += Read(outputDetails, “reasoning_tokens”); } } public override string ToString() => $“calls={Calls} input={Input} cached={Cached} output={Output} reasoning={Reasoning}”; static int Read(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt32() : 0; } |
Example 1: reading a screenshot
The first request sends a question and the PNG together in one user message. The image goes in as a base64 data URL, and the data URL has to carry the real type of the file. MimeTypeFor maps the extension to image/png, image/jpeg, image/webp, or image/gif, which are the formats the Responses API accepts.
The detail setting decides how closely the model looks:
| detail | What it does | Use it for |
|---|---|---|
| low | A lower-resolution copy of the image. Fewest tokens, fastest. | Rough checks, such as “is this a receipt or a product photo?” |
| high | A higher-resolution copy. More tokens, slower. | Small text, such as the red field hints on this form |
| auto | The default. The model picks. | When you don’t know what the images will look like |
The sample uses high because the answer depends on two small error messages under the fields. Run it once with low and compare the answer and the input tokens.
This is the reading half of computer use. The model doesn’t click anything. It tells your code what it sees and what to do next, and your code, or a browser automation tool such as Playwright, does the click. Then you send the next screenshot.
Example 2: an invoice agent
The second request does three things the first one doesn’t.
It sends a PDF. The invoice goes in as an input_file item with a base64 data:application/pdf URL. Microsoft’s docs say models that take both text and images can read PDFs, and that the model sees both the extracted text and an image of each page. That helps with tables, stamps and scanned pages, but it also means a PDF costs more tokens than its text alone. You can also send a file ID from the Files API or a file_url instead of base64. Each request is limited to 50 MB of files.
It calls a C# function. The request declares one tool, check_supplier, with a JSON schema for its argument. Terra reads the supplier name off the invoice and asks for the call. The app finds the function_call item in the output, runs CheckSupplierAsync, and sends the result back as a function_call_output with the same call_id. The loop stops when Terra returns no more calls, with a limit of five rounds so a confused model can’t loop forever.
The follow-up request uses previous_response_id instead of sending the whole conversation again. The service keeps the earlier turn, including the reasoning items, which the reasoning guide says you should carry across tool calls. This needs stored responses, which is the default. Microsoft keeps stored responses for 30 days unless you delete them.
It returns structured output. text.format holds a JSON schema with strict set to true, so the final answer has to match it: supplier, invoice number, date, PO number, currency, total, whether it needs approval, and a list of issues. Strict mode needs every property in required and additionalProperties set to false. The app deserializes the answer into the InvoiceReview record with System.Text.Json, using the snake_case naming policy so supplier_name maps to SupplierName.
The approval rule lives in the instructions: anything over 5,000, or from a supplier that isn’t on file and active, needs approval. In a real system, keep that rule in your own code as well, and treat the model’s requires_approval as a second opinion, not the decision.
CheckSupplierAsync is a dictionary standing in for your ERP. Replace it with a real lookup, and keep it read-only. A tool that only looks things up is a safe first tool. A tool that pays an invoice is not.
Run it
Run it from the project folder, because the app loads the sample files from there. Pass 1 for the screenshot, 2 for the invoice agent, or all (the default) for both:
|
1 |
dotnet run — 1 |
|
1 |
dotnet run — 2 |
|
1 |
dotnet run — all |
Anything else prints the usage and exits with code 1. Each example is its own class, ScreenshotExample and InvoiceAgentExample, and both send requests through one small TerraClient, so you can copy either one into your own app.
Your output will look like this. The numbers depend on your prompt, your deployment, and your region:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
=== Example 1: read a screenshot === 1. Submit a hotel expense for approval. 2. Date: 10/14/2026; merchant: Harborview Hotel; amount: $1,284.00; purpose: customer workshop in Portland, 3 nights. 3. A category and receipt are missing. 4. Use “Select a category” and “Upload receipt.” [terra] calls=1 input=1186 cached=0 output=73 reasoning=0 elapsed=5.0s === Example 2: review a PDF invoice === [tool] check_supplier({“supplier_name”:“Fabrikam Office Supplies”}) [tool] –> {“found”:true,“supplier_id”:“V-1042”,“status”:“active”} Supplier: Fabrikam Office Supplies (on file: True) Invoice: INV–20481, dated 2026–10–12, PO PO–7734 Total: 6,545.96 USD Needs approval: True Issue: Invoice states it is a sample document for a code demo involving fictitious companies. [terra] calls=2 input=4201 cached=0 output=140 reasoning=28 elapsed=5.8s |
Look at the one issue Terra raised. My test invoice has a footer line saying it’s a sample document for a code demo, with fictitious companies, and Terra flagged it even though nothing in the instructions mentioned it. That’s a fair catch: a real invoice that calls itself a sample is exactly what a reviewer should see. It’s also why the issues list goes to a person instead of being dropped. The model will notice things your rules didn’t anticipate, and some of them will matter.
What to measure before you ship
- Latency. Run the same screen or image through Terra at each reasoning effort. The elapsed value is wall-clock time for one run, which is one sample, not a benchmark. The invoice example makes at least two calls, one before the tool call and one after, so expect it to take longer than the screenshot.
- Tokens. Compare input, cached, output, and reasoning across runs. Reasoning tokens are billed as output. Try detail at low and high on the screenshot, and note what one PDF page costs you.
- Accuracy on your data. Microsoft’s examples are not your screens, and my invoice is not your invoices. Test on your own forms and documents, and compare the structured output with what a person would enter, before you trust it.
Computer use needs guardrails
Terra can act on screens, so treat its actions like any automation. Run it with the least permission it needs, keep a human approval step on anything that spends money, changes records, or sends a message, and log every action. The sample’s only tool is a read-only supplier lookup, and the screenshot example only describes what it sees, so neither can change anything. The pattern matters as soon as you give Terra a tool that writes, or connect a real browser or desktop.
Don’t rely on the prompt to keep it in bounds. According to reporting on OpenAI’s GPT-6.1 Terra system card, when Terra ran into an environment restriction such as denied access at maximum reasoning effort, it found another way to finish the task in 29.6% of test scenarios. That is higher than GPT-6 Astra and GPT-6.1 Sol. The same reporting says most of those tests were low risk and had no system-level enforcement, so read the number as a tendency, not a measured failure rate. I haven’t checked the figure against the system card itself. Either way, the lesson is the same: enforce limits in the environment, with a sandbox, scoped credentials, and an allow list, not only in the instructions.
Final thoughts
Terra is the model I would reach for when the input is a picture or a screen and the output is a small action or a clear summary, at volume. For the hard reasoning, keep a stronger model. For the quick typed call, use the Decisions API. Terra sits between them. The sample gives you the two patterns most of that work is built from: read a screen and say what to do next, and turn a document into typed data after checking it against your own system. It works with an API key today and with Entra ID for production.
Resources
- GPT-6-1-Terra-CSharp-Sample on GitHub
- GPT-6.1 Terra now generally available in Microsoft Foundry
- Azure OpenAI Responses API (Microsoft Learn)
- Azure OpenAI reasoning models (Microsoft Learn)
- Azure OpenAI Decisions API walkthrough
- GPT-6.1 Sol for C# developers
Building AI features in C#? I write about practical, no-hype prompt engineering and Azure AI patterns for .NET developers. Check out Prompt Engineering for .NET Developers — free, no Python required. You can also subscribe for more posts.

Leave a Reply