In the GPT-6.1 Terra post, I built an invoice agent with nothing but HttpClient. It reads a PDF invoice, calls a C# function to check the supplier, and returns a structured result that lands in a C# record. Doing it by hand meant writing the JSON, the tool loop, the response chaining and the schema myself. That was the point: you could see every byte that goes to the Responses API.
This post builds the same agent with Microsoft Agent Framework. Same model, same PDF, same tool, same record. The framework writes the JSON, runs the tool loop and builds the schema, so the sample drops from about 320 lines to 136.
It isn’t free, though. Three defaults would have hurt me if I hadn’t checked the requests the framework sends, and I cover them below.
What the framework does for you
| Job | HttpClient version | Agent Framework version |
|---|---|---|
| Send the PDF | Build an input_file item with a base64 data URL | new DataContent(bytes, “application/pdf”) |
| Declare the tool | Write the JSON schema by hand | AIFunctionFactory.Create(CheckSupplier) reads the method signature and [Description] attributes |
| Run the tool loop | Find function_call items, run them, send function_call_output, repeat | Automatic |
| Chain the turns | Send previous_response_id yourself | Automatic, with previous_response_id |
| Structured output | Write the schema, then deserialize | RunAsync<InvoiceReview>() builds the schema from the record and deserializes the answer |
| Token usage | Add it up across calls | response.Usage, already summed across the calls |
I checked each row against the requests the framework actually sent, using a local stand-in for the Responses API. The PDF goes out as an input_file with a data:application/pdf URL, the follow-up request carries previous_response_id, and the tool result comes back as a function_call_output. Those are the same requests the HttpClient version sends by hand.
The packages
You need fewer packages than you might expect:
|
1 2 3 4 5 6 7 |
dotnet new console -n GPT-6-1-Terra-AgentFramework-CSharp-Sample cd GPT-6-1-Terra-AgentFramework-CSharp-Sample dotnet add package Microsoft.Agents.AI.OpenAI dotnet add package Azure.Identity dotnet add package Microsoft.Extensions.Configuration.UserSecrets dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables dotnet user-secrets init |
Microsoft.Agents.AI.OpenAI brings in the OpenAI .NET SDK and Microsoft.Extensions.AI, so you don’t add them yourself.
Notice what’s missing: Azure.AI.OpenAI. Microsoft’s Agent Framework docs start from AzureOpenAIClient in that package, which is still a prerelease. When I tried it, Azure.AI.OpenAI 2.9.0-beta.1 compiled fine next to Microsoft.Agents.AI.OpenAI 1.26.0, then failed at startup with a MissingMethodException in GetResponsesClient(). The Agent Framework package pulls in a newer OpenAI SDK than the Azure beta was built for.
The fix is simpler than the problem. Azure OpenAI has a v1 endpoint that the plain OpenAI SDK talks to directly, and Microsoft’s Responses API docs use exactly that: a ResponsesClient pointed at https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1. No Azure-specific client, no beta package.
Store the settings the same way as the first post:
|
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” |
The API key is optional. Leave it out and run az login to use Entra ID, which is what you want in production.
The code
|
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 |
#pragma warning disable OPENAI001 // The OpenAI Responses types are marked experimental. using System.ClientModel; using System.ClientModel.Primitives; using System.ComponentModel; using System.Diagnostics; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using OpenAI.Responses; 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 OpenAI SDK talks to Azure through the v1 endpoint, so no Azure-specific client is needed. var clientOptions = new ResponsesClientOptions { Endpoint = new Uri($“{endpoint}/openai/v1”) }; // The API key is optional. Without one, the app signs in with Entra ID through az login. var apiKey = config[“AZURE_OPENAI_API_KEY”]; var responsesClient = string.IsNullOrWhiteSpace(apiKey) ? new ResponsesClient( authenticationPolicy: new BearerTokenPolicy(new DefaultAzureCredential(), “https://ai.azure.com/.default”), options: clientOptions) : new ResponsesClient( authenticationPolicy: ApiKeyAuthenticationPolicy.CreateHeaderApiKeyPolicy(new ApiKeyCredential(apiKey), “api-key”), options: clientOptions); // The agent: Terra on the Responses API, with instructions and one tool. AIAgent agent = responsesClient .AsAIAgent( model: deployment, name: “InvoiceReviewer”, instructions: “”“ You review supplier invoices for accounts payable. Read the attached invoice. Always call check_supplier with the supplier name exactly as printed. It requires approval if the total is over 5000, or if the supplier is not on file or not active. List anything a reviewer should look at as issues, such as math that doesn’t add up or a missing PO number. Use an empty list when there is nothing to flag. ““”, tools: [ // Name the tool yourself: a local function’s generated name looks like _Main_g_CheckSupplier_0_0. // “strict” asks the OpenAI adapter to send the tool schema in strict mode. AIFunctionFactory.Create(CheckSupplier, new AIFunctionFactoryOptions { Name = “check_supplier”, AdditionalProperties = new Dictionary<string, object?> { [“strict”] = true } }) ]); var pdfPath = args.Length > 0 ? args[0] : “invoice.pdf”; if (!File.Exists(pdfPath)) { throw new FileNotFoundException(“Run from the project folder, or pass the path to a PDF invoice.”, pdfPath); } // The PDF goes in as DataContent. The OpenAI adapter sends application/pdf as an input_file. var message = new ChatMessage(ChatRole.User, [ new DataContent(await File.ReadAllBytesAsync(pdfPath), “application/pdf”) { Name = Path.GetFileName(pdfPath) }, new TextContent(“Review this invoice.”) ]); // Reasoning effort has no ChatOptions property, so it goes on the raw Responses options. var options = new ChatClientAgentRunOptions(new ChatOptions { AdditionalProperties = new AdditionalPropertiesDictionary { [“strict”] = true }, RawRepresentationFactory = _ =>; new CreateResponseOptions { ReasoningOptions = new ResponseReasoningOptions { ReasoningEffortLevel = ResponseReasoningEffortLevel.Low } } }); var stopwatch = Stopwatch.StartNew(); // RunAsync<T> asks for structured output with a schema built from InvoiceReview, runs the tool calls, // and deserializes the final answer. AgentResponse<InvoiceReview> response = await agent.RunAsync<InvoiceReview>(message, options: options); stopwatch.Stop(); var review = response.Result; 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}”); } var usage = response.Usage; Console.WriteLine(); Console.WriteLine($“[terra] input={usage?.InputTokenCount} cached={usage?.CachedInputTokenCount} output={usage?.OutputTokenCount} reasoning={usage?.ReasoningTokenCount} elapsed={stopwatch.Elapsed.TotalSeconds:0.0}s”); // A stand-in for your ERP or vendor master. Replace it with a real lookup. // The framework builds the tool schema from the method signature and the Description attributes. [Description(“Looks up a supplier in the vendor master. Returns whether it exists, its ID and its status.”)] static SupplierLookup CheckSupplier([Description(“The supplier name as printed on the invoice.”)] 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 SupplierLookup(true, supplier.Id, supplier.Status) : new SupplierLookup(false, null, null); Console.WriteLine($“[tool] CheckSupplier(\”{supplierName}\”) -> {result}”); return result; } record SupplierLookup(bool Found, string? SupplierId, string? Status); // The C# shape of the structured output. The framework turns it into the JSON schema. record InvoiceReview( string SupplierName, bool SupplierOnFile, string InvoiceNumber, [property: Description(“ISO 8601 date, for example 2026-10-12.”)] string InvoiceDate, [property: Description(“Empty if the invoice has no PO number.”)] string PoNumber, [property: Description(“ISO 4217 code, for example USD.”)] string Currency, decimal Total, bool RequiresApproval, List<string> Issues); |
The project includes the same invoice.pdf as the first sample. Run it from the project folder, or pass the path to your own PDF:
|
1 |
dotnet run |
|
1 |
dotnet run — path/to/your-invoice.pdf |
Walking through it
The client. ResponsesClient takes an authentication policy and the v1 endpoint. For Entra ID, BearerTokenPolicy gets a token from DefaultAzureCredential for https://ai.azure.com/.default, the same scope the first post used. For an API key, ApiKeyAuthenticationPolicy.CreateHeaderApiKeyPolicy sends it in the api-key header that Azure expects.
The agent. AsAIAgent turns the client into an AIAgent with a model, a name, instructions and tools. model takes your deployment name, as it does in the raw API.
The PDF. A DataContent with the application/pdf media type becomes an input_file. Setting Name fills in the filename. Everything the first post said about PDFs still applies: the model sees the extracted text and an image of each page, and that costs more tokens than the text alone.
The tool. CheckSupplier is an ordinary C# method. AIFunctionFactory.Create builds the JSON schema from its parameters and [Description] attributes, and the framework calls the method when Terra asks for it. It can even return a record: SupplierLookup is serialized to JSON for the model.
The structured output. RunAsync<InvoiceReview> builds a JSON schema from the record, sends it as text.format, and deserializes the answer into response.Result. Descriptions on the record’s properties end up in the schema, which is how the model learns that the date should be ISO 8601.
Reasoning effort. ChatOptions has no property for it, so it goes on the raw Responses options through RawRepresentationFactory. The request shows up with “reasoning”: {“effort”: “low”}, the same as the first post.
Three settings to get right
These are the things I would have shipped wrong if I hadn’t logged the requests.
1. Name your tools. CheckSupplier is a local function in a top-level program, and C# compiles it to a method with a generated name. Left alone, the tool went out as _Main_g_CheckSupplier_0_0. The model can still call it, but your instructions say CheckSupplier, your logs say something else, and the name changes if you move the code. Set Name in AIFunctionFactoryOptions, as the sample does with check_supplier.
2. Turn on strict mode. By default, the tool went out with “strict”: null and the response schema had no strict flag at all. Without strict mode, the model is asked to follow the schema but not forced to. The OpenAI adapter in Microsoft.Extensions.AI reads a strict entry from additional properties: the sample sets it on the tool’s AIFunctionFactoryOptions and on the run’s ChatOptions, and with both in place, the tool and the InvoiceReview schema go out with “strict”: true.
3. Expect camelCase. The schema built from InvoiceReview uses camelCase names, such as supplierName and requiresApproval. The first post’s schema used snake_case. It doesn’t matter to the model, but it matters if you compare logs between the two versions, or if your prompt names the fields. I reworded the instructions so they describe the rules without naming fields.
Which one should you use?
Both send the same requests. The difference is who writes them.
| HttpClient | Agent Framework | |
|---|---|---|
| Lines of code | About 320 | 136 |
| Tool loop | You write it | Built in |
| Schema | You write it | Built from the record |
| What you can see | Every byte | What you log |
| New API features | Add the JSON yourself | Wait for the SDK, or use RawRepresentationFactory |
| Room to grow | You build it | Streaming, threads, middleware, tool approval, MCP tools |
Use HttpClient when you’re learning a new model, debugging a request that fails, or using a feature the SDK doesn’t support yet. Use Agent Framework when you’re building an app: once you add a second tool, a human approval step or a streaming UI, the hand-written version stops being short.
My own rule is to start a new model with raw requests until I know what it accepts, then move to the framework. That’s the order of these two posts.
What to check before you ship
- Run it against your deployment. I tested the request shapes against a local stand-in for the Responses API, not against Terra. Check that strict structured output and the tool call work together on your deployment.
- Pin your package versions. The Agent Framework packages move fast. Pin them, and update on purpose.
- Log the requests while you build. The three settings above only showed up because I looked at the JSON. Add HTTP logging in development, and turn it off before you ship, because the requests include the whole PDF.
Your output will look like this. The numbers depend on your deployment and your region:
|
1 2 3 4 5 6 7 8 9 |
[tool] CheckSupplier(“Fabrikam Office Supplies”) -> SupplierLookup { Found = True, SupplierId = 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: The document identifies itself as a sample for a code demo and says the companies are fictitious. [terra] input=4292 cached=2113 output=134 reasoning=20 elapsed=4.8s |

Leave a Reply