You do not need a full agent framework to let a .NET chat application edit Word documents. If your model supports function calling, you can project OfficeAgent.NET directly as Microsoft.Extensions.AI tools on an IChatClient.
The small host below registers one document, gives the model four bounded tools, and retrieves the saved .docx by the id returned from apply_plan. Unlike OfficeAgent's broader AgentEdit sample, this path uses no ChatClientAgent. Its specific purpose is to show direct IChatClient integration and host-side capture of the output id.
What you need before you start
- .NET 8 or later
- A synthetic Word document at
./contracts/contract.docxcontaining the exact sentenceInvoices are payable within 60 days of receipt. - An Azure OpenAI deployment with a model that supports function calling
-
AZURE_OPENAI_ENDPOINTandAZURE_OPENAI_DEPLOYMENTenvironment variables - Azure CLI login with
az login - The identity selected by
DefaultAzureCredentialmust have theCognitive Services OpenAI Userrole on the Azure OpenAI resource
Add the packages:
mkdir OfficeAgentChat
cd OfficeAgentChat
dotnet new console --framework net8.0
mkdir contracts
dotnet add package OfficeAgent.Core --version 0.2.1
dotnet add package OfficeAgent.Word --version 0.2.1
dotnet add package OfficeAgent.AgentFramework --version 0.2.1
dotnet add package Azure.AI.OpenAI --version 2.1.0
dotnet add package Azure.Identity --version 1.21.0
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.8.1
dotnet add package Microsoft.Extensions.DependencyInjection --version 10.0.10
While still inside OfficeAgentChat, create ./contracts/contract.docx with the exact synthetic sentence from the prerequisites. All relative paths below are resolved from this project directory.
Despite its package name, OfficeAgent.AgentFramework exposes ordinary AIFunction objects. The following example uses no ChatClientAgent.
Compose OfficeAgent and register the document
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using OfficeAgent.Abstractions;
using OfficeAgent.AgentFramework;
using OfficeAgent.Core;
using OfficeAgent.Core.DocumentProviders;
using OfficeAgent.Word;
string storageRoot = Path.GetFullPath("./contracts");
using ServiceProvider services = new ServiceCollection()
.AddWordFormat()
.AddFileSystemDocumentProvider("contracts", storageRoot)
.AddOfficeAgent()
.BuildServiceProvider();
OfficeAgentClient office = services.GetRequiredService<OfficeAgentClient>();
DocumentReference source = await office.RegisterAsync(
"contracts",
Path.Combine(storageRoot, "contract.docx"));
AIFunction[] tools = new OfficeAgentTools(office).AsAIFunctions();
The host registers the path. For initial addressing, the model receives the connection name and opaque document id rather than a path or credential. The inspection and find tools then return the document text and structure needed to plan the edit.
Add function invocation to the IChatClient
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("Set AZURE_OPENAI_ENDPOINT.");
string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("Set AZURE_OPENAI_DEPLOYMENT.");
string? outputDocumentId = null;
string? lastApplyResult = null;
IChatClient chat = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deployment)
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation(configure: invoker =>
{
invoker.FunctionInvoker = async (context, cancellationToken) =>
{
Console.WriteLine($"tool: {context.Function.Name}");
object? rawResult = await context.Function.InvokeAsync(
context.Arguments,
cancellationToken);
object? result = NormalizeToolResult(rawResult);
if (context.Function.Name == "apply_plan"
&& result is JsonElement resultRoot)
{
lastApplyResult = resultRoot.GetRawText();
if (resultRoot.TryGetProperty(
"committed",
out JsonElement committed)
&& committed.GetBoolean()
&& resultRoot.TryGetProperty(
"outputDocumentId",
out JsonElement outputId))
{
outputDocumentId = outputId.GetString();
}
}
return result;
};
})
.Build();
static object? NormalizeToolResult(object? result)
{
if (result is JsonElement element)
{
if (element.ValueKind == JsonValueKind.String
&& element.GetString() is string json)
{
using JsonDocument parsed = JsonDocument.Parse(json);
return parsed.RootElement.Clone();
}
return element;
}
if (result is string jsonText)
{
using JsonDocument parsed = JsonDocument.Parse(jsonText);
return parsed.RootElement.Clone();
}
return result;
}
UseFunctionInvocation() is essential. It executes the model's requested AIFunction, returns the structured result to the model, and repeats until the model produces a final answer. The small hook records the tool order and keeps the saved document id in application code instead of relying on the model to copy it into prose. NormalizeToolResult handles the two JSON shapes the
function adapter can surface: a JSON object or a JSON string, so the model sees a structured result and the host can reliably read outputDocumentId.
This collector is deliberately local to one console run. Do not attach a shared closure like this to a singleton client serving concurrent conversations. Create request-scoped state, or correlate captured tool results to the conversation or request id.
Make one tracked edit
string instructions = $"""
You are editing connectionId=contracts,
documentId={source.ItemId}.
{OfficeAgentTools.SystemPromptGuidance}
Call inspect_document before planning. Use find_in_document when needed
to locate the exact target. Preview before applying.
When applying, use saveMode NewVersion.
""";
var messages = new List<ChatMessage>
{
new(ChatRole.System, instructions),
new(
ChatRole.User,
"Change '60 days' to '30 days' as a tracked change. " +
"Make no other edits.")
};
var options = new ChatOptions
{
Tools = tools.Cast<AITool>().ToList(),
AllowMultipleToolCalls = false,
MaxOutputTokens = 1200
};
ChatResponse response = await chat.GetResponseAsync(messages, options);
Console.WriteLine(response.Text);
if (outputDocumentId is null)
throw new InvalidOperationException(
lastApplyResult is null
? "The model did not call apply_plan."
: $"apply_plan did not commit: {lastApplyResult}");
The safety sequence is discovery, preview, then apply. Discovery starts with inspect_document; the model can also call find_in_document when it needs a specific anchor. OfficeAgent's plan validation protects the document, but the model still decides which discovery tools to request. Keep its instruction narrow and log the tool calls in production.
Retrieve the result
using var saved = await office.OpenReadAsync(
DocumentReference.ForFileSystem("contracts", outputDocumentId));
await using var output = File.Create("reviewed-contract.docx");
await saved.Stream.CopyToAsync(output);
Console.WriteLine($"Saved reviewed-contract.docx from {outputDocumentId}");
The .docx bytes never pass through the model. Your host retrieves them from the configured provider and can return them through its normal download or attachment channel.
For this synthetic console exercise, printing the complete apply_plan result is useful when a run fails. In production, log its error codes and correlation data without exposing document content.
This is the useful boundary: IChatClient handles the conversation, OfficeAgent.NET handles verified Word operations, and your application retains control of storage and delivery. Microsoft documents the generic function calling loop in AI tool calling for .NET; Microsoft's Azure OpenAI authentication guidance explains the DefaultAzureCredential and RBAC requirement. The complete blocks above form one Program.cs in order. The broader OfficeAgent workflow is in the agent-integration guide. A complete runnable version of this exact host is available in the IChatClient Word-editing sample.
To try it, create the console project and synthetic document above, paste the ordered code blocks into Program.cs, and run dotnet run. Keep this first run single-request and confirm the trace ends with apply_plan.
Top comments (0)