agentleFS
emgreppiemgreppi/Business-Central-AI-Skill/creating-bc-custom-api/SKILL.md

creating-bc-custom-api

Skill1 starsChanged 9 months agoMITView on GitHub

Creates custom API pages (CRUD) and API queries (read-only joins) in AL for Business Central. Use when exposing custom tables, adding business logic to APIs, creating reporting endpoints, or joining multiple tables for external consumption.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Final: All CRUD operations work (GET 200, POST 201, PATCH 200, DELETE 204)

    line 24
  2. Very specific:

    Add ?$schemaversion=1.0 for old behavior.

    line 235
  3. Distinctive:

    Alphanumeric only: - no special characters in field names

    line 262

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Skill: Creating BC Custom APIs

Key Concept

TypeObjectOperationsUse Case
API PagePageType = APICRUD (Read-Write)Expose single table
API QueryQueryType = APIRead-onlyJoin multiple tables

For consuming Microsoft standard APIs, use al-standard-api skill instead.

Validation Gates

  1. After Step 2: API page compiles, GET returns 200 with fields
  2. After Step 3: Subpage works, navigation property expands correctly
  3. Final: All CRUD operations work (GET 200, POST 201, PATCH 200, DELETE 204)

Note: OData UI endpoints deprecated BC30 (2027). Always use API pages.

Placeholder Reference

PlaceholderDescriptionExample
<ID>Object ID from app.json idRanges50100
<PREFIX>Company prefix from app.jsonSL, ABC
<SourceTable>Source table name"Rental Machine"
<entityName>Singular entity name (camelCase)rentalMachine
<entitySetName>Plural entity name (camelCase)rentalMachines
<publisher>APIPublisher valuecontoso
<group>APIGroup valuerental
<KeyField>Primary key field"No."
<Field1>, <Field2>Table fields to expose"Description", "Status"

Procedure

Step 1: Decide API Type

Use API Page when:

  • Need CRUD operations (Create, Read, Update, Delete)
  • Exposing a single table
  • Need to execute business logic on insert/modify

Use API Query when:

  • Read-only access sufficient
  • Need to join multiple tables
  • Reporting/analytics endpoint

Step 2: Create API Page (CRUD)

page <ID> "<PREFIX> API <EntityName>"
{
    APIGroup = '<group>';
    APIPublisher = '<publisher>';
    APIVersion = 'v2.0';
    EntityName = '<entityName>';
    EntitySetName = '<entitySetName>';
    EntityCaption = '<Entity Display Name>';
    EntitySetCaption = '<Entities Display Name>';
    PageType = API;
    SourceTable = <SourceTable>;
    DelayedInsert = true;
    ODataKeyFields = SystemId;

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field(id; Rec.SystemId)
                {
                    Caption = 'ID';
                    Editable = false;
                }
                field(<fieldApiName1>; Rec.<KeyField>)
                {
                    Caption = '<Field1 Caption>';
                }
                field(<fieldApiName2>; Rec.<Field1>)
                {
                    Caption = '<Field2 Caption>';
                }
                field(<fieldApiName3>; Rec.<Field2>)
                {
                    Caption = '<Field3 Caption>';
                    Editable = false;  // Calculated fields
                }
                field(lastModifiedDateTime; Rec.SystemModifiedAt)
                {
                    Caption = 'Last Modified';
                    Editable = false;
                }
            }
        }
    }

    trigger OnInsertRecord(BelowxRec: Boolean): Boolean
    begin
        // Optional: Set default values or execute business logic
        Rec.Insert(true);
        exit(false);  // Return false to prevent double insert
    end;
}

Step 3: Add Navigation Property (Lines/Details)

For header-line relationships, add a part to the parent API page:

layout
{
    area(Content)
    {
        repeater(General)
        {
            // ... header fields ...
        }
        part(<entityName>Lines; "<PREFIX> API <EntityName> Lines")
        {
            EntityName = '<entityName>Line';
            EntitySetName = '<entityName>Lines';
            SubPageLink = <ParentKeyField> = field(<KeyField>);
        }
    }
}

Subpage (Lines):

