Getting started

Bootstrap a portal block: connect to the portal, gate your app on the session, and size the iframe.

This page walks through standing up a real block, the way a HEMS built with Spark does it. The shape is always: connect once → expose the session to your tree → keep the iframe sized to your content.

1. Connect to the portal

Everything starts with initialize(). It performs the postMessage handshake with the parent portal and resolves to a SparkSession. It's safe to call from anywhere; repeat calls return the same cached session.

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

const session = await initialize()

initialize() rejects if the portal doesn't answer within the timeout (default 5s), so always handle the failure. That's your "couldn't connect" state.

2. Gate your app on the session

In React, do the handshake once in a provider, render a spinner while it's in flight, an error state if it fails, and the app once it's ready. Downstream components read the session from context.

Here is a HEMS's spark-session-context.ts, a tiny context plus a hook that throws if used outside the provider:

// app/providers/spark-session-context.ts
import type { SparkSession } from '@epilot/spark-sdk'
import { createContext, useContext } from 'react'

interface SparkSessionContextValue {
  session: SparkSession
}

export const SparkSessionContext =
  createContext<SparkSessionContextValue | null>(null)

export function useSparkSession(): SparkSession {
  const context = useContext(SparkSessionContext)
  if (!context) {
    throw new Error('useSparkSession must be used inside SparkSessionProvider')
  }
  return context.session
}

And the provider that drives the connection lifecycle (simplified from spark-session-provider.tsx; a full implementation also coordinates view transitions):

// app/providers/spark-session-provider.tsx
import { initialize, updateContentHeight } from '@epilot/spark-sdk'
import type { SparkSession } from '@epilot/spark-sdk'
import { Callout, CalloutDescription } from '@epilot/spark-ui/callout'
import { Spinner } from '@epilot/spark-ui/spinner'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { SparkSessionContext } from './spark-session-context'

type SessionStatus =
  | { kind: 'loading' }
  | { kind: 'ready'; session: SparkSession }
  | { kind: 'error' }

export function SparkSessionProvider({ children }: { children: ReactNode }) {
  const { t } = useTranslation()
  const containerRef = useRef<HTMLDivElement | null>(null)
  const [status, setStatus] = useState<SessionStatus>({ kind: 'loading' })

  useEffect(() => {
    let observer: ResizeObserver | null = null

    void (async () => {
      try {
        const session = await initialize()
        setStatus({ kind: 'ready', session })

        // Keep the iframe sized to the content (see step 3)
        const container = containerRef.current
        if (container) {
          observer = new ResizeObserver(() =>
            updateContentHeight(container.scrollHeight),
          )
          observer.observe(container)
        }
      } catch {
        setStatus({ kind: 'error' })
      }
    })()

    return () => observer?.disconnect()
  }, [])

  return (
    <div ref={containerRef} className="flex flex-col gap-4 pb-6">
      {status.kind === 'loading' && (
        <div className="flex flex-col items-center justify-center gap-3 py-12">
          <Spinner />
          <p className="text-sm text-gray-light">{t('session.connecting')}</p>
        </div>
      )}

      {status.kind === 'error' && (
        <Callout variant="error">
          <CalloutDescription>
            {t('session.connectionError')}
          </CalloutDescription>
        </Callout>
      )}

      {status.kind === 'ready' && (
        <SparkSessionContext.Provider value={{ session: status.session }}>
          {children}
        </SparkSessionContext.Provider>
      )}
    </div>
  )
}

With that in place, any component can reach the session:

function ContractTitle({ id }: { id: string }) {
  const session = useSparkSession() // typed SparkSession, never null
  // ...
}

3. Report your content size

The portal can't measure the DOM inside your cross-origin iframe, so you tell it how tall the block is and it resizes the iframe to match. Call updateContentHeight (or updateContentSize for width + height) whenever your layout changes.

The robust pattern is a ResizeObserver on your root container, which catches data loading, route changes, expanding panels, everything:

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

const observer = new ResizeObserver(() => {
  updateContentHeight(container.scrollHeight)
})
observer.observe(container)

If your block has absolutely-positioned or overflowing elements (footers, popovers anchored to the document), report both dimensions with updateContentSize({ width: el.scrollWidth, height: el.scrollHeight }).

4. Read the block's config

The portal passes per-installation configuration in session.config. A HEMS reads its Integration Hub id and target entity from there and exposes them via a small context:

// app/providers/block-context-provider.tsx
const session = useSparkSession()

const integrationId = session.config?.integration_id as string | undefined
const entityIds = (session.config?.entityIds ?? {}) as Record<string, string>
const assetId = entityIds.asset
const isMockedMode = session.config?.is_mocked_mode === true

integrationId is what every use-case call needs, and isMockedMode lets the block render mock data in the block editor without hitting real integrations.

Drive your i18n library from session.lang and subscribe to onLocaleUpdate so changing the portal language updates the block live, without an iframe reload:

// app/providers/i18n-provider.tsx
import { onLocaleUpdate } from '@epilot/spark-sdk'

useEffect(() => {
  void i18n.changeLanguage(session.lang ?? DEFAULT_LANGUAGE)
}, [session.lang])

useEffect(() => {
  return onLocaleUpdate((lang) => {
    void i18n.changeLanguage(lang)
  })
}, [])

Next steps

On this page