Use cases

Call Integration Hub through the portal proxy with executeUseCase.

executeUseCase is how a block talks to external systems wired up through Integration Hub: device control, energy data, vendor lookups, anything your integration exposes. It's the most-used SDK primitive in a HEMS: every device, energy, and tariff call goes through it.

The request travels Block → Customer Portal API → Integration API proxy. The portal bridges your PortalAuth token to an internal token, so your block never holds integration credentials.

executeUseCase(params)

import { executeUseCase } from '@epilot/spark-sdk'

const result = await executeUseCase({
  integration_id: 'abc-123',
  use_case_slug: 'list_devices',
})

if (result.success) {
  console.log(result.data)
}

ExecuteUseCaseParams

FieldTypeDescription
integration_idstring (required)The Integration Hub installation id, usually session.config.integration_id.
use_case_slugstringIdentify the use case by slug…
use_case_namestring…or by name…
use_case_idstring…or by id. Provide exactly one of slug / name / id.
payloadRecord<string, unknown>The request body forwarded to the integration.
contextRecord<string, unknown>Extra context for the execution.

ExecuteUseCaseResult

interface ExecuteUseCaseResult {
  success: boolean
  data?: Record<string, unknown>
  entity_updated?: boolean
  entity_created?: boolean
  created_entity_id?: string
  error?: {
    code?: string
    message?: string
    details?: Record<string, unknown>
  }
}

success: false means the use case ran but the integration returned an error; inspect result.error. A rejected promise means the call itself failed (network, timeout, not initialized).

The query pattern

Wrap each use case in a TanStack Query hook. The integration_id comes from the block config, and queries stay disabled until it's available (and while in mock mode). From use-devices-query.ts:

import { executeUseCase } from '@epilot/spark-sdk'
import { useQuery } from '@tanstack/react-query'
import { unwrapProxyResponse } from '@/utils/unwrap-proxy-response'

export function useDevicesQuery() {
  const { integrationId, isMockedMode } = useBlockContext()

  return useQuery<Device[]>({
    queryKey: deviceKeys.list(integrationId ?? ''),
    queryFn: async () => {
      if (!integrationId) throw new Error('No integration configured')

      const raw = await executeUseCase({
        integration_id: integrationId,
        use_case_slug: 'list_devices',
      })
      const result = unwrapProxyResponse(raw)

      if (!result.success) {
        throw new Error(result.error?.message ?? 'Failed to load devices')
      }

      const data = result.data as Record<string, unknown> | undefined
      return (data?.devices ?? data?.data ?? []) as Device[]
    },
    enabled: Boolean(integrationId) && !isMockedMode,
  })
}

Sending a payload looks the same. Here a daily energy read from use-daily-energy-query.ts:

const raw = await executeUseCase({
  integration_id: integrationId,
  use_case_slug: 'get_daily_energy_data',
  payload: { date }, // yyyy-MM-dd
})

Two patterns worth copying

A HEMS standardizes two small helpers around the raw result. They aren't part of the SDK, but they're the conventions that make executeUseCase pleasant to use, so reach for the same shape in your block.

Unwrap the proxy envelope

When the use case has no response_mapping configured, the real payload is nested under data.response. unwrap-proxy-response.ts flattens it so the rest of your code sees a consistent shape:

import type { ExecuteUseCaseResult } from '@epilot/spark-sdk'

/** Unwrap the proxy envelope when no response_mapping is configured. */
export function unwrapProxyResponse(
  result: ExecuteUseCaseResult,
): ExecuteUseCaseResult {
  if (!result.success || !result.data) return result

  const data = result.data as Record<string, unknown>
  if (!data.response || typeof data.response !== 'object') return result

  const originalPayload = data.payload ?? data.original_request
  return {
    ...result,
    data: {
      ...(data.response as Record<string, unknown>),
      ...(originalPayload && typeof originalPayload === 'object'
        ? { _payload: originalPayload }
        : {}),
    },
  }
}

Parse structured errors

Integration errors often carry an RFC-7807-style problem document. A HEMS parses it into a typed ConnectApiError (status code, error code, trace id) so the UI can branch on it, for example retrying a delete with force: true on a 409:

import { parseConnectApiError } from '@/utils/connect-api-error'

const result = unwrapProxyResponse(
  await executeUseCase({
    integration_id: integrationId,
    use_case_slug: 'delete_device',
    payload: { deviceId, force: false },
  }),
)

if (!result.success) {
  const err = parseConnectApiError(result, 'Failed to remove device.')
  const isConflict = err.statusCode === 409 || err.problemType === 'conflict'
  // …decide whether to retry with force, or surface err.message
}

Mutations & optimistic updates

For control actions, pair executeUseCase with a mutation and optimistic cache updates so the UI responds instantly. The skeleton from use-execute-capability-mutation.ts:

return useMutation({
  mutationFn: async ({ deviceId, capabilityName, data, useCaseSlug }) => {
    // In mock mode (block editor preview) never reach the Integration Hub;
    // the optimistic patch already reflects the change locally.
    if (isMockedMode) return null
    if (!integrationId) throw new Error('No integration configured')

    const result = unwrapProxyResponse(
      await executeUseCase({
        integration_id: integrationId,
        use_case_slug: useCaseSlug ?? 'execute_capability',
        payload: { deviceId, capability: capabilityName, data },
      }),
    )

    if (!result.success) {
      throw parseConnectApiError(result, 'Capability update failed')
    }
    return result.data
  },
  onMutate: async (vars) => {
    /* cancel queries, snapshot, write optimistic value */
  },
  onError: (_e, _vars, ctx) => {
    /* roll back from snapshot */
  },
  onSettled: () => {
    /* invalidate to reconcile with the server */
  },
})

Mock mode. Guarding every call with isMockedMode from the block config lets your block render and animate in the portal's block editor without firing real integration commands.

On this page