GPT-6.1 Terra with Microsoft Agent Framework: A PDF Invoice Agent in C#

GPT-6.1-Terra-for-CSharp-With-Microsoft-Agent-Framework

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:

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:

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

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:

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:

Resources

Taswar Bhatti Avatar

Written by