As AI continues to integrate into Business Central, we’re seeing exciting new possibilities unfold. Not only are built-in AI features transforming workflows, but we also have the tools to design our own AI-powered processes. While adopting new technology can feel overwhelming, let’s take on this challenge together and build something practical.
One useful AI-driven solution is automating customer address updates. Imagine simply pasting plain text into a box and letting AI intelligently extract the key address elements, seamlessly populating the relevant fields in Business Central. Let’s explore how to make this happen.
In order to make this happen we are going to need to implement a few things.
- We need an Azure AI Capacity
- BC Management components for the Azure AI Capacity
- BC Customer Extension to utilize an AI process
- The AI Processor that does the work in BC
Note, that even as I write this, things are changing. Steps 1 and 2 may become unnecessary as AI capacity becomes a managed feature inside Business Central.
We are going to need an Azure Subscription. A Pay-as-you-Go subscription or some student/partner credits will be needed. Running the AI Capacity will cost a few dollars.
Log into the Azure Portal and click on Create a resource.

We want to add an “Azure OpenAI” resource.

Click on the box then Click the “Create” button.

First, I’m going to select my Subscription named “Development-MPC”. This one is associated with some Azure credits.
Next, I’m going to Create a Resource Group called “BCAIDemo”.

We will also need a region; “East US” for me. Provide it with a name and we will be using the Pricing tier of “Standard S0”.

Once the form is all filled out, click “Next”.
We will need to specify what networks can connect to this resource. We are going to allow for “All networks”.

Click “Next”. We aren’t applying tags, so click “Next” again.

Once everything is done validating, click Create

When everything is done, you should be able to click on a “Go to resource” button.

You should see this screen; click on the resource we’ve created.

Next, we will click on the “Explore Azure AI Foundry portal” button.

We have a whole new screen now!
With “Chat” selected in the left, select “Create new deployment”, then select “From base models”.

I’m going to find “gpt-4o” and select it from the list.

I’ll then click “Confirm”.

You can set a Deployment name here. We are sticking with “Global Standard” for this deployment type, click “Deploy”. There are lots of important things on this screen, and we will discuss them at a later date when we review cost control for AI.
We should end up back where we started now. On the right hand side, click on “Deployments”, then the model we just deployed.

This screen is the last step and contains some very important information.

