All Around Microsoft Dynamics Business Central
In the first BusinessCraft post, I said the next step would be the hard part: the service tier.
A few days later, craft-server can run two complete AL apps. You can publish them, sign in, open the role center, click through the pages, enter data, run actions, and see FlowFields update.
At first, I thought the way it got there was not what I asked for. That turned into an interesting discussion with the agent.
I wrote two small apps myself. They are intentionally simple, but they cover the typical things a real extension does.
BusinessCraftBaseApp
My Customer with an OnValidate trigger on NameMy Customer Cue with a count FlowFieldMy Customers with a page variable, an action, OnOpenPage, and OnAfterGetRecordMy Customer Activities with a cue group and drill-downBusiness Craft Demo RC with the activities part and an embedded actionGetDefaultRoleCenterIDCustomer Level (extension that depends on the base app)
Gold Level field and three table procedureswhere clauseSayHelloGold Customers tile to the activities partHere is one of the extension procedures that now runs in BusinessCraft:
internal procedure SayGoodBye()
var
MyCustomer: Record "My Customer";
GoodByeLbl: Label 'Goodbye, %1!', Comment = '%1 - Customer Name';
begin
MyCustomer.SetFilter(Name, 'T*');
MyCustomer.SetRange("Gold Level", true);
if not MyCustomer.FindSet() then
exit;
repeat
Message(GoodByeLbl, MyCustomer.Name);
until MyCustomer.Next() = 0;
end;
And here is the FlowField from the extension that powers the gold customer tile:
field(50200; "Gold Customers"; Integer)
{
FieldClass = FlowField;
CalcFormula = count("My Customer" where("Gold Level" = const(true)));
}
Both apps run end to end. There is also an integration test (run_base_app.rs) that publishes them, opens the role center, inserts customers, and checks that the gold customer tile counts only gold customers.
This feels very close to working with BC. You publish an app, create a company and a user, and start the web server:
# publish the base app and the extension (.app with source, or a folder with app.json)
craft-server --database sqlite://data/craft.db --app BaseApp
craft-server --database sqlite://data/craft.db --app LevelApp
# create a company and a user
craft-server --database sqlite://data/craft.db --company "My Company" --user admin --password admin
# serve the published apps to the web client
craft-web --backend server --database sqlite://data/craft.db
The web client from the first post now has a fourth backend: --backend server. That means the same Vue client can talk to the mock tier, the original BC web server, the original BC NST, or BusinessCraft's own service tier.
A few details are worth calling out:
Company$Table$AppId pattern.SystemId, SystemCreatedAt, SystemCreatedBy, SystemModifiedAt, and SystemModifiedBy. The By fields are stamped with the signed-in user's SystemId.User table, and application data is stored per company.This is the part I find most interesting.
My instruction was clear: do not interpret AL. Translate it into something compiled, either Rust or an intermediate form, similar to how Business Central compiles AL into C# and then runs it on .NET.
When I looked at the code, the agent seemed to have done something else.
It built a tree-walking interpreter. AL is effectively treated as a scripting language:
craft-al parses the AL source into a syntax tree of statements (AlStmt) and expressions (AlExpr).craft-mock's Runner walks that tree statement by statement.You can see that very clearly in the code:
false.repeat ... until has a hard limit. Loops stop after 10,000 iterations to prevent endless loops.My first reaction was: this is not what I asked for, and it is not how BC works.
So I confronted the agent and gave it Stefan Maron's post AL Runner: Run AL Unit Tests Without a BC Service Tier.
AL Runner takes the opposite approach. It calls the public API of the BC compiler (Compilation.Emit()) to transpile AL into C#. It then rewrites the BC runtime types with in-memory mocks, compiles the result with Roslyn against the BC service tier DLLs, and runs the tests. That way, thousands of AL tests run in seconds without a container.
That is exactly the compile-first approach I had in mind.
The agent's answer was more nuanced than I expected. It had not ignored my wish. It had made a deliberate trade-off:
if statements or assignments. A small interpreter in Rust is fast compared to the SQL round trips around it.craft-al), metadata (craft-metadata), and execution (Runner) are separate. If the interpreter becomes a bottleneck, the execution layer can be replaced with a compiler backend later, while the parser and metadata model stay the same.I have to admit that this is a good argument.
The interpreter got two apps running in a few days, and every unsupported feature fails immediately with a clear error and source position.
The open question is the type system. "Everything is text" will not hold up for Decimal, Date, Code[20], or Option/Enum. A proper interpreter needs typed values, regardless of whether a compiler comes later.
This is the current supported subset of AL in craft-server.
| Object | Supported |
|---|---|
table |
Fields, keys, Caption, ToolTip, DrillDownPageId, field OnValidate |
tableextension |
New fields and procedures |
page |
Layout, actions, page variables, triggers |
pageextension |
addafter for fields and actions |
codeunit |
Event subscribers |
profile |
RoleCenter, caption, description |
Enums, interfaces, queries, reports, XMLports, and permission sets are not supported yet.
The compiler accepts RoleCenter, List, Card, Document, Worksheet, ListPart, and CardPart.
The two apps exercise RoleCenter, List, and CardPart.
group, repeater, cuegroup, partfield bound to Rec."Field" or to a page variableDrillDownPageId, or through the source table's DrillDownPageIdaction with RunObject = page ... or an OnAction triggerSourceTable, UsageCategory, Editable, InsertAllowed, ModifyAllowed, DeleteAllowedCalcFormula = count(...) and sum(...)where clauses with const(...), filter(...), and field(...)| Trigger | Where |
|---|---|
OnOpenPage |
Page |
OnAfterGetRecord |
Page |
OnAfterGetCurrRecord |
Page |
OnAction |
Page action, including actions from page extensions |
OnValidate |
Table field. Error is shown as a dialog and the value is kept |
GetDefaultRoleCenterID |
Event subscriber on System Action Triggers. This is currently the only event the service tier raises |
Table procedures can be called from page actions, for example Rec.SayGoodBye(), including procedures defined in a table extension of another app.
begin ... endif ... then ... elserepeat ... untilexitVariable := ...Rec."Field" := ...Label variablesRec."Field"= and notEnum::ValueFindSet() or Next() = 0MessageErrorStrSubstNoThese support %1, %2, and so on, including labels.
This is the largest area. The runtime handles about 80 record methods:
| Area | Methods |
|---|---|
| Read | Get, GetBySystemId, Find, FindFirst, FindLast, FindSet, Next, IsEmpty, Count, CountApprox |
| Write | Init, Insert, Modify, ModifyAll, Delete, DeleteAll, Rename, Validate, TransferFields, Truncate |
| Filters | SetRange, SetFilter, Reset, GetFilter, GetFilters, HasFilter, FilterGroup, CopyFilter, CopyFilters, SetRecFilter, GetRangeMin, GetRangeMax, SetView, GetView |
| Keys and sorting | SetCurrentKey, CurrentKey, Ascending, SetAscending, GetAscending |
| FlowFields | CalcFields, CalcSums, SetAutoCalcFields |
| Partial records | SetLoadFields, AddLoadFields, LoadFields, AreFieldsLoaded, SetBaseLoadFields |
| Validation | TestField, FieldError |
| Marks | Mark, MarkedOnly, ClearMarks |
| Metadata | TableName, TableCaption, FieldName, FieldCaption, FieldNo, FieldActive, FullyQualifiedName, RecordId, IsTemporary, IsDirty, Relation |
| Position | GetPosition, SetPosition |
| Company | ChangeCompany, CurrentCompany |
| Transactions and isolation | LockTable, ReadIsolation, ReadConsistency, Consistent, RecordLevelLocking |
| Permissions and security | ReadPermission, WritePermission, SetPermissionFilter, SecurityFiltering |
| Links | AddLink, DeleteLink, DeleteLinks, HasLinks, CopyLinks |
| Other | Copy |
Temporary records (Record "X" temporary) are supported too.
The list of missing features is still longer than the list of working ones:
= (<>, <, >, <=, >=)and, or, and arithmetic (+, -, *, /)while, for, and caseConfirm, StrMenu, and suspending a call while waiting for the userRun, events other than GetDefaultRoleCenterID, permissions, and number seriesSo no, BusinessCraft cannot run the Base App yet. Not even close.
But it can run real AL apps with tables, extensions, FlowFields, triggers, and actions.
As mentioned in the first post, I am using Cursor for BusinessCraft. I wanted to give it a proper try. Most people on our team use Claude or OpenAI privately, so I thought I would test the last big harness as well.
Unfortunately, the web client used up my budget for third-party models. Until it resets next week, the only model left for me is Grok 4.7.
My plan was to just wait. According to Reddit, Grok is a model straight from hell. I expected nothing and tried it anyway.
The result is surprisingly good.
Maybe that is because of the structure Opus 5.5 had already put in place. The crates, the metadata model, and the conventions were there. Grok mostly had to implement technical specifications within that frame, not design the system from scratch.
Either way, it reminded me of something: you have to test a lot of things yourself, no matter what benchmarks or Reddit say.
The plan is now:
case, while, for, Confirm with suspension, codeunits, and enums.The agent bet on "fast enough". I am curious whether it is right.
I'll keep you updated. :)
BusinessCraft is not affiliated with or endorsed by Microsoft. "Microsoft", "Dynamics 365" and "Business Central" are trademarks of Microsoft Corporation.