Planara Intelligence Layer
Platform Concepts

Integration Architecture

How Planara connects to external systems — DMS, telemetry, ERP, and identity providers

Planara uses an adapter pattern for all external integrations. The frontend never knows where data comes from. It speaks a set of typed interfaces. Behind those interfaces, an adapter translates between the external system's API and Planara's data model.

This means adding a new DMS provider, telemetry source, or ERP system does not require any frontend changes. It requires one new adapter that implements the existing interface.


How It Works

Integration adapter pattern — external systems, adapter layer, service interfaces, widgets, with fallback path

Every widget in the technician dashboard consumes data through a service interface. The interface defines the shape of the data. The adapter provides the data from whatever source is configured.

External System --> Adapter --> Service Interface --> Widget

Example: DMS Integration

Lightspeed EVO                    QueueService
      |           Lightspeed           |
      +----------> Adapter ----------->+---> QueueList widget
                      |                |
                  Maps fields       Returns
                  to QueueJob[]    QueueJob[]


Example: Telemetry Integration

Siren Marine                      TelemetryService
      |           Siren Marine         |
      +----------> Adapter ----------->+---> TelemetryWidget
                      |                |
                  Maps readings     Returns
                  to fields[]      TelemetryProfile

The Full Data Flow

+------------------+     +------------------+     +------------------+
|  External System |     |  Adapter Layer   |     |  Widget Layer    |
|                  |     |                  |     |                  |
|  Lightspeed API -+---->+ LightspeedQueue  +---->+ QueueList        |
|  Siren Marine   -+---->+ SirenTelemetry   +---->+ TelemetryWidget  |
|  SAP Parts      -+---->+ SAPParts         +---->+ PartsWidget      |
|  (none)         -+---->+ MockAdapter      +---->+ (demo data)      |
+------------------+     +------------------+     +------------------+

When no external system is configured, the MockAdapter provides demo data. This is how the platform works out of the box — every widget renders with realistic sample data so the customer can evaluate the experience before connecting their systems.


Service Interfaces

These are the contracts that widgets consume. Every adapter must implement these interfaces. The frontend imports the interface, not the adapter. Swapping from mock to Lightspeed to CDK requires zero frontend code changes.

QueueService

Provides the job queue — work orders assigned to the logged-in technician.

interface QueueJob {
  id: string;                // Work order number
  label: string;             // Vessel/asset name
  sublabel: string;          // Make and model
  equipment: string;         // Engine model
  equipmentCount: number;    // Number of engines
  hours: number;             // Engine hours
  complaint: string;         // Customer complaint summary
  priority: "high" | "normal" | "low";
  faultCode: string | null;
  faultDescription: string | null;
  partsStatus: "staged" | "ordered" | "not_staged";
  assessmentReady: boolean;
  bay: string;
  customer: string;
  assignedAt: string;        // ISO timestamp
}

interface QueueService {
  getJobs(): Promise<QueueJob[]>;
}

Powered by: DMS integration (Lightspeed, CDK, DockMaster) or mock data.

JobService

Loads full work order detail and supports creating draft jobs from manual input.

interface DraftJobInput {
  description: string;
  equipmentId?: string;
  faultCodes?: string;
  customerName?: string;
  assetName?: string;
}

interface JobService {
  loadWorkOrder(jobId: string): Promise<WorkOrder>;
  createDraft(input: DraftJobInput): WorkOrder;
}

Powered by: DMS integration or local state (for manually-created jobs via the New Job sheet).

TelemetryService

Provides live engine readings and event history for charting.

interface TelemetryField {
  key: string;              // e.g. "coolant_temp_f"
  label: string;            // e.g. "Coolant Temp"
  value: number | null;     // Current reading
  unit: string;             // e.g. "F", "PSI", "RPM"
  warn_low: number | null;
  warn_high: number | null;
  critical_low: number | null;
  critical_high: number | null;
}

