CI HUBCI HUB SDK
Building an Adapter

Search

The search handler returns assets matching a text query.

Called when the user types in the search bar in the CI HUB plugin UI.

Inputs

ParameterTypeDescription
requestData.query.querystringSearch text entered by the user
requestData.query.pagestringCurrent page number
requestData.query.sizestringRequested page size
requestData.query.filtersstring[]Selected filter option IDs
requestData.query.parentIdstringFolder scope — limits search to a subfolder
requestData.body.dataBase64stringBase64-encoded image for similarity/visual search (optional)

Response

return send({ assets, folders, filters, more })
FieldTypeDescription
assetsAsset[]Matching assets
foldersFolder[]Subfolders relevant to the query
filtersFilter[]Filter definitions with options and active state
morenumber | undefinedNext page token. Present when more assets exist. undefined = last page

The extractAssetsData Pattern

Every adapter needs a function that maps remote API objects to the CI HUB Asset shape. By convention this function is called extractAssetData (single) or extractAssetsData (batch). It is not an SDK export — you write it in your adapter. Both search and getFolder reuse it.

Asset Object Fields

Every field the mapping function should produce:

FieldTypePurpose
idstringUnique identifier from the remote system
namestringDisplay name. Sanitize with .replace(/[/\\:*?"<>|]/g, '_') — creative tools reject special characters in file names
downloadUrlstring | nullURL to fetch the file binary. Append URL suffixes as needed. null if download is not permitted
thumbnailUrlstring | nullPreview image URL. For InDesign/IDMS files, CI HUB can generate thumbnails server-side via getThumbnailUrl() — pass the source system thumbnail or an empty string
mimeTypestringDerived from file extension or the remote API. Fall back to 'application/octet-stream'
xSizePx / ySizePxnumberPixel dimensions (width / height)
fileSizenumberSize in bytes
created / modifiednumberUnix millisecond timestamps. Convert ISO date strings with new Date(dateString).getTime()
versionnumberVersion number. Default to 1 when the platform doesn't support versioning
valuesArray<{id, name, type, value}>Metadata entries shown in the asset detail panel. See Custom Metadata
conversionsArray<{id, name, url, extension}>Available renditions/derivatives of the asset
capabilitiesAssetCapabilitiesPer-asset permissions. Spread defaultAssetCapabilities and override what the platform supports
keywordsstring[]Tags — displayed as chips in the plugin UI
statesstring[]Workflow / lifecycle state labels
assetDetailsExternalUrlstring | null"Open in source system" link
downloadHashFileAttributesstringJSON.stringify({ fileSize, modified }) — used by CI HUB for change detection

URL Suffixes

Append these to downloadUrl or thumbnailUrl to control how CI HUB fetches the file:

  • $NO_AUTH$ — URL is publicly accessible, skip auth headers
  • $AUTH_PAYLOAD_BEARER$ — attach the stored token as a Bearer header
  • $FORCE_DOWNLOAD$ — force download instead of streaming

Full list in the Search Schema.

Pagination

more controls asset pagination. Return a page token (typically page + 1) when more assets exist; return undefined on the last page.

For subfolders: return all matching folders in a single response — don't paginate folders.

If the platform allows uploading assets without a folder context (e.g. a flat asset library), set canAddAsset: true in the search result's capabilities:

capabilities: { canAddAsset: true }

This shows the upload button in the plugin UI even when not inside a folder.

Patterns

Example - extractAssetData

Maps a single remote API object to the CI HUB Asset shape. Derives the name, thumbnail, MIME type, conversions, states, and metadata values from the remote item.

const stateProps = [
  { id: 'watermarked', name: 'Watermarked' },
  { id: 'limited', name: 'Limited' },
  { id: 'archive', name: 'Archived' },
  { id: 'isPublic', name: 'Public' }
]
const getValues = item => stateProps.
  filter(prop => item[prop.id]).
  map(prop => ({ id: prop.id, name: prop.name, type: 'TEXT', value: '✓' })).
  concat(Object.entries(item).
    filter(([ key, value ]) => key.startsWith('property_') && value).
    map(([ key, value ]) => ({ id: key, name: `${key[9].toUpperCase()}${key.substring(10)}`, type: 'TEXT', value: Array.isArray(value) ? value.join(', ') : value })))

const extractAssetData = (locals, item) => {
  let name = (item.name || '').replace(/[/\\:*?"<>|]/g, '_')
  const currentExtension = (path.extname(name) || '').toLowerCase().replace('.', '')

  if (item.extension && currentExtension !== `${item.extension}`.toLowerCase()) {
    name += `.${item.extension}`
  }
  const thumbnailVideoUrl = item && item.videoPreviewURLs && item.videoPreviewURLs.length && item.videoPreviewURLs[0]
  const downloadUrl = getDownloadUrl(locals, item.id)
  const thumbnailUrl = getThumbnailUrl(downloadUrl, name, [ 'indd', 'idms' ], item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`)
  const fileSize = item.fileSize
  const modified = new Date(item.dateModified).getTime()

  return {
    id: item.id,
    name,
    parentPath: '/Assets',
    fileSize,
    xSizePx: item.width,
    ySizePx: item.height,
    created: new Date(item.dateCreated).getTime(),
    modified,
    mimeType: mime.lookup(name) || 'application/octet-stream',
    version: item.version || 1,
    caption: item.description && item.description.trim() || undefined,
    copyright: item.copyright && item.copyright.trim() || undefined,
    title: item.name,
    thumbnailUrl,
    thumbnailVideoUrl,
    downloadUrl,
    downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
    lowresUrl: !thumbnailVideoUrl ? getDownloadUrl(locals, item.id, '?lowres=1') : undefined,
    assetDetailsExternalUrl: getAssetDetailsExternalUrl(locals, item.id),
    conversions: getConversions(item),
    keywords: item.tags,
    states: stateProps.filter(entry => item[entry.id]).map(entry => entry.name),
    capabilities: defaultAssetCapabilities,
    values: getValues(item)
  }
}

Example - extractAssetsData

Batch variant that maps an array of remote items, merges per-asset permissions with defaultAssetCapabilities, and filters out broken items.

const extractAssetsData = (locals, items, overrideCapabilities = {}, useFileName = false) => items.map((item) => {
  const expired = item.expiresAt ? new Date(item.expiresAt).getTime() < Date.now() : false
  const [ key, fallback ] = useFileName ? [ 'title', 'filename' ] : [ 'filename', 'title' ]
  const assetName = item[key] || item[fallback]

  const downloadUrl = item.downloadUrl?.concat('$NO_AUTH$') ?? getDownloadUrl(locals)
  const thumbnailUrl = item.thumbnailUrl?.concat('$NO_AUTH$')
  const extension = item.extension || mimeTypes.extension(item.filename)
  const fileSize = item.size
  const modified = new Date(item.modifiedAt).getTime()
  const readableId = getHumanReadableId(item.id.toString())
  const canDownload = !expired && (item.currentUserPermissions?.canDownload ?? true)

  const capabilities = {
    canUpdateAsset: !expired && (item.currentUserPermissions?.canEdit || false),
    canDeleteAsset: !expired && (item.currentUserPermissions?.canDelete || false)
  }

  try {
    const asset = {
      id: item.id.toString(),
      name: assetName,
      downloadUrl: canDownload ? downloadUrl : null,
      thumbnailUrl: thumbnailUrl,
      mimeType: mimeTypes.lookup(extension) || mimeTypes.lookup(assetName) || 'application/octet-stream',
      title: item.title,
      xSizePx: item.width,
      ySizePx: item.height,
      fileSize,
      created: new Date(item.createdAt).getTime(),
      modified,
      version: 1,
      values: getValues(item),
      conversions: expired ? [] : getConversions(item, extension, thumbnailUrl),
      keywords: item.tags?.map((tag) => tag.value).filter((tag) => tag),
      states: [ item.workflowTask?.status?.name, item.licenses?.map((license) => license.title), expired ? 'Expired' : '' ].flat().filter((state) => state),
      capabilities: { ...defaultAssetCapabilities, ...capabilities, ...overrideCapabilities },
      downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
      assetDetailsExternalUrl: readableId ? new URL(`/screen/${readableId}`, locals.adapterData.resourceEndpoint).toString() : null
    }

    return asset
  } catch {
    return undefined
  }
}).filter((item) => item)

Example - Search Handler

Parses query parameters, calls the remote API, maps results through extractAssetData, builds filters, and determines pagination.

search: async (locals, requestData) => {
  const query = requestData.query.query || ''
  const page = Number.parseInt(requestData.query.more || 0, 10)
  const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('exampleAdapter1.maxQuerySize'))
  const activeFilters = requestData.query.filters || []

  try {
    const result = await getAssets(locals, null, query, page, size, activeFilters)
    return sendResult(null, result)
  } catch (err) {
    return sendError(err, 400)
  }
}

// getAssets builds the full result object:
const getAssets = async (locals, collection, search, page, size, activeFilters = []) => {
  const capabilities = Object.assign({}, defaultFolderCapabilities, {
    canAddAsset: true,
    canDeleteFolder: collection !== ''
  })
  const result = { folders: [], assets: [], filters: [], capabilities, totalAssetsCount: 0 }

  const query = {
    limit: size,
    page: page + 1,
    count: true,
    total: true,
    orderBy: 'dateCreated desc'
  }

  const assets = await callApi(locals, 'GET', '/api/v4/media/', query)
  result.totalAssetsCount = assets.count.total

  // Pagination: check if more assets exist
  if (page * size + assets.media.length < result.totalAssetsCount) {
    result.more = page + 1
  }

  // Map each remote item to CI HUB Asset
  for (const item of assets.media) {
    const asset = extractAssetData(locals, item)
    result.assets.push(asset)
  }

  return result
}

Example - Search Handler

SDK-based adapter with filter parsing, folder scoping, and two search paths (library search vs. brand-wide search).

search: async (locals, requestData) => {
  const { brandId } = locals.adapterData
  const isFileNameSearch = requestData.query.type === 'fileName'
  const query = (requestData.query.query || '').replace(isFileNameSearch ? /^"|"$/g : '', '')
  const activeFilters = requestData.query.filters || []
  const activeFiltersJson = parseOptions(activeFilters)
  const rFolderId = (requestData.query.parentId || '').toString()
  const folderId = folderIdStartsWith(rFolderId) ? undefined : rFolderId
  const more = parseInt((requestData.query.more || '1'), 10) || 1
  const size = Math.min(parseInt((requestData.query.size || '25'), 10), config.get('exampleAdapter2.maxQuerySize'))
  const result = {
    assets: [],
    folders: [],
    filters: [],
    more: undefined,
    capabilities: {},
    totalAssetsCount: 0
  }

  try {
    const searchQuery = {
      ...folderId ? { inFolder: { id: folderId } } : {},
      search: query
    }
    const assets = await callApi(locals, {
      query: queries.getSearchAssetsQuery,
      variables: { libraryId, query: searchQuery, page: more, limit: size }
    })

    result.assets = extractAssetsData(locals, assets.data.library.assets.items)
    result.more = assets.data.library.assets.hasNextPage ? more + 1 : undefined
    result.totalAssetsCount = assets.data.library.assets.total
    result.filters = getFilters(assets.data, activeFiltersJson)

    return send(result)
  } catch (error) {
    return sendError(`Error searching: ${error}`, 400)
  }
}

Search Schema | Filters | Custom Metadata

On this page