Skip to content
UniBox

Tutorial: write a plugin

We will write a plugin for Shelfie, a made-up service that tracks things stored in places. It is small on purpose, but it exercises everything a real plugin needs: sign-in, mapping, errors, search and tests. Every snippet below is imported from the example package in the repository (packages/integration-example), which is compiled and tested with the rest of the project, so what you read is what runs.

Shelfie’s API, in full:

Request Returns
POST /api/session with { apiKey } 200 { version }, or 401 for an unknown key
GET /api/places { places: [{ id, name, parentId }] }
GET /api/things?placeId= { things: [{ id, title, notes, count, placeId }] }
GET /api/things/:id one thing, or 404
GET /api/search?q= { things: [...] }

Every call except sign-in carries Authorization: Bearer <key>.

  1. Describe the remote shapes, privately

    These are Shelfie’s own types. They are not exported from the package; nothing outside the plugin may see them.

    src/api.ts
    // The remote service's own data shapes. These are private to the plugin: nothing outside this package may see them
    // (that is the whole point of a plugin), so they are never exported from index.ts.
    /** A place on a shelf-tracking service: rooms, shelves and boxes are all "places", nested by `parentId`. */
    export interface RemotePlace {
    id: string
    name: string
    parentId: string | null
    }
    /** Something stored in a place. */
    export interface RemoteThing {
    id: string
    title: string
    notes?: string
    count: number
    placeId: string
    }
  2. Write the manifest

    The manifest is data: what the plugin is called, what it can do, and the form the app should show to connect it. The app renders that form itself, so you never write UI. See Manifest and setup.

    src/manifest.ts
    import type { IntegrationManifest } from '@unibox-cam/integration-sdk'
    /**
    * The manifest is the plugin's public face. The app renders the setup form straight from `setup`, so it never needs to
    * know what a "Shelfie" is, and it shows or hides features from `capabilities`.
    */
    export const shelfieManifest: IntegrationManifest = {
    id: 'example.shelfie',
    name: 'Shelfie (example)',
    version: '0.1.0',
    description: 'Example plugin for a made-up REST API. Read it to learn how plugins are written.',
    capabilities: {
    containers: true,
    items: true,
    search: true,
    images: false,
    deepLinks: true,
    incrementalSync: false,
    remoteQrCodes: false,
    quantities: true
    },
    setup: {
    // Non-secret settings: stored in the local database.
    config: [{ key: 'baseUrl', label: 'Server address', type: 'url', placeholder: 'https://shelfie.example' }],
    // Secrets: kept in the device's secure storage, never in the database. Each entry is one way to sign in.
    credentials: [
    {
    method: 'api-key',
    label: 'API key',
    fields: [{ key: 'apiKey', label: 'API key', type: 'password', help: 'Create one under Account → API keys.' }]
    }
    ]
    }
    }
  3. Create the plugin and connect

    A plugin is a manifest plus a create function. create returns a runtime, one per configured source, which owns that source’s connection.

    src/plugin.ts
    export interface ShelfieConfig {
    baseUrl: string
    }
    export interface ShelfieCredentials {
    method: 'api-key'
    apiKey: string
    }
    export const shelfiePlugin: InventoryIntegrationPlugin<ShelfieConfig, ShelfieCredentials> = {
    manifest: shelfieManifest,
    // One runtime per configured source ("instance"). It owns that source's connection state.
    create: (context, instance, config) => new ShelfieRuntime(context, instance, config)
    }

    Connecting sends the key once and remembers it for later calls.

    src/plugin.ts (inside the runtime class)
    async connect(credentials: ShelfieCredentials) {
    const res = await this.send<{ version: string }>('POST', '/api/session', { body: { apiKey: credentials.apiKey }, auth: false })
    this.apiKey = credentials.apiKey
    return { status: { state: 'connected' as const, serverVersion: res.data.version }, serverVersion: res.data.version }
    }
    async disconnect() {
    this.apiKey = null // idempotent: calling it twice is fine
    }
    async testConnection() {
    try {
    const res = await this.send<{ version: string }>('GET', '/api/session')
    return { state: 'connected' as const, serverVersion: res.data.version }
    } catch (e) {
    // testConnection reports problems as a status instead of throwing.
    return { state: 'error' as const, error: toIntegrationError(e) }
    }
    }
  4. Put all transport in one place

    Every request goes through one method that builds the call and turns failures into normalized errors: a transport failure becomes network-unreachable; a 401 after sign-in becomes authentication-expired; other statuses go through errorFromHttpStatus. Keeping this in one place is what makes the rest of the plugin short. See Errors.

    src/plugin.ts (inside the runtime class)
    private async send<T>(
    method: 'GET' | 'POST',
    path: string,
    options: { query?: Record<string, string>; body?: unknown; auth?: boolean } = {}
    ): Promise<HttpResponse<T>> {
    if (options.auth !== false && !this.apiKey) throw new IntegrationException({ code: 'authentication-expired', message: 'Not connected' })
    let res: HttpResponse<T>
    try {
    res = await this.context.http.request<T>({
    method,
    url: `${this.config.baseUrl.replace(/\/+$/, '')}${path}`,
    query: options.query,
    body: options.body,
    headers: options.auth === false ? undefined : { Authorization: `Bearer ${this.apiKey}` },
    timeoutMs: 15_000
    })
    } catch (e) {
    throw toIntegrationError(e) // a transport failure (offline, DNS, TLS, timeout) becomes network-unreachable
    }
    if (res.status === 401 && options.auth !== false) {
    // We were connected, so a 401 now means the key stopped working (revoked or expired), not "wrong password".
    throw new IntegrationException({ code: 'authentication-expired', message: 'The API key no longer works', statusCode: 401 })
    }
    if (res.status >= 400) {
    // Any HTTP status resolves; it is our job to turn failures into the normalized codes.
    const providerMessage = typeof (res.data as { error?: unknown })?.error === 'string' ? (res.data as { error: string }).error : undefined
    throw errorFromHttpStatus(res.status, providerMessage)
    }
    return res
    }
    }
  5. Map containers

    Shelfie calls them places; UniBox calls them containers. The mapping builds the root-to-leaf path, namespaces ids by instance (so two sources can never collide) and keeps metadata as plain data.

    src/plugin.ts (inside the runtime class)
    async getContainers() {
    return this.mapPlaces(await this.places())
    }
    async getContainer(remoteId: string) {
    const found = (await this.getContainers()).find(c => c.remoteId === remoteId)
    if (!found) throw new IntegrationException({ code: 'not-found', message: `Container ${remoteId} not found`, statusCode: 404 })
    return found
    }
    src/plugin.ts (inside the runtime class)
    private async places(): Promise<RemotePlace[]> {
    return (await this.send<{ places: RemotePlace[] }>('GET', '/api/places')).data.places
    }
    private mapPlaces(places: RemotePlace[]): InventoryContainer[] {
    const byId = new Map(places.map(p => [p.id, p]))
    const pathOf = (p: RemotePlace): string[] => {
    const path: string[] = []
    const seen = new Set<string>() // a bad remote cycle must not hang the app
    for (let at: RemotePlace | undefined = p; at && !seen.has(at.id); at = at.parentId ? byId.get(at.parentId) : undefined) {
    seen.add(at.id)
    path.unshift(at.name)
    }
    return path
    }
    return places.map(p => ({
    id: this.containerId(p.id),
    integrationInstanceId: this.instance.id,
    remoteId: p.id,
    name: p.name,
    parentId: p.parentId ? this.containerId(p.parentId) : undefined,
    path: pathOf(p),
    sourceUrl: this.link('places', p.id),
    metadata: {},
    syncedAt: this.context.clock.now()
    }))
    }
    private toItem(t: RemoteThing): InventoryItem {
    return {
    id: `${this.instance.id}:i:${t.id}`,
    integrationInstanceId: this.instance.id,
    remoteId: t.id,
    name: t.title,
    description: t.notes?.trim() || undefined,
    quantity: t.count,
    containerId: this.containerId(t.placeId),
    metadata: {}, // plain data only: it must survive a JSON round-trip
    syncedAt: this.context.clock.now()
    }
    }
    /** Normalized ids are namespaced by instance so two sources can never collide. */
    private containerId(remoteId: string) {
    return `${this.instance.id}:c:${remoteId}`
    }
  6. Read items

    An unknown container must reject with not-found, never return an empty list, so contents first checks that the container exists.

    src/plugin.ts (inside the runtime class)
    async getContainerContents(remoteContainerId: string, options: GetContainerContentsOptions = {}) {
    await this.getContainer(remoteContainerId) // an unknown container must reject with not-found, not return []
    const res = await this.send<{ things: RemoteThing[] }>('GET', '/api/things', { query: { placeId: remoteContainerId } })
    const things = res.data.things.slice(options.offset ?? 0, options.limit ? (options.offset ?? 0) + options.limit : undefined)
    return things.map(t => this.toItem(t))
    }
    async getItem(remoteId: string) {
    const res = await this.send<RemoteThing>('GET', `/api/things/${encodeURIComponent(remoteId)}`)
    return this.toItem(res.data)
    }
  7. Add search (optional)

    Search is an optional capability. If your remote API cannot filter by container or by kind, do it in the plugin, as here. canLocatePhysically tells UniBox whether a result can be found by camera: true when you know which container it is in.

    src/plugin.ts (inside the runtime class)
    async search(query: InventorySearchQuery): Promise<InventorySearchResult[]> {
    const containers = await this.getContainers()
    const byRemoteId = new Map(containers.map(c => [c.remoteId, c]))
    const needle = query.text.trim().toLowerCase()
    const results: InventorySearchResult[] = []
    if (query.includeItems !== false) {
    const res = await this.send<{ things: RemoteThing[] }>('GET', '/api/search', { query: { q: query.text } })
    for (const thing of res.data.things) {
    const item = this.toItem(thing)
    // Honour the container filter ourselves if the remote API cannot.
    if (query.containerIds && !query.containerIds.includes(item.containerId!)) continue
    results.push({
    kind: 'item',
    id: item.id,
    integrationInstanceId: this.instance.id,
    item,
    container: byRemoteId.get(thing.placeId),
    score: thing.title.toLowerCase().startsWith(needle) ? 1 : 0.5,
    searchableText: `${thing.title} ${thing.notes ?? ''}`.trim(),
    matchedFields: ['name'],
    // The item can be found physically because we know which container it is in.
    canLocatePhysically: byRemoteId.has(thing.placeId)
    })
    }
    }
    if (query.includeContainers !== false && needle) {
    for (const c of containers) {
    if (!c.name.toLowerCase().includes(needle)) continue
    if (query.containerIds && !query.containerIds.includes(c.id)) continue
    results.push({
    kind: 'container',
    id: c.id,
    integrationInstanceId: this.instance.id,
    container: c,
    score: 0.8,
    searchableText: c.name,
    matchedFields: ['name'],
    canLocatePhysically: true
    })
    }
    }
    return results.sort((a, b) => b.score - a.score).slice(0, query.limit ?? results.length)
    }
    getDeepLink(resource: InventoryContainer | InventoryItem) {
    return this.link('path' in resource ? 'places' : 'things', resource.remoteId) // only containers have a path
    }
    private link(kind: 'places' | 'things', remoteId: string) {
    return `${this.config.baseUrl.replace(/\/+$/, '')}/${kind}/${encodeURIComponent(remoteId)}`
    }
  8. Export the plugin, and only the plugin

    src/index.ts
    // The package's public surface: the plugin and its types. The remote DTOs in api.ts stay private.
    export { shelfiePlugin, type ShelfieConfig, type ShelfieCredentials } from './plugin'
    export { shelfieManifest } from './manifest'
  9. Test against a fake server, then the contract suite

    Tests give the plugin a fake HttpClient through the context, so its real request and error code runs without a network. Keep the fake as dumb as the real service: JSON in, JSON out, failures as HTTP statuses.

    src/fixture-server.ts
    import type { HttpClient, HttpRequest, HttpResponse } from '@unibox-cam/integration-sdk'
    import type { RemotePlace, RemoteThing } from './api'
    export const VALID_API_KEY = 'shelfie-key-123'
    const PLACES: RemotePlace[] = [
    { id: 'p1', name: 'Garage', parentId: null },
    { id: 'p2', name: 'Shelf A', parentId: 'p1' },
    { id: 'p3', name: 'Box 04', parentId: 'p2' },
    { id: 'p4', name: 'Workshop', parentId: null }
    ]
    const THINGS: RemoteThing[] = [
    { id: 't1', title: 'Cordless drill', notes: ' 18V, two batteries', count: 1, placeId: 'p3' },
    { id: 't2', title: 'M4 screws', count: 200, placeId: 'p3' },
    { id: 't3', title: 'Soldering iron', count: 1, placeId: 'p4' }
    ]
    /**
    * A tiny in-memory version of the remote service, as an `HttpClient`. Tests hand it to the plugin through the context,
    * so the plugin's real request-building and error-mapping code runs without any network. Keep the fake as dumb as the
    * real service is: JSON in, JSON out, errors as HTTP statuses.
    */
    export function createFixtureServer(options: { offline?: boolean; revokeKeyAfter?: number } = {}): HttpClient & { requests: HttpRequest[]; setOffline(v: boolean): void } {
    const requests: HttpRequest[] = []
    let offline = options.offline ?? false
    const reply = <T>(status: number, data: T): HttpResponse<T> => ({ status, headers: { 'content-type': 'application/json' }, data })
    return {
    requests,
    setOffline: v => {
    offline = v
    },
    async request<T>(req: HttpRequest): Promise<HttpResponse<T>> {
    requests.push(req)
    if (offline) throw new TypeError('Failed to fetch') // what fetch does when the network is down
    const url = new URL(req.url)
    const send = (status: number, data: unknown) => reply(status, data) as unknown as HttpResponse<T>
    if (req.method === 'POST' && url.pathname === '/api/session') {
    return (req.body as { apiKey?: string })?.apiKey === VALID_API_KEY ? send(200, { version: '2.1' }) : send(401, { error: 'Unknown API key' })
    }
    const key = req.headers?.Authorization?.replace('Bearer ', '')
    const authenticated = key === VALID_API_KEY && !(options.revokeKeyAfter !== undefined && requests.length > options.revokeKeyAfter)
    if (!authenticated) return send(401, { error: 'Invalid or revoked key' })
    const q = req.query ?? {}
    if (url.pathname === '/api/session') return send(200, { version: '2.1' })
    if (url.pathname === '/api/places') return send(200, { places: PLACES })
    if (url.pathname === '/api/things') return send(200, { things: THINGS.filter(t => !q.placeId || t.placeId === q.placeId) })
    if (url.pathname === '/api/search') {
    const needle = String(q.q ?? '').toLowerCase()
    return send(200, { things: THINGS.filter(t => `${t.title} ${t.notes ?? ''}`.toLowerCase().includes(needle)) })
    }
    const thing = url.pathname.match(/^\/api\/things\/(.+)$/)
    if (thing) {
    const found = THINGS.find(t => t.id === decodeURIComponent(thing[1]))
    return found ? send(200, found) : send(404, { error: 'No such thing' })
    }
    return send(404, { error: 'Not found' })
    }
    }
    }

    Then run the shared contract suite, plus tests for what is specific to your plugin.

    src/plugin.spec.ts
    // 1. The shared contract: every plugin must pass the same behavioural test suite.
    integrationContractTests({
    name: shelfieManifest.name,
    fixture: {
    rootContainerRemoteId: 'p1',
    nestedContainerRemoteId: 'p3',
    expectedPath: ['Garage', 'Shelf A', 'Box 04'],
    itemRemoteId: 't1',
    itemContainerRemoteId: 'p3',
    searchText: 'drill',
    missingRemoteId: 'does-not-exist'
    },
    validCredentials: { method: 'api-key', apiKey: VALID_API_KEY },
    invalidCredentials: { method: 'api-key', apiKey: 'wrong' },
    createRuntime: async () => make(),
    createUnreachableRuntime: async () => make(createFixtureServer({ offline: true }))
    })