interface TelemetryEvent {
  id: string;
  date: string;
  time: string;
  peak: number;
  rpm: number;
  severity: "critical" | "warning" | "info";
  data: { t: string; v: number }[];  // Time-series for charting
}

interface TelemetryProfile {
  events: TelemetryEvent[];
  threshold: number;
  unit: string;
  domain: [number, number];
  ticks: number[];
  fields: TelemetryField[];
}

interface TelemetryService {
  getReadings(engineId: string): Promise<TelemetryReading[]>;
  getEventProfile(faultCode?: string): Promise<TelemetryProfile>;
}

Powered by: Telemetry integration (Siren Marine, NMEA 2000, Yamaha CL7) or mock data.

The fields array is dynamic — it contains whatever readings the telemetry source provides, with thresholds for each. The TelemetryWidget renders gauges automatically based on the fields present. No code changes needed when a new sensor type is added.

PartsService

Provides parts for a work order — inventory, pricing, bin locations.

interface PartEntry {
  partNumber: string;
  description: string;
  quantity: number;
  status: string;           // "in_stock", "ordered", "backordered"
  binLocation: string;
  unitCost: number;
}

interface PartsService {
  getPartsForJob(woNumber: string): Promise<PartEntry[]>;
}

Powered by: ERP integration (SAP, NetSuite) or DMS parts module (Lightspeed) or mock data.

CustomerService

Provides customer contact info and service history for the current asset.

interface CustomerInfo {
  name: string;
  phone: string;
  email?: string;
}

interface ServiceHistoryEntry {
  date: string;
  hoursAtService: number;
  description: string;
}

interface CustomerService {
  getCustomer(woNumber: string): Promise<CustomerInfo>;
  getServiceHistory(assetId: string): Promise<ServiceHistoryEntry[]>;
}

Powered by: DMS integration or mock data.

ServiceAdapter (Aggregate)

All services bundled. Every adapter implementation provides the full set.

interface ServiceAdapter {
  queue: QueueService;
  job: JobService;
  telemetry: TelemetryService;
  parts: PartsService;
  customer: CustomerService;
}

The frontend instantiates one ServiceAdapter at startup. Which implementation it uses is determined by configuration. Today: MockAdapter for demo, with real adapters built per customer as integrations are connected.


Building a Custom Adapter

For customers with development teams who want to connect their own systems directly, or for Planara team members building new provider support.

Step 1: Implement the Interface

Create a new adapter that implements ServiceAdapter (or a subset — not every service needs to be real).

// Example: a partial adapter that provides real queue data
// but falls back to mock for everything else

import { MockAdapter } from "./mock-adapter";
import type { ServiceAdapter, QueueService, QueueJob } from "./types";

class LightspeedQueueService implements QueueService {
  constructor(private apiUrl: string, private apiKey: string) {}

  async getJobs(): Promise<QueueJob[]> {
    const response = await fetch(`${this.apiUrl}/work-orders`, {
      headers: { Authorization: `Bearer ${this.apiKey}` },
    });
    const data = await response.json();
    return data.work_orders.map(mapLightspeedToQueueJob);
  }
}

// Compose: real queue, mock everything else
export function createLightspeedAdapter(
  config: { apiUrl: string; apiKey: string }
): ServiceAdapter {
  const mock = new MockAdapter();
  return {
    queue: new LightspeedQueueService(config.apiUrl, config.apiKey),
    job: mock.job,
    telemetry: mock.telemetry,
    parts: mock.parts,
    customer: mock.customer,
  };
}

Step 2: Map Fields

The mapping function translates the external system's data shape to Planara's interface. This is where provider-specific logic lives.

