Extension Points (Dispense SDK)

The Dispense SDK provides multiple extension points that allow you to customize and enhance SDK functionality without modifying the core models. These extension points follow a registration pattern where you provide custom functions that the SDK invokes during model lifecycle events.

Extension System Overview

Extensions are registered through DispenseExtensionManager, which provides access to both dispense-specific and core KOS framework extension points:

  • Dispense extensions – Dispense SDK-specific extension points (documented here)
  • Core extensions – Base KOS framework extension points via extensionManager.core (covered in general KOS documentation)

Extensions are registered during application initialization, before models are loaded.

Companion Models

Purpose: Attach additional models with custom data and behavior to SDK models without modifying the SDK models themselves.

What are Companion Models?

Companion models are separate KOS models that are associated with SDK models (like AvailabilityModel) to provide additional data or functionality specific to your application. They participate in the KOS model lifecycle and are accessible through the parent model.

Common Use Cases

  • Adding brand-specific data to AvailabilityModel (images, color schemes, marketing copy)
  • Attaching external API data to SDK models
  • Providing custom methods for business logic
  • Extending SDK models with application-specific features

Registering Companion Models

Companion models are registered using KosModelRegistry during model registration:

import {
  KosModelRegistry,
  Availability,
  AvailabilityModel
} from "@kosdev-code/kos-dispense-sdk";
import { BrandCompanion } from "./brand-companion-registration";
KosModelRegistry.dispense
  .models()
  .model(BrandCompanion)
  .companion(
    Availability.type,        // Parent model type
    BrandCompanion.type       // Companion model type (string)
  );

The companion model type can be either:

  • A string – same companion type for all parent instances
  • A factory function – dynamically determine companion type based on parent model
// Factory function example - different companions for different groups
KosModelRegistry.dispense
  .models()
  .model(Brand)
  .model(Beverage)
  .companion(
    Availability.type,
    (availability: AvailabilityModel) => {
      if (availability.groupId === "brands") {
        return Brand.type;
      } else if (availability.groupId === "beverages") {
        return Beverage.type;
      }
      return undefined; // No companion for this instance
    }
  );

Accessing Companion Models

Use getKosCompanionModel to retrieve companion models:

import { getKosCompanionModel } from "@kosdev-code/kos-ui-sdk";
// In application code
const brand: AvailabilityModel = nozzle.brands[0];
const brandCompanion = getKosCompanionModel<BrandCompanion>(
  brand,
  BrandCompanion.type
);
// Access companion data and methods
const iconUrl = brandCompanion.iconUrl;
const color = brandCompanion.cuiColorCode;

Companion Models in CUI Kit

CUI Kit hooks support automatic companion model access through the companionType parameter:

const { brands, brandCompanions } = useBrands<BrandData, BrandCompanion>({
  companionType: BrandCompanion.type
});
// Render brands with their companions
brands.forEach((brand, index) => {
  const companion = brandCompanions[index];
  // Use companion data in UI
});

See 06-cui-kit.md for complete CUI Kit companion integration patterns.

Data Mapping Extensions

Data mappers transform service response data before it is loaded into SDK models. This is the primary way to add custom data to SDK models.

Assembly Data Mapper

Purpose: Augment AssemblyModel with additional properties from extended backend responses.

Extension Type: ExtensionType.AssemblyMapper

When to use:

  • Backend assembly service returns additional properties beyond base KOS assembly
  • Need to add custom hardware components (agitators, dispensers) to assembly model
  • Want to transform assembly data structure before model creation

Example:

