DEV Community

Cover image for Add Word-Editing Tools to a .NET IChatClient with OfficeAgent.NET
Ilia Sokolov
Ilia Sokolov

Posted on

Add Word-Editing Tools to a .NET IChatClient with OfficeAgent.NET

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.docx containing the exact sentence Invoices are payable within 60 days of receipt.
  • An Azure OpenAI deployment with a model that supports function calling
  • AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT environment variables
  • Azure CLI login with az login
  • The identity selected by DefaultAzureCredential must have the Cognitive Services OpenAI User role 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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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)