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>.
-
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: stringname: stringparentId: string | null}/** Something stored in a place. */export interface RemoteThing {id: stringtitle: stringnotes?: stringcount: numberplaceId: string} -
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.' }]}]}} -
Create the plugin and connect
A plugin is a manifest plus a
createfunction.createreturns 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.apiKeyreturn { 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) }}} -
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; a401after sign-in becomesauthentication-expired; other statuses go througherrorFromHttpStatus. 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 : undefinedthrow errorFromHttpStatus(res.status, providerMessage)}return res}} -
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 keepsmetadataas 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 appfor (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-tripsyncedAt: 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}`} -
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)} -
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.
canLocatePhysicallytells 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!)) continueresults.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)) continueif (query.containerIds && !query.containerIds.includes(c.id)) continueresults.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)}`} -
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' -
Test against a fake server, then the contract suite
Tests give the plugin a fake
HttpClientthrough 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 ?? falseconst 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 downconst 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 }))})
Where to go next
Section titled “Where to go next”- Testing with the contract suite lists exactly what every plugin is checked for.
- The runtime documents each method’s rules.
- Services and context covers the cache, secure storage and logging you did not need here.