import { AssemblyTypes, DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
import { DataMapper } from "@kosdev-code/kos-ui-sdk";
// Define additional data in service response
interface FreestyleAssemblyResponse {
  iceAgitator?: {
    name: "iceAgitator";
    path: string;
  };
  carbAgitator?: {
    name: "carbAgitator";
    path: string;
  };
}
// Define additional data structure for assembly model
interface FreestyleAssembly {
  iceAgitator: {
    path: string;
  };
  carbAgitator: {
    path: string;
  };
}
// Mapper transforms service response into model data
const assemblyDataMapper: DataMapper<
  AssemblyTypes.AssemblyResponse<FreestyleAssemblyResponse>,
  FreestyleAssembly
> = async (data) => {
  const response = data.assemblies.reduce((acc, assembly) => {
    const agitatorKeys = ["iceAgitator", "carbAgitator"];
    agitatorKeys.forEach((key) => {
      const agitator = assembly[key];
      if (agitator) {
        acc[key] = agitator;
      }
    });
    return acc;
  }, {} as FreestyleAssembly);
  return response;
};
// Register the mapper
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerAssemblyDataMapper(assemblyDataMapper);

The mapped data is merged into the AssemblyModel and accessible as typed properties:

const assembly = Assembly.instance().build();
const iceAgitatorPath = assembly.iceAgitator.path; // Type-safe access

Availability Data Mapper

Purpose: Augment AvailabilityModel instances with additional data (images, colors, marketing info).

Extension Type: ExtensionType.AvailabilityMapper

When to use:

  • Need to add UI-specific data to brands, beverages, or flavors
  • Loading external data (brandset files, CMS data) to enrich availability
  • Transforming beverage graph data before display

Example:

import { NozzleServices, DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
import { DataMapper } from "@kosdev-code/kos-ui-sdk";
interface BrandData {
  iconUrl: string;
  cuiColorCode: string;
}
// Context is provided by AvailabilityLoader (see next section)
interface BrandsetContext {
  [ingredientId: string]: {
    icon: string;
    cuiColorCode: string;
  };
}
const beverageDataMapper: DataMapper<
  NozzleServices.BeverageResponse,
  BrandData,
  BrandsetContext
> = async (data, context) => {
  const id = data.altId;
  const iconUrl = encodeURI(
    `http://localhost:8081/system/brandset/${context?.[id]?.icon}`
  );
  const cuiColorCode = context?.[id]?.cuiColorCode || "";
  return { iconUrl, cuiColorCode };
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerAvailabilityDataMapper(beverageDataMapper);

The mapped data is available in AvailabilityModel.data:

const brand: AvailabilityModel<BrandData> = nozzle.brands[0];
const iconUrl = brand.data.iconUrl;
const color = brand.data.cuiColorCode;

Holder Data Mapper

Purpose: Add custom data to HolderModel instances.

Extension Type: ExtensionType.HolderMapper

Example:

import { DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
interface HolderCustomData {
  displayName: string;
  maintenanceSchedule: string;
}
const holderDataMapper: DataMapper<
  HolderResponse,
  HolderCustomData
> = async (holderResponse) => {
  return {
    displayName: holderResponse.name.toUpperCase(),
    maintenanceSchedule: calculateMaintenanceSchedule(holderResponse)
  };
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerHolderDataMapper(holderDataMapper);

Ingredient Data Mapper

Purpose: Augment IngredientModel with additional properties.

Extension Type: ExtensionType.IngredientMapper

Example:

import { DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
interface IngredientData {
  chilled: boolean;
  carbonated: boolean;
  highYield: boolean;
}
const ingredientDataMapper: DataMapper<
  IngredientResponse,
  IngredientData
> = async (data, context) => {
  return {
    chilled: data.chilled,
    carbonated: data.carbonated,
    highYield: data.highYield,
  };
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerIngredientDataMapper(ingredientDataMapper);

Board Data Mapper

Purpose: Add custom data to BoardModel instances.

Extension Type: ExtensionType.BoardMapper

Usage:

const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerBoardDataMapper(boardDataMapper);

Context Loaders

Context loaders fetch external data that is provided to data mappers. Loaders run before mappers during model lifecycle.

Availability Loader

Purpose: Load external data (brandset, CMS content) to provide context for AvailabilityMapper.

Extension Type: ExtensionType.AvailabilityLoader

Example:

import { DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
interface BrandsetIngredient {
  ingredientId: string;
  icon: string;
  cuiColorCode: string;
}
interface BrandsetResponse {
  ingredients: BrandsetIngredient[];
}
const beverageContextLoader = async (): Promise<BrandsetContext> => {
  const response = await kosFetch('kos:/system/brandset/brandset.json');
  if (response.status !== 200) {
    KosLog.error('Failed to load beverage data');
    return {};
  }
  const json: BrandsetResponse = await response.json();
  // Normalize into a map for easy lookup
  const beverages = json.ingredients.reduce((acc, beverage) => {
    acc[beverage.ingredientId] = beverage;
    return acc;
  }, {} as BrandsetContext);
  return beverages;
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerAvailabilityLoader(beverageContextLoader);

The loaded context is passed as the second parameter to AvailabilityMapper:

const beverageDataMapper = async (data, context) => {
  // context is the result of beverageContextLoader
  const brandsetData = context[data.altId];
  // Use context to augment data
};

Ingredient Loader

Purpose: Load external data to provide context for IngredientMapper.

Extension Type: ExtensionType.IngredientLoader

Example:

const ingredientContextLoader = async () => {
  const response = await kosFetch('kos:/system/ingredients/config.json');
  if (response.status !== 200) {
    KosLog.error('Failed to load ingredient data');
    return {};
  }
  const json = await response.json();
  return json;
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerIngredientLoader(ingredientContextLoader);

Property Mappers

Property mappers transform individual properties during model creation. They are more granular than data mappers.

Ingredient Type Mapper

Purpose: Map ingredient service response into the type property of IngredientModel.

Extension Type: ExtensionType.IngredientTypeMapper

Example:

const ingredientTypeMapper = (ingredientResponse: IngredientResponse): string => {
  // Custom logic to determine ingredient type
  if (ingredientResponse.carbonated) {
    return "carbonated";
  } else if (ingredientResponse.chilled) {
    return "chilled";
  }
  return "ambient";
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerIngredientTypeMapper(ingredientTypeMapper);

Holder Type Mapper

Purpose: Map holder service response into the type property of HolderModel.

Extension Type: ExtensionType.HolderTypeMapper

Example:

const holderTypeMapper = (holderResponse: HolderResponse): string => {
  return holderResponse.group === "A" ? "primary" : "secondary";
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerHolderTypeMapper(holderTypeMapper);

Holder Ingredient Mapper

Purpose: Map the ingredientId property of HolderModel from service response.

Extension Type: ExtensionType.HolderIngredientMapper

When to use: When ingredientId in service response needs transformation before assignment.

Usage:

const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerHolderIngredientMapper(holderIngredientMapper);

Selected Pourable Mapper

Purpose: Map the abstract SelectedPourable data into a format specific to your nozzle implementation.

Extension Type: ExtensionType.SelectedPourableMapper

When to use: When the default pourable format needs transformation before sending to pour API.

Usage:

const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerSelectedPourableDataMapper(selectedPourableMapper);

Container Index Extensions

Container index extensions provide custom indexing strategies for accessing models in containers. They extend the default indexes with additional lookup patterns.

Holder Index Extension

Purpose: Add custom indexes to HolderContainer for faster lookups.

Extension Type: ExtensionType.HolderIndex

Example:

The SDK already provides indexes by nozzle and group. You can add custom indexes:

import { IndexExtension } from "@kosdev-code/kos-ui-sdk";
import { DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
const holderIndex: IndexExtension<HolderModel> = {
  // Index holders by ingredient type
  ingredientType: (holder) => holder.ingredient?.type || "unknown",
  // Index holders by board path
  boardPath: (holder) => holder.pumps.models[0]?.boardPath || "",
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerHolderIndex(holderIndex);

Access custom indexes through the container:

const assembly = Assembly.instance().build();
// Use custom index
const carbonatedHolders = assembly.holderContainer.holders.getModelsByIndex(
  "ingredientType",
  "carbonated"
);

Pump Index Extension

Purpose: Add custom indexes to PumpContainer.

Extension Type: ExtensionType.PumpIndex

Example:

const pumpIndex: IndexExtension<PumpModel> = {
  boardType: (pump) => pump.type,
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerPumpIndex(pumpIndex);

Board Index Extension

Purpose: Add custom indexes to BoardContainer.

Extension Type: ExtensionType.BoardIndex

Usage:

const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerBoardIndex(boardIndex);

Ingredient Index Extension

Purpose: Add custom indexes to IngredientContainer.

Extension Type: ExtensionType.IngredientIndex

Usage:

const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerIngredientIndex(ingredientIndex);

Feature Flag Resolution

Feature flag resolvers enable or disable features dynamically based on assembly data or model state.

Assembly Feature Flags

Purpose: Enable features based on AssemblyModel data during ready lifecycle phase.

Extension Type: ExtensionType.AssemblyFeatureFlags

Example:

import { FlagResolver } from "@kosdev-code/kos-ui-sdk";
import { AssemblyModel, DispenseExtensionManager } from "@kosdev-code/kos-dispense-sdk";
const assemblyFeatureFlagResolver: FlagResolver<AssemblyModel> = (
  assembly: AssemblyModel
) => {
  const flags: Record<string, boolean> = {};
  // Enable ice feature if ice agitator exists
  flags["ice.enabled"] = assembly.iceAgitator !== undefined;
  // Enable premium features based on pump count
  flags["premium.enabled"] = assembly.pumpContainer.models.length > 8;
  return flags;
};
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerAssemblyFeatureFlagResolver(
  assemblyFeatureFlagResolver
);

Access feature flags through the KOS feature flag system.

Extension Registration Pattern

All extensions follow a common registration pattern during application initialization:

Complete registration example:

// registration.ts
import {
  KosModelRegistry,
  Availability,
  AvailabilityModel,
  DispenseExtensionManager,
} from "@kosdev-code/kos-dispense-sdk";
import { Brand, BeverageCompanion } from "./models";
import {
  availabilityDataLoader,
  availabilityDataMapper,
  assemblyDataMapper
} from "./data";
// 1. Register models and companions
KosModelRegistry.dispense
  .models()
  .model(Brand)
  .model(BeverageCompanion)
  .companion(
    Availability.type,
    (availability: AvailabilityModel) => {
      if (availability.groupId === "brands") {
        return Brand.type;
      }
      return undefined;
    }
  );
// 2. Register data extensions
const dispenseExtensionManager = new DispenseExtensionManager();
dispenseExtensionManager.registerAvailabilityLoader(availabilityDataLoader);
dispenseExtensionManager.registerAvailabilityDataMapper(availabilityDataMapper);
dispenseExtensionManager.registerAssemblyDataMapper(assemblyDataMapper);

Extensions must be registered before initKosProvider is called.

Core Extension Access

The DispenseExtensionManager also provides access to core KOS framework extensions through the core property:

const dispenseExtensionManager = new DispenseExtensionManager();
// Access core extensions
dispenseExtensionManager.core.registerCanvasPatternLoader(async () => {
  return {
    patterns: [
      {
        type: 'video',
        id: 'heart',
        data: {
          src: 'http://localhost:8081/system/brandset/ui/videos/heart.mp4',
        },
      },
      // ... more patterns
    ],
  };
});

See general KOS SDK documentation for complete core extension reference.

Extension Timing and Lifecycle

Understanding when extensions are invoked helps you design effective extension logic:

Extension TypeLifecycle PhaseInvocation Timing
Context LoadersLoadBefore data mappers, once per container
Data MappersLoadAfter loaders, once per model instance
Property MappersLoadDuring model creation, per property
Index ExtensionsLoadAfter model creation, during container setup
Feature Flag ResolversReadyAfter all models loaded, once per parent model

Best Practices

  1. Keep extensions focused – Each extension should have a single, clear responsibility
  2. Handle errors gracefully – Extensions should not throw errors that break model loading
  3. Use type parameters – TypeScript generics ensure type safety in mapped data
  4. Avoid side effects – Extensions should be pure data transformation functions
  5. Document custom indexes – Make it clear what custom indexes are available
  6. Test extension registration – Verify extensions are registered before models load
  7. Use companion models for complex behavior – Data mappers are for data transformation only
  8. Register before initKosProvider – All extensions must be registered during app initialization

Validation

All extension APIs documented here have been validated against:

Related Documentation