Critical here is your End Point, Deployment, and API Key.
NEVER SHARE THE API KEY!
Keeping the API key secret is so critical; you don’t even get to see it on the screen by accident. You have to click the copy button to the right of the key field to put it into your clipboard.
In my deployment I’m going to note the following details:
Endpoint: https://bcaitraining.openai.azure.com/
Deployment: gpt-4o
Key: <not going to paste that here>
With that in hand, we can now write some Business Central AL Code!
The first thing we are going to need is a safe place to store these OpenAI details. We could save them in a table, but that would make them accessible to others, and we need to protect these values, they could cost us a lot of money if they get out of our control.
Business Central has something called Isolated Storage. This is storage that is only accessible to the extension that created it. This allows us to store things like secrets and keys without risk of them being accessed outside the extension we are creating.
To make it easy to manage our isolated storage, we can create a code unit for Isolated Storage Management. Isolated Storage uses a Key/Value pair for the Get and Set process. If I want to store a value I can do something like:
IsolatedStorage.Set('Secret Key', 'Secret Key Value');
To get the value out of Isolated Storage I would use a Get Command and a variable.
var
SecretValue: Text;
IsolatedStorage.Get('Secret Key', SecretValue);
This will load the value of Secret Key into the variable SecretValue.
To make life even easier, and prevent a typo from breaking everything we can store the names of the keys as labels. For our implementation we have 3 labels for the three values, and three get/set pairs to facilitate the getting and setting actions.
Here is my Code Unit ARDIsolatedStorageWrapper.CodeUnit.al.
codeunit 50002 ARD_IsolatedStorageWrapper
{
SingleInstance = true;
Access = Internal;
var
IsolatedStorageSecretKeyKey: Label 'CopilotSecret', Locked = true;
IsolatedStorageDeploymentKey: Label 'CopilotDeployment', Locked = true;
IsolatedStorageEndpointKey: Label 'CopilotEndpoint', Locked = true;
procedure GetSecretKey() SecretKey: Text
begin
if IsolatedStorage.Get(IsolatedStorageSecretKeyKey, SecretKey) = false then SecretKey := '';
end;
procedure GetDeployment() Deployment: Text
begin
if IsolatedStorage.Get(IsolatedStorageDeploymentKey, Deployment) = false then Deployment := '';
end;
procedure GetEndpoint() Endpoint: Text
begin
if IsolatedStorage.Get(IsolatedStorageEndpointKey, Endpoint) = false then Endpoint := '';
end;
procedure SetSecretKey(SecretKey: Text)
begin
IsolatedStorage.Set(IsolatedStorageSecretKeyKey, SecretKey);
end;
procedure SetDeployment(Deployment: Text)
begin
IsolatedStorage.Set(IsolatedStorageDeploymentKey, Deployment);
end;
procedure SetEndpoint(Endpoint: Text)
begin
IsolatedStorage.Set(IsolatedStorageEndpointKey, Endpoint);
end;
}
Note that line 4 makes this entire code unit “Internal”. This will prevent anyone from using it to gain access to the isolated storage. Back in BC AL Journey #12 we discussed Scope and how it can restrict access to objects in Business Central AL. In this case, we are restricting access to this extension only.
We will need to support this with a Settings page. I’ve named mine ARD_CopilotSettings.
page 50000 ARD_CopilotSettings
{
ApplicationArea = All;
Caption = 'AardvarkLabs Copilot Settings';
PageType = Card;
UsageCategory = Administration;
SourceTable = "Integer";
layout
{
area(Content)
{
group(General)
{
Caption = 'General';
field(Endpoint; Endpoint)
{
ApplicationArea = All;
Caption = 'Endpoint';
ToolTip = 'Enter the endpoint URL for the Copilot service.';
trigger OnValidate()
begin
IsolatedStorageWrapper.SetEndpoint(Endpoint);
end;
}
field(Deployment; Deployment)
{
ApplicationArea = All;
Caption = 'Deployment';
ToolTip = 'Enter the deployment name for the Copilot service.';
trigger OnValidate()
begin
IsolatedStorageWrapper.SetDeployment(Deployment);
end;
}
field(APIKey; UserAPIKey)
{
ApplicationArea = All;
Caption = 'API Key';
ToolTip = 'Enter the API key for the Copilot service.';
trigger OnValidate()
begin
IsolatedStorageWrapper.SetSecretKey(UserAPIKey);
end;
}
}
}
}
var
Endpoint: Text;
Deployment: Text;
UserAPIKey: Text;
IsolatedStorageWrapper: Codeunit ARD_IsolatedStorageWrapper;
trigger OnAfterGetRecord()
begin
UserAPIKey := '*****';
GetSettings();
end;
local procedure GetSettings()
begin
Deployment := IsolatedStorageWrapper.GetDeployment();
Endpoint := IsolatedStorageWrapper.GetEndpoint();
end;
}
This is a very simple page where, when it loads the Endpoint and Deployment values are retrieved from Isolated Storage and displayed in fields. When a fields validate is triggered, we Set the Isolated Storage values.
Note, the API Key is never retrieved, once saved, we NEVER speak of it again to the user. We don’t ever want this getting out. We can add this page to the assisted setup if we want, but for now we can find it with the magnifying glass and typing “AardvarkLabs Copilot Settings”.
We can run the system now and fill out the form with the settings from our Azure OpenAI configuration.

The next component we are going to need is to extend the Copilot Capability enumerator to include the feature we are adding. I created a file ARDCopilot.EnumExt.al and the content looks like this.
enumextension 50000 ARD_Copilot extends "Copilot Capability"
{
value(50000; "Customer Detail")
{
Caption = 'Customer Detail';
}
}
We need this so that we can register the capability in another code unit. I created a code unit called ARDCopilotSetup.CoideUnit.al.
codeunit 50000 ARD_CopilotSetup
{
Subtype = Install;
InherentEntitlements = X;
InherentPermissions = X;
Access = Internal;
trigger OnInstallAppPerDatabase()
begin
RegisterCapability();
end;
local procedure RegisterCapability()
var
EnvironmentInfo: Codeunit "Environment Information";
CopilotCapability: Codeunit "Copilot Capability";
LearnMoreUrlTxt: Label 'https://aardvarklabs.blog', Locked = true;
begin
// Verify that environment in a Business Central online environment
if EnvironmentInfo.IsSaaSInfrastructure() then
// Register capability
if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"Customer Detail") then
CopilotCapability.RegisterCapability(
Enum::"Copilot Capability"::"Customer Detail",
Enum::"Copilot Availability"::Preview, LearnMoreUrlTxt);
end;
}
This code unit run on installation of the Extension and adds the Copilot capabilities to Business Central. This adds our offering to the “Copilot & agent capabilities” management page so that it can be turned off if it isn’t wanted.

