diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs
new file mode 100644
index 00000000..14497f88
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs
@@ -0,0 +1,20 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+///
+/// Parent command for Custom API operations.
+/// Usage: txc environment customapi [list|create|generate-openapi]
+///
+[CliCommand(
+ Name = "customapi",
+ Description = "Custom API discovery, creation, and OpenAPI generation for the live environment.",
+ Children = new[] { typeof(CustomApiListCliCommand), typeof(CustomApiCreateCliCommand), typeof(CustomApiGenerateOpenApiCliCommand) }
+)]
+public class CustomApiCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs
new file mode 100644
index 00000000..f7ee770d
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs
@@ -0,0 +1,193 @@
+using System.ComponentModel;
+using System.Text.Json;
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+///
+/// Creates a Custom API with optional request parameters and response properties.
+/// Usage: txc environment customapi create --unique-name <name> --display-name <label> [--request-param name:type[:optional] ...] [--response-property name:type ...] --apply
+///
+[CliIdempotent]
+[CliCommand(
+ Name = "create",
+ Description = "Create a Custom API in the LIVE connected environment, optionally with request parameters and response properties. Requires an active profile. Parameter types: boolean, datetime, decimal, entity, entitycollection, entityreference, float, integer, money, picklist, string, stringarray, guid."
+)]
+#pragma warning disable TXC003
+public class CustomApiCreateCliCommand : StagedCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiCreateCliCommand));
+
+ [CliOption(Name = "--unique-name", Description = "Unique name of the Custom API, including publisher prefix (e.g. udpp_CalculateTotal).", Required = true)]
+ public string UniqueName { get; set; } = null!;
+
+ [CliOption(Name = "--display-name", Description = "Display name (label) for the Custom API.", Required = true)]
+ public string DisplayName { get; set; } = null!;
+
+ [CliOption(Name = "--description", Description = "Description of what the Custom API does. Defaults to the display name (Dataverse requires a non-empty description).", Required = false)]
+ public string? Description { get; set; }
+
+ [CliOption(Name = "--binding-type", Description = "Binding: 'global' (default), 'entity', or 'entitycollection'.", Required = false)]
+ [DefaultValue("global")]
+ public string BindingType { get; set; } = "global";
+
+ [CliOption(Name = "--bound-entity", Description = "Logical name of the bound entity. Required when --binding-type is 'entity' or 'entitycollection'.", Required = false)]
+ public string? BoundEntity { get; set; }
+
+ [CliOption(Name = "--function", Description = "Register as an OData function (GET, no side effects) instead of an action (POST).", Required = false)]
+ [DefaultValue(false)]
+ public bool IsFunction { get; set; }
+
+ [CliOption(Name = "--private", Description = "Mark the Custom API as private (hidden from metadata consumers).", Required = false)]
+ [DefaultValue(false)]
+ public bool IsPrivate { get; set; }
+
+ [CliOption(Name = "--execute-privilege", Description = "Name of the privilege required to execute the Custom API.", Required = false)]
+ public string? ExecutePrivilege { get; set; }
+
+ [CliOption(Name = "--processing-step-type", Description = "Allowed custom processing steps: 'none' (default), 'async', or 'sync-and-async'.", Required = false)]
+ [DefaultValue("none")]
+ public string ProcessingStepType { get; set; } = "none";
+
+ [CliOption(Name = "--request-param", Description = "Request parameter as name:type[:optional] (e.g. Quantity:integer, Comment:string:optional). Repeatable.", Required = false)]
+ public string[]? RequestParams { get; set; }
+
+ [CliOption(Name = "--response-property", Description = "Response property as name:type (e.g. Total:money). Repeatable.", Required = false)]
+ public string[]? ResponseProperties { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ ValidateExecutionMode();
+
+ if (!CustomApiMaps.BindingTypes.TryGetValue(BindingType, out int bindingCode))
+ {
+ Logger.LogError("Invalid --binding-type '{BindingType}'. Valid values: global, entity, entitycollection.", BindingType);
+ return ExitValidationError;
+ }
+
+ if (bindingCode != 0 && string.IsNullOrWhiteSpace(BoundEntity))
+ {
+ Logger.LogError("--bound-entity is required when --binding-type is '{BindingType}'.", BindingType);
+ return ExitValidationError;
+ }
+
+ if (!CustomApiMaps.ProcessingStepTypes.TryGetValue(ProcessingStepType, out int stepTypeCode))
+ {
+ Logger.LogError("Invalid --processing-step-type '{StepType}'. Valid values: none, async, sync-and-async.", ProcessingStepType);
+ return ExitValidationError;
+ }
+
+ if (!TryParseSpecs(RequestParams, out var requestParams) ||
+ !TryParseSpecs(ResponseProperties, out var responseProps))
+ {
+ return ExitValidationError;
+ }
+
+ if (Stage)
+ {
+ if (requestParams.Count > 0 || responseProps.Count > 0)
+ {
+ Logger.LogError("--request-param and --response-property require --apply; staged creation supports only the Custom API record itself.");
+ return ExitValidationError;
+ }
+
+ var store = TxcServices.Get();
+ store.Add(new StagedOperation
+ {
+ Category = "data",
+ OperationType = "CREATE",
+ TargetType = "record",
+ TargetDescription = "customapi",
+ Details = $"unique name: \"{UniqueName}\"",
+ Parameters = new Dictionary
+ {
+ ["entity"] = "customapi",
+ ["data"] = JsonSerializer.Serialize(BuildApiAttributes(bindingCode, stepTypeCode)),
+ ["file"] = null
+ }
+ });
+ OutputWriter.WriteLine($"Staged: CREATE customapi '{UniqueName}'");
+ return ExitSuccess;
+ }
+
+ var service = TxcServices.Get();
+ var apiAttributes = ToJsonElement(BuildApiAttributes(bindingCode, stepTypeCode));
+ var apiId = await service.CreateAsync(Profile, "customapi", apiAttributes, CancellationToken.None).ConfigureAwait(false);
+
+ foreach (var (name, typeCode, optional) in requestParams)
+ {
+ var attributes = ToJsonElement(BuildChildAttributes(apiId, name, typeCode, isOptional: optional));
+ await service.CreateAsync(Profile, "customapirequestparameter", attributes, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ foreach (var (name, typeCode, _) in responseProps)
+ {
+ var attributes = ToJsonElement(BuildChildAttributes(apiId, name, typeCode, isOptional: null));
+ await service.CreateAsync(Profile, "customapiresponseproperty", attributes, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ OutputFormatter.WriteResult(
+ "succeeded",
+ $"Created Custom API '{UniqueName}' with {requestParams.Count} request parameter(s) and {responseProps.Count} response property(ies).",
+ apiId.ToString());
+ return ExitSuccess;
+ }
+
+ private bool TryParseSpecs(string[]? specs, out List<(string Name, int TypeCode, bool Optional)> parsed)
+ {
+ parsed = [];
+ foreach (var spec in specs ?? [])
+ {
+ var result = CustomApiMaps.ParseParameterSpec(spec, out var error);
+ if (result is null)
+ {
+ Logger.LogError("{Error}", error);
+ return false;
+ }
+ parsed.Add(result.Value);
+ }
+ return true;
+ }
+
+ private Dictionary BuildApiAttributes(int bindingCode, int stepTypeCode)
+ {
+ var attributes = new Dictionary
+ {
+ ["uniquename"] = UniqueName,
+ ["name"] = DisplayName,
+ ["displayname"] = DisplayName,
+ // Dataverse's RequiredFieldValidator rejects a NULL description on customapi.
+ ["description"] = string.IsNullOrWhiteSpace(Description) ? DisplayName : Description,
+ ["bindingtype"] = bindingCode,
+ ["isfunction"] = IsFunction,
+ ["isprivate"] = IsPrivate,
+ ["allowedcustomprocessingsteptype"] = stepTypeCode,
+ };
+ if (bindingCode != 0) attributes["boundentitylogicalname"] = BoundEntity;
+ if (!string.IsNullOrWhiteSpace(ExecutePrivilege)) attributes["executeprivilegename"] = ExecutePrivilege;
+ return attributes;
+ }
+
+ private static Dictionary BuildChildAttributes(Guid apiId, string name, int typeCode, bool? isOptional)
+ {
+ var attributes = new Dictionary
+ {
+ ["uniquename"] = name,
+ ["name"] = name,
+ ["displayname"] = name,
+ ["description"] = name,
+ ["type"] = typeCode,
+ ["customapiid"] = new Dictionary { ["Id"] = apiId, ["LogicalName"] = "customapi" },
+ };
+ if (isOptional is not null) attributes["isoptional"] = isOptional;
+ return attributes;
+ }
+
+ private static JsonElement ToJsonElement(Dictionary attributes) =>
+ JsonSerializer.SerializeToElement(attributes);
+}
diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs
new file mode 100644
index 00000000..6e6c9035
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs
@@ -0,0 +1,142 @@
+using System.Text.Json;
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Abstractions;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+///
+/// Generates an OpenAPI 3.0 specification for Custom APIs in the connected environment.
+/// Usage: txc environment customapi generate-openapi [--unique-name <name>] [--output <file>]
+///
+[CliReadOnly]
+[CliCommand(
+ Name = "generate-openapi",
+ Description = "Generate an OpenAPI 3.0 spec (JSON) describing Custom APIs in the LIVE connected environment, including request parameters and response properties. Requires an active profile. Use --unique-name for a single API, --output to write to a file instead of stdout."
+)]
+public class CustomApiGenerateOpenApiCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiGenerateOpenApiCliCommand));
+
+ [CliOption(Name = "--unique-name", Description = "Generate the spec for a single Custom API by unique name. Omit to include all.", Required = false)]
+ public string? UniqueName { get; set; }
+
+ [CliOption(Name = "--output", Description = "Path of the file to write the spec to. Omit to print to stdout.", Required = false)]
+ public string? Output { get; set; }
+
+ [CliOption(Name = "--title", Description = "OpenAPI document title.", Required = false)]
+ public string? Title { get; set; }
+
+ [CliOption(Name = "--spec-version", Description = "OpenAPI document version string (info.version).", Required = false)]
+ public string? SpecVersion { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ var query = TxcServices.Get();
+ var ct = CancellationToken.None;
+
+ string? apiFilter = UniqueName is not null ? $"uniquename eq '{UniqueName.Replace("'", "''")}'" : null;
+ var apiResult = await query.QueryODataAsync(
+ Profile, "customapis",
+ "customapiid,uniquename,name,description,bindingtype,boundentitylogicalname,isfunction",
+ apiFilter, "uniquename", null, false, ct).ConfigureAwait(false);
+
+ if (apiResult.Records.Count == 0)
+ {
+ Logger.LogError(UniqueName is not null
+ ? $"Custom API '{UniqueName}' was not found in the environment."
+ : "No Custom APIs found in the environment.");
+ return ExitValidationError;
+ }
+
+ var requestParams = await query.QueryODataAsync(
+ Profile, "customapirequestparameters",
+ "uniquename,name,type,isoptional,_customapiid_value",
+ null, "uniquename", null, false, ct).ConfigureAwait(false);
+
+ var responseProps = await query.QueryODataAsync(
+ Profile, "customapiresponseproperties",
+ "uniquename,name,type,_customapiid_value",
+ null, "uniquename", null, false, ct).ConfigureAwait(false);
+
+ var definitions = BuildDefinitions(apiResult.Records, requestParams.Records, responseProps.Records);
+
+ var document = CustomApiOpenApiBuilder.Build(
+ definitions,
+ Title ?? "Dataverse Custom APIs",
+ SpecVersion ?? "1.0.0",
+ await TryResolveEnvironmentUrlAsync(ct).ConfigureAwait(false));
+
+ // Serialize via JsonNode so dictionary keys (parameter names, paths) keep their exact casing.
+ string json = JsonSerializer.SerializeToNode(document)!.ToJsonString(TxcOutputJsonOptions.Default);
+
+ if (Output is not null)
+ {
+ var fullPath = Path.GetFullPath(Output);
+ await File.WriteAllTextAsync(fullPath, json, ct).ConfigureAwait(false);
+ OutputFormatter.WriteResult("succeeded", $"OpenAPI spec with {definitions.Count} Custom API(s) written to {fullPath}.");
+ }
+ else
+ {
+ OutputFormatter.WriteRaw(json);
+ }
+
+ return ExitSuccess;
+ }
+
+ internal static List BuildDefinitions(
+ IReadOnlyList apis,
+ IReadOnlyList requestParams,
+ IReadOnlyList responseProps)
+ {
+ var paramsByApi = requestParams.ToLookup(p => GetString(p, "_customapiid_value"));
+ var propsByApi = responseProps.ToLookup(p => GetString(p, "_customapiid_value"));
+
+ return apis.Select(api =>
+ {
+ string? id = GetString(api, "customapiid");
+ return new CustomApiDefinition(
+ GetString(api, "uniquename") ?? "",
+ GetString(api, "name"),
+ GetString(api, "description"),
+ GetInt(api, "bindingtype"),
+ GetString(api, "boundentitylogicalname"),
+ GetBool(api, "isfunction"),
+ paramsByApi[id].Select(ToParameter).OrderBy(p => p.UniqueName, StringComparer.OrdinalIgnoreCase).ToList(),
+ propsByApi[id].Select(ToParameter).OrderBy(p => p.UniqueName, StringComparer.OrdinalIgnoreCase).ToList());
+ }).ToList();
+ }
+
+ private static CustomApiParameter ToParameter(JsonElement e) => new(
+ GetString(e, "uniquename") ?? "",
+ GetString(e, "name"),
+ GetInt(e, "type"),
+ GetBool(e, "isoptional"));
+
+ private async Task TryResolveEnvironmentUrlAsync(CancellationToken ct)
+ {
+ try
+ {
+ var resolver = TxcServices.Get();
+ var context = await resolver.ResolveAsync(Profile, ct).ConfigureAwait(false);
+ return context.Connection.EnvironmentUrl;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static string? GetString(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
+
+ private static int GetInt(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
+
+ private static bool GetBool(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True;
+}
diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs
new file mode 100644
index 00000000..431005b5
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs
@@ -0,0 +1,123 @@
+using System.Text.Json;
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+///
+/// Summary row for a Custom API in the connected environment.
+///
+public sealed record CustomApiSummaryRecord(
+ string UniqueName,
+ string? DisplayName,
+ string BindingType,
+ string? BoundEntity,
+ bool IsFunction,
+ bool IsPrivate,
+ Guid Id);
+
+///
+/// Lists Custom APIs registered in the connected Dataverse environment.
+/// Usage: txc environment customapi list [--search <term>]
+///
+[CliReadOnly]
+[CliCommand(
+ Name = "list",
+ Description = "Lists Custom APIs registered in the LIVE connected environment. Requires an active profile. Use --search to filter by unique name or display name."
+)]
+public class CustomApiListCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiListCliCommand));
+
+ [CliOption(Name = "--search", Description = "Filter Custom APIs by unique name or display name (case-insensitive substring).", Required = false)]
+ public string? Search { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ var query = TxcServices.Get();
+ var result = await query.QueryODataAsync(
+ Profile,
+ "customapis",
+ "customapiid,uniquename,name,bindingtype,boundentitylogicalname,isfunction,isprivate",
+ null,
+ "uniquename",
+ null,
+ false,
+ CancellationToken.None).ConfigureAwait(false);
+
+ var rows = result.Records.Select(ToSummary)
+ .Where(r => MatchesSearch(r, Search))
+ .ToList();
+
+ OutputFormatter.WriteList(rows, PrintTable);
+ return ExitSuccess;
+ }
+
+ internal static CustomApiSummaryRecord ToSummary(JsonElement record) => new(
+ GetString(record, "uniquename") ?? "",
+ GetString(record, "name"),
+ CustomApiMaps.BindingTypeName(GetInt(record, "bindingtype")),
+ GetString(record, "boundentitylogicalname"),
+ GetBool(record, "isfunction"),
+ GetBool(record, "isprivate"),
+ record.TryGetProperty("customapiid", out var id) && id.TryGetGuid(out var guid) ? guid : Guid.Empty);
+
+ internal static bool MatchesSearch(CustomApiSummaryRecord row, string? search)
+ {
+ if (string.IsNullOrWhiteSpace(search)) return true;
+ return row.UniqueName.Contains(search, StringComparison.OrdinalIgnoreCase)
+ || (row.DisplayName?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false);
+ }
+
+ private static string? GetString(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
+
+ private static int GetInt(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
+
+ private static bool GetBool(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True;
+
+ // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintTable(IReadOnlyList rows)
+ {
+ if (rows.Count == 0)
+ {
+ OutputWriter.WriteLine("No Custom APIs found.");
+ return;
+ }
+
+ int uniqueWidth = Math.Clamp(rows.Max(r => r.UniqueName.Length), 11, 48);
+ int displayWidth = Math.Clamp(rows.Max(r => (r.DisplayName ?? "").Length), 12, 40);
+ int bindingWidth = Math.Clamp(rows.Max(r => r.BindingType.Length), 7, 16);
+ int boundWidth = Math.Clamp(rows.Max(r => (r.BoundEntity ?? "").Length), 12, 32);
+
+ string header =
+ $"{"Unique Name".PadRight(uniqueWidth)} | " +
+ $"{"Display Name".PadRight(displayWidth)} | " +
+ $"{"Binding".PadRight(bindingWidth)} | " +
+ $"{"Bound Entity".PadRight(boundWidth)} | " +
+ $"{"Function".PadRight(8)} | Private";
+ OutputWriter.WriteLine(header);
+ OutputWriter.WriteLine(new string('-', header.Length));
+
+ foreach (var r in rows)
+ {
+ OutputWriter.WriteLine(
+ $"{Truncate(r.UniqueName, uniqueWidth).PadRight(uniqueWidth)} | " +
+ $"{Truncate(r.DisplayName ?? "", displayWidth).PadRight(displayWidth)} | " +
+ $"{r.BindingType.PadRight(bindingWidth)} | " +
+ $"{Truncate(r.BoundEntity ?? "", boundWidth).PadRight(boundWidth)} | " +
+ $"{(r.IsFunction ? "true" : "false").PadRight(8)} | {(r.IsPrivate ? "true" : "false")}");
+ }
+ }
+#pragma warning restore TXC003
+
+ private static string Truncate(string value, int maxWidth) =>
+ value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value;
+}
diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs
new file mode 100644
index 00000000..a12fca64
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs
@@ -0,0 +1,106 @@
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+///
+/// Value maps for Custom API metadata: binding types, parameter/property
+/// type codes, and their OpenAPI schema equivalents.
+///
+internal static class CustomApiMaps
+{
+ internal static readonly IReadOnlyDictionary BindingTypes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["global"] = 0,
+ ["entity"] = 1,
+ ["entitycollection"] = 2,
+ };
+
+ internal static readonly IReadOnlyDictionary ProcessingStepTypes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["none"] = 0,
+ ["async"] = 1,
+ ["sync-and-async"] = 2,
+ };
+
+ internal static readonly IReadOnlyDictionary ParameterTypes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["boolean"] = 0,
+ ["datetime"] = 1,
+ ["decimal"] = 2,
+ ["entity"] = 3,
+ ["entitycollection"] = 4,
+ ["entityreference"] = 5,
+ ["float"] = 6,
+ ["integer"] = 7,
+ ["money"] = 8,
+ ["picklist"] = 9,
+ ["string"] = 10,
+ ["stringarray"] = 11,
+ ["guid"] = 12,
+ };
+
+ internal static string BindingTypeName(int code) => code switch
+ {
+ 0 => "global",
+ 1 => "entity",
+ 2 => "entitycollection",
+ _ => code.ToString(),
+ };
+
+ internal static string ParameterTypeName(int code) =>
+ ParameterTypes.FirstOrDefault(kv => kv.Value == code).Key ?? code.ToString();
+
+ /// Maps a Custom API type code to an OpenAPI (type, format, items-type) triple.
+ internal static (string Type, string? Format, string? ItemsType) ToOpenApiSchema(int code) => code switch
+ {
+ 0 => ("boolean", null, null),
+ 1 => ("string", "date-time", null),
+ 2 => ("number", "decimal", null),
+ 3 => ("object", null, null),
+ 4 => ("array", null, "object"),
+ 5 => ("object", null, null),
+ 6 => ("number", "float", null),
+ 7 => ("integer", "int32", null),
+ 8 => ("number", "decimal", null),
+ 9 => ("integer", "int32", null),
+ 10 => ("string", null, null),
+ 11 => ("array", null, "string"),
+ 12 => ("string", "uuid", null),
+ _ => ("string", null, null),
+ };
+
+ ///
+ /// Parses a name:type[:optional] parameter definition (e.g. Quantity:integer,
+ /// Comment:string:optional). Returns null with an error message on bad input.
+ ///
+ internal static (string Name, int TypeCode, bool Optional)? ParseParameterSpec(string spec, out string? error)
+ {
+ error = null;
+ var parts = spec.Split(':', StringSplitOptions.TrimEntries);
+ if (parts.Length is < 2 or > 3 || parts[0].Length == 0)
+ {
+ error = $"Invalid parameter spec '{spec}'. Expected format: name:type[:optional].";
+ return null;
+ }
+
+ if (!ParameterTypes.TryGetValue(parts[1], out int typeCode))
+ {
+ error = $"Unknown parameter type '{parts[1]}' in '{spec}'. Valid types: {string.Join(", ", ParameterTypes.Keys)}.";
+ return null;
+ }
+
+ bool optional = false;
+ if (parts.Length == 3)
+ {
+ if (!parts[2].Equals("optional", StringComparison.OrdinalIgnoreCase))
+ {
+ error = $"Invalid modifier '{parts[2]}' in '{spec}'. Only 'optional' is allowed.";
+ return null;
+ }
+ optional = true;
+ }
+
+ return (parts[0], typeCode, optional);
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs
new file mode 100644
index 00000000..de7d52fd
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs
@@ -0,0 +1,173 @@
+namespace TALXIS.CLI.Features.Environment.CustomApi;
+
+/// Custom API definition with its parameters, used as OpenAPI generation input.
+internal sealed record CustomApiDefinition(
+ string UniqueName,
+ string? DisplayName,
+ string? Description,
+ int BindingType,
+ string? BoundEntity,
+ bool IsFunction,
+ IReadOnlyList RequestParameters,
+ IReadOnlyList ResponseProperties);
+
+/// A request parameter or response property of a Custom API.
+internal sealed record CustomApiParameter(
+ string UniqueName,
+ string? DisplayName,
+ int TypeCode,
+ bool IsOptional);
+
+///
+/// Builds an OpenAPI 3.0 document (as a nested dictionary, serialized by the caller)
+/// from Custom API definitions. Actions become POST operations, functions become GET.
+///
+internal static class CustomApiOpenApiBuilder
+{
+ internal static Dictionary Build(
+ IReadOnlyList apis,
+ string title,
+ string version,
+ string? environmentUrl)
+ {
+ var paths = new Dictionary();
+ foreach (var api in apis.OrderBy(a => a.UniqueName, StringComparer.OrdinalIgnoreCase))
+ paths[PathFor(api)] = BuildPathItem(api);
+
+ var document = new Dictionary
+ {
+ ["openapi"] = "3.0.3",
+ ["info"] = new Dictionary
+ {
+ ["title"] = title,
+ ["version"] = version,
+ ["description"] = "Custom APIs registered in the Dataverse environment. Generated by TALXIS CLI.",
+ },
+ ["paths"] = paths,
+ };
+
+ if (!string.IsNullOrWhiteSpace(environmentUrl))
+ {
+ document["servers"] = new List