function mapLightspeedToQueueJob(wo: LightspeedWorkOrder): QueueJob {
  return {
    id: wo.wo_number,
    label: wo.unit?.name || "Unknown",
    sublabel: `${wo.unit?.make || ""} ${wo.unit?.model || ""}`.trim(),
    equipment: wo.unit?.engine_model || "Unknown",
    equipmentCount: wo.unit?.engine_count || 1,
    hours: wo.unit?.hours || 0,
    complaint: wo.complaint_description || "",
    priority: wo.priority === "rush" ? "high" : "normal",
    faultCode: wo.dtc_codes?.[0]?.code || null,
    faultDescription: wo.dtc_codes?.[0]?.description || null,
    partsStatus: mapPartsStatus(wo.parts_status),
    assessmentReady: wo.status === "assigned",
    bay: wo.bay_number || "",
    customer: wo.customer?.name || "",
    assignedAt: wo.assigned_at || new Date().toISOString(),
  };
}

Step 3: Register the Adapter

In the backoffice Integrations page, create an integration entry with the provider, endpoint URL, and API key. The adapter layer reads this configuration at startup and instantiates the correct adapter.

Step 4: Test

Use the backoffice Test Connection button to verify the endpoint is reachable. Then load the technician dashboard and confirm the widget renders real data.


Error Handling and Fallback

Every adapter is wrapped in error handling that falls back to the mock adapter on failure. This means a DMS outage does not break the technician dashboard — it degrades gracefully to demo data with a "Connection error — showing cached data" indicator.

Normal flow:
  External API --> Adapter --> Widget (real data)

Error flow:
  External API --> Adapter --> ERROR
                      |
                      v
              MockAdapter --> Widget (demo data + warning banner)

The fallback is automatic. No configuration needed. The widget displays a subtle indicator when showing fallback data so the technician knows the information may not be current.


Rate Limiting and Caching

Polling Intervals

For REST-based integrations (DMS, telemetry polling):

Integration TypeDefault Poll IntervalConfigurable
DMS Queue60 secondsYes
Telemetry30 secondsYes
Parts Inventory300 secondsYes

Poll intervals are configurable per integration in the backoffice. Shorter intervals mean fresher data but more API calls to the external system.

Response Caching

API responses from external systems are cached in memory with a TTL matching the poll interval. This means:

  • Multiple widgets requesting the same data within one poll cycle get the cached response
  • A page refresh does not trigger a new external API call if the cache is still valid
  • Cache is invalidated immediately on manual refresh or when the poll interval expires

Rate Limit Handling

If an external API returns a 429 (rate limited), the adapter:

  1. Serves the cached response if available
  2. Backs off exponentially (2s, 4s, 8s, 16s, max 60s)
  3. Logs the rate limit event in the backoffice integration status
  4. Resumes normal polling when the rate limit clears

Webhook Support

Status: [PLANNED]

For integrations that support it, webhook push is preferred over polling. The external system sends events to a Planara endpoint when data changes — new work order created, telemetry reading received, parts status updated.

Benefits over polling:

  • Real-time updates (no poll interval delay)
  • Lower API call volume on the external system
  • Event-driven architecture (process only changes, not full snapshots)

When available, webhook endpoints will be registered in the backoffice Integrations page alongside the polling configuration.


Integration Types at a Glance

TypeDirectionPowers These WidgetsCommon Providers
DMSPullQueue, Job Summary, Customer, Service HistoryLightspeed, CDK, DockMaster
TelemetryPull or PushTelemetry Gauges, Vitals, Event TimelineSiren Marine, NMEA 2000, Yamaha CL7
ERP / PartsPullParts Availability, PricingSAP, NetSuite, QuickBooks
NotificationPushAlerts (SMS, email, Slack, webhook)SendGrid, Twilio, Slack
IdentityFederationUser Auth, Role AssignmentAzure AD, Okta, Google Workspace

For Planara Team: Adding a New Provider

  1. Get the provider's API documentation and sandbox credentials
  2. Create the adapter class implementing the relevant service interface(s)
  3. Write the field mapping function
  4. Add the provider name to PROVIDER_SUGGESTIONS in the backoffice integrations page
  5. Test with sample data in the sandbox
  6. Deploy the adapter
  7. Update the integration type's documentation in the docs site