page <ID+1> "<PREFIX> API <EntityName> Lines"
{
    APIGroup = '<group>';
    APIPublisher = '<publisher>';
    APIVersion = 'v2.0';
    EntityName = '<entityName>Line';
    EntitySetName = '<entityName>Lines';
    PageType = API;
    SourceTable = <LineSourceTable>;
    DelayedInsert = true;
    ODataKeyFields = SystemId;

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field(id; Rec.SystemId) { Editable = false; }
                field(<parentKeyApiName>; Rec.<ParentKeyField>) { }
                field(lineNo; Rec."Line No.") { }
                // ... other line fields ...
            }
        }
    }
}

Step 4: Create API Query (Read-Only Joins)

query <ID> "<PREFIX> API <QueryName>"
{
    QueryType = API;
    APIPublisher = '<publisher>';
    APIGroup = '<group>';
    APIVersion = 'v2.0';
    EntityName = '<entityName>';
    EntitySetName = '<entitySetName>';

    elements
    {
        dataitem(<HeaderAlias>; <HeaderTable>)
        {
            column(id; SystemId) { }
            column(<headerField1>; <KeyField>) { }
            column(<headerField2>; <Field1>) { }

            dataitem(<LineAlias>; <LineTable>)
            {
                DataItemLink = <LinkField> = <HeaderAlias>.<KeyField>;
                SqlJoinType = InnerJoin;  // or LeftOuterJoin

                column(lineNo; "Line No.") { }
                column(<lineField1>; <LineField1>) { }
                column(<lineField2>; <LineField2>) { }
            }
        }
    }
}

Required Properties

PropertyRequiredDescription
APIPublisherYesYour company/publisher name
APIGroupYesLogical grouping
APIVersionYesv2.0 recommended, beta for development
EntityNameYesSingular, camelCase
EntitySetNameYesPlural, camelCase
ODataKeyFieldsYesAlways use SystemId
PageType/QueryTypeYesAPI
DelayedInsertRecommendedtrue for API pages

API URL Structure

Custom API endpoint:

https://api.businesscentral.dynamics.com/v2.0/<TenantID>/<Environment>/api/<publisher>/<group>/<version>/companies(<CompanyID>)/<entitySetName>

Example:

https://api.businesscentral.dynamics.com/v2.0/contoso.com/Production/api/contoso/rental/v2.0/companies(xxx)/rentalMachines

Schema Version and OData Features (BC v24+)

Enum Values (schemaversion 2.0)

Starting BC v24, enum values are returned as XML-encoded names:

  • Return Order → Return_x0020_Order

Add ?$schemaversion=1.0 for old behavior.

IN Operator (schemaversion 2.1)

?$schemaversion=2.1&$filter=status in ('Open', 'Released')

Without it → BadRequest_MethodNotImplemented.

Filter inside $expand

?$expand=lines($filter=type eq 'Item')

Parentheses required around nested options.

Multi-level Expand

?$expand=lines($expand=item($expand=category))

Best Practices

  1. Always use ODataKeyFields = SystemId - stable across renames
  2. Use EntityCaption/EntitySetCaption - for localization at /entityDefinitions
  3. camelCase for API names - rentalMachine not RentalMachine
  4. Alphanumeric only - no special characters in field names
  5. Version your APIs - v1.0, v2.0, or beta
  6. Include SystemModifiedAt - enables delta sync with $filter
  7. DelayedInsert = true - prevents partial record creation

CRUD Control

Make API read-only:

page <ID> "<PREFIX> API Read Only"
{
    PageType = API;
    InsertAllowed = false;
    ModifyAllowed = false;
    DeleteAllowed = false;
}

Troubleshooting

ErrorCauseFix
400Invalid field nameCheck camelCase, no spaces
404Entity not foundVerify ODataKeyFields = SystemId, entity names
500Server errorCheck OnInsertRecord trigger, BC event log
API not visibleNot publishedRestart service tier, check compilation

Feedback loop: Fix issue → Recompile → Test GET first → Then test POST/PATCH/DELETE.

References

See references/ folder for:

  • api-page-template.al - Complete AL template for API Page (CRUD), Subpage (Lines), and Read-Only API
  • api-query-template.al - Complete AL template for API Query with join examples

External Documentation

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.