Now we are going to start building our Copilot job. There are a few things we need to think about when we start designing the job. A Copilot, or any GPT system, has two prompts, one is provided by the user, the other is engineered by the developers.
The user prompt is the data that is provided by the user during interaction with the AI system. We can help the user enter good prompts, we can seed the user prompt with additional information, and we can verify that the user entered good data.
The System Prompt is what we provide to the AI to know what we expect of it. This is the “language” of GPT agents, it is how we set and tune the behavior of the system to get the results we want. Here is my system prompt:
| The user will provide details about a customer address. Your task is to find the best address for that customer. The output should be a JSON object with the following fields: addressline1, addressline2, city, state, country, postalCode, phone, email, formatted. Do not use line breaks or other special characters in addressline1, addressline2, city, state, country, postalCode, phone, email. Skip empty nodes. |
Here we can see that I’m setting the expectation of the user data, the GPT agents task, and what I’m expecting the output format to be. We are asking the agent to create JSON because as a computer program JSON is easy to work with.
We feed the User Prompt and System Prompt into the agent, and parse the results.
Here is the chat processor procedure from the larger ARDCopilotJob.CodeUnit.al file.
procedure Chat(ChatSystemPrompt: Text; ChatUserPrompt: Text): Text
var
AzureOpenAI: Codeunit "Azure OpenAI";
EnvironmentInformation: Codeunit "Environment Information";
AOAIOperationResponse: Codeunit "AOAI Operation Response";
AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params";
AOAIChatMessages: Codeunit "AOAI Chat Messages";
AOAIDeployments: Codeunit "AOAI Deployments";
IsolatedStorageWrapper: Codeunit ARD_IsolatedStorageWrapper;
Result: Text;
EntityTextModuleInfo: ModuleInfo;
begin
// Set up Azure OpenAI authorization using isolated storage values
AzureOpenAI.SetAuthorization(Enum::"AOAI Model Type"::"Chat Completions",
IsolatedStorageWrapper.GetEndpoint(), IsolatedStorageWrapper.GetDeployment(), IsolatedStorageWrapper.GetSecretKey());
// Set the Copilot capability for customer detail processing
AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"Customer Detail");
// Configure chat completion parameters
AOAIChatCompletionParams.SetMaxTokens(2500); // Set maximum tokens for the response
AOAIChatCompletionParams.SetTemperature(0); // Set temperature for deterministic responses
AOAIChatCompletionParams.SetJsonMode(true); // Enable JSON mode for structured responses
// Add system and user messages to the chat
AOAIChatMessages.AddSystemMessage(ChatSystemPrompt); // Add the system prompt
AOAIChatMessages.AddUserMessage(ChatUserPrompt); // Add the user prompt
// Generate the chat completion using Azure OpenAI
AzureOpenAI.GenerateChatCompletion(AOAIChatMessages, AOAIChatCompletionParams, AOAIOperationResponse);
// Check if the operation was successful and return the result
if AOAIOperationResponse.IsSuccess() then
Result := AOAIChatMessages.GetLastMessage() // Retrieve the last message from the chat
else
Error(AOAIOperationResponse.GetError()); // Handle errors by raising an error with the response message
exit(Result); // Return the result of the chat completion
end;
Line 21 sets the maximum number of tokens in the response. A token is the unit of measure for GPT work. 1 token is about 4 characters or ¾ words. 100 tokens are about 75 words. Different GPT Models token things differently. The bottom line, we get charged by the token.
Line 22 sets the temperature, which is a measure of how “creative” the GPT model is allowed to get. We use 0 when we want no creativity, like this parsing exercise. If we wanted to describe something, we would use a higher number to allow for more creative expressions. The max value is 2, but as a decimal you can move in small increments.
Line 23 tells the system that we are expecting a response in JSON. We are a computer; we want a computer compatible response.
When this runs, the results of the GPT process is a JSON text of the address based on the user input. There is a routine in the Code Unit that turns it into a Dictionary for easy handling.
We are going to need a means to prompt the user for input. There is a special page type just for Copilot prompting called the “Prompt Dialog” page. There are many different things we can do in a Prompt Dialog, suggest inputs, offer output reviews, drive user selections, but in this case, we are going to keep it very simple; an open space for text.
I created a file named ARDAddressPrompt.Page.AL. When the user clicks the Generate button, we run the following code.
var
GenerateAddress: Codeunit ARD_CopilotJob;
ChatRequest: Text;
AddressDict: Dictionary of [Text, Text];
/// <summary>
/// Executes the address generation process by interacting with the GenerateAddress object.
/// Updates the page caption, sets the user prompt, and attempts to generate an address
/// up to a maximum of 5 times. If successful, displays the result; otherwise, shows an error message.
/// </summary>
/// <remarks>
/// The procedure uses a loop to retry the address generation process if the result is empty.
/// It ensures that the process does not exceed 5 attempts to prevent infinite loops.
/// </remarks>
/// <exception cref="Error">
/// Throws an error if the maximum number of attempts is reached without a successful result.
/// </exception>
local procedure RunGeneration()
var
InStr: InStream;
Attempts: Integer;
begin
CurrPage.Caption := ChatRequest;
GenerateAddress.SetUserPrompt(ChatRequest);
Attempts := 0;
while (AddressDict.Count() = 0) AND (Attempts < 5) do begin
if GenerateAddress.Run() then
AddressDict := GenerateAddress.GetResult();
Attempts += 1;
end;
if (Attempts < 5) then begin
message(GenerateAddress.GetCompletionResult());
end else
Error('Something went wrong. Please try again. ' + GetLastErrorText());
end;
Line 24 feeds the user prompt into our code unit. Then we loop up to 5 times running the GPT process. It may not respond the first time, so it is best practice to give it a few tries if it is unresponsive.
That last bit before we can run our agent, we need to add a button to the Customer card. I extended the customer card in a file named ARDCustomerCard.PageExt.al.
pageextension 50000 ARD_CustomerCard extends "Customer Card"
{
actions
{
addbefore(Email_Promoted)
{
actionref(GenerateAddress_Pro; GenerateAddress){}
}
addbefore(Email)
{
action(GenerateAddress)
{
ApplicationArea = All;
Caption = 'Generate Address';
ToolTip = 'Generate address using Dynamics 365 Copilot.';
Image = Sparkle;
trigger OnAction()
var
AddressPrompt: Page "ARD_AddressPrompt";
AddressDict: Dictionary of [Text, Text];
begin
// Open the address prompt page when the action is clicked
if AddressPrompt.RunModal() = Action::OK then begin
AddressDict := AddressPrompt.GetResult();
if AddressDict.Count() > 0 then begin
// Loop through the dictionary and set the address fields
if AddressDict.ContainsKey('addressline1') then Rec."Address" := AddressDict.Get('addressline1');
if AddressDict.ContainsKey('addressline2') then Rec."Address 2" := AddressDict.Get('addressline2');
if AddressDict.ContainsKey('city') then Rec."City" := AddressDict.Get('city');
if AddressDict.ContainsKey('state') then Rec."Country/Region Code" := AddressDict.Get('state');
if AddressDict.ContainsKey('postalcode') then Rec."Post Code" := AddressDict.Get('postalcode');
if AddressDict.ContainsKey('phone') then Rec."Phone No." := AddressDict.Get('phone');
if AddressDict.ContainsKey('email') then Rec."E-Mail" := AddressDict.Get('email');
end;
end;
end;
}
}
}
}
We add the button, and in the action we call up our Address Prompt page, and if the user clicks okay, we pull the important information out of the Dictionary.
Here is how it looks in action on a Customer Card.
First off we can see our new “Generate Address” button.

Clicking the button displays the Address Prompt page, where I’ll enter an address for the system to break down for me.

When I click Generate, it displays the updated address with the option to confirm, Regenerate, or Discard it.

Clicking Confirm processes the address into the Customer card.

That’s it, our first Copilot agent is complete! If it is not producing the results you expect, adjust the system prompt with a more detailed explanation of your needs and goals. Try adding a “please” in there, it actually helps.
You can download the complete extension from my GitHub. This example was adapted from examples available in the Microsoft/BCTech GitHub.
Thank you for joining me on this first Copilot project. Do you have any ideas of agents you want to create? Problems this can solve? We will revisit this later with more involved agents and processes.





Leave a comment