App Templates: Detailed Guide

Publishing the data scope of your own app (hds-dataset.json)? See Data-set templates.

appTemplates holds the building blocks HDS apps use for consent-based data collection: a requester (doctor, researcher, bridge) describes the data it wants, the account owner (patient) accepts, and both sides then work with the resulting relationship.

Since hds-lib 1.0.0 the consent exchange itself runs on CMC (the consent-management plugin of the Pryv core, client SDK @pryv/cmc, re-exported as cmc). This library adds the HDS layer on top of it:

Removed in 1.0.0: Collector, CollectorInvite, CollectorClient, AppManagingAccount.createCollector / getCollectors / getCollectorById, AppClientAccount.handleIncomingRequest / getCollectorClients, and the applications/<app>/<collectorId>-* stream layout with its */collector-v1 event types. See the 1.0.0 entry of the CHANGELOG for the migration notes.


Overview

sequenceDiagram
    participant D as Doctor app
    participant DC as Doctor account
    participant P as Patient app
    participant PC as Patient account

    D->>D: edit a CollectorRequest, convert it to a FormSpec
    D->>DC: cmcAppScope.ensureAppScope(conn, 'hds-collector', collectorId)
    D->>DC: cmcFormSpec.saveFormSpec(conn, scopeStreamId, formSpec)
    D->>DC: cmcFormSpec.createInviteWithFormSpec(conn, params)
    DC-->>D: { inviteEventId, capabilityUrl }
    D-->>P: capabilityUrl (link, QR code, email)
    P->>DC: cmcFormSpec.readOfferWithFormSpec(capabilityUrl)
    P->>PC: cmc.acceptInvite(conn, capabilityUrl, { scopeStreamId })
    P->>PC: cmcFormSpec.mirrorFormSpecOnAcceptEvent(conn, acceptEventId, formSpec)
    P->>PC: Questionnaire.writeBundled(conn, { questionnaires }, streamIds)
    P->>P: Contact.aggregateCmc(accesses, accepts, patientScope)

The doctor side reads the patient data through the data-grant access the CMC plugin creates on accept. Relationship lifecycle (refuse, revoke, scope updates, chat, system messages) is handled by cmc.* directly; see the @pryv/cmc README.


Application (base class)

AppManagingAccount and AppClientAccount both extend Application. An Application is defined by:

Instantiation

import { appTemplates } from 'hds-lib';

// From an API endpoint
const app = await appTemplates.AppClientAccount.newFromApiEndpoint(
  'my-app',      // baseStreamId (at least 2 characters)
  apiEndpoint,
  'My App'       // appName
);

// From an existing connection
const app2 = await appTemplates.AppClientAccount.newFromConnection('my-app', connection, 'My App');

Both factories accept an optional fourth argument, features: { streamsAutoCreate?: boolean } (default true, attaches a toolkit.StreamsAutoCreate instance to the connection). Both call init(), which checks the access and creates applications and applications/<baseStreamId> when missing.

Access requirements checked by init():

A custom Application subclass can require the * / manage access by returning mustBeMaster: true from appSettings. AppManagingAccount and AppClientAccount do not: their apps log in with scoped accesses.

appName is required unless the subclass takes it from the access info. AppManagingAccount does (appNameFromAccessInfo), so its appName argument is optional and is replaced by the access name.

Custom settings

Arbitrary app settings are stored as a single settings/any event in baseStreamId.

await app.setCustomSettings({ theme: 'dark', notifyEmail: true }); // replace all
const settings = await app.getCustomSettings();                    // cached; pass true to refetch
await app.setCustomSetting('theme', 'light');                      // update one key
await app.setCustomSetting('notifyEmail', null);                   // delete one key

Stream data

app.streamData;              // the baseStreamId stream (with children), loaded by init()
await app.loadStreamData();  // refetch it

Stream structure

applications/
└── {baseStreamId}/          app root stream
    └── [settings/any event] custom settings

AppManagingAccount and AppClientAccount

Since 1.0.0 both classes are the Application surface only; they no longer manage forms or relationships.

Class Side Differences from Application
AppManagingAccount doctor / researcher appName taken from the access info
AppClientAccount patient appName required

Typical use is as the target of HDSSettings.hookToApplication(app) (see Settings). Form storage goes through cmcFormSpec, invites and relationships through cmc, and the patient’s relationship list through Contact.aggregateCmc.


CollectorRequest

CollectorRequest is the in-memory editor state of a data request. It is used by FormBuilder UIs (hds-forms-js), converted to a FormSpec for storage and invites, and rebuilt from a FormSpec for editing. It does no I/O.

Creating

const { CollectorRequest } = appTemplates;

const request = new CollectorRequest({});          // empty
const loaded = new CollectorRequest(content);      // from a serialized `content`
request.setContent(otherContent);                  // merge more content in

setContent reads the keys described below and validates them. A version of 0 is converted to version 1; any other version than 1 throws. Keys it does not know are kept and returned unchanged by request.content. An unknown key inside features throws.

Properties

Property Type Description
version number Always 1 (read-only)
title localizableText Request title
description localizableText Request description
consent localizableText Consent text shown to the patient
requesterName string Name of the requester
appId string Application identifier
appUrl string Application URL
appCustomData any Arbitrary app metadata (serialized as app.data)
permissions PermissionItem[] Permissions (read-only array, see Permissions)
permissionsExtra PermissionItemLight[] Extra permissions not linked to items (read-only array)
features { chat?: { type: 'user' \| 'usernames' } } Optional features
hasChatFeature boolean true when features.chat is set
sections CollectorRequestSection[] Form sections (read-only array)
sectionsData object[] Serialized sections
existingStreamRefs ExistingStreamRef[] Access asks on pre-existing streams
customFields CustomFieldDeclaration[] Template-private streams to provision
questionnaires QuestionnaireRequestContent[] Bundled questionnaires
content object Full serializable payload

title, description, consent and section names accept either a localizableText object or a plain string. A plain string is stored as the English text ('Weight' becomes { en: 'Weight' }), matching the AppTemplate schema.

Sections

Sections group the requested data items. Each section has a type:

const profile = request.createSection('profile-data', 'permanent');
profile.setName({ en: 'Profile', fr: 'Profil' });
profile.addItemKeys(['profile-name', 'profile-date-of-birth']);

const daily = request.createSection('daily-measures', 'recurring');
daily.setName('Daily measurements');
daily.addItemKeys(['body-weight']);
daily.setItemCustomization('body-weight', { repeatable: 'P1D' });

daily.moveItemKey('body-weight', 0);
request.moveSection('daily-measures', 0);
request.getSectionByKey('daily-measures');
request.removeSection('profile-data');

createSection throws if the key already exists. addItemKey throws if the item key is unknown to the loaded HDS model, and ignores duplicates.

CollectorRequestSection

Property / method Description
key Section identifier
type 'permanent' or 'recurring'
name Localized section name
itemKeys Item keys, in display order
itemCustomizations Per-item customization map
customFieldKeys Keys of request.customFields[] entries shown in this section
addItemKey(key) / addItemKeys(keys) Add items (validated against the model)
removeItemKey(key) Remove an item (throws if absent)
moveItemKey(key, toIndex) Reorder an item
setName(localizableText \| string) Set the section name
setNameLocal(lang, name) Set the name for one language
setItemCustomization(key, data) / getItemCustomization(key) Per-item customization
setCustomFieldKeys(keys) / addCustomFieldKey(key) Reference custom fields
getData() Serializable section object

Permissions

// Rebuild `permissions` from every section's itemKeys plus `permissionsExtra`
request.buildPermissions();

// Extra permission not linked to an item (defaultName and level are optional)
request.addPermissionExtra({ streamId: 'profile', level: 'read' });

// Manual management
request.addPermission('body', 'Body', 'read');
request.addPermissions([{ streamId: 'body', defaultName: 'Body', level: 'read' }]);
request.resetPermissions();

buildPermissions() uses getHDSModel().authorizations.forItemKeys(itemKeys, { preRequest: permissionsExtra }): one read permission per item stream, the extras merged in (a missing defaultName is taken from the model streams, a missing level defaults to read), and a permission dropped when a parent stream already grants the same or a higher level. permissionsExtra itself is not modified.

buildPermissions() does not look at existingStreamRefs or customFields. Wherever permissions are rebuilt from item keys, re-add the read grant on app-private refs with appTemplates.withAppPrivatePermissions(request.permissions, request.existingStreamRefs) (see Data-set templates).

Chat feature

request.addChatFeature();                       // { type: 'user' }
request.addChatFeature({ type: 'usernames' });
request.addChatFeature(true);                   // FormSpec / AppTemplate shape: same as { type: 'user' }
request.addChatFeature(false);                  // removes chat
request.hasChatFeature;                         // boolean

setContent accepts the same values in features.chat, so a FormSpec with features: { chat: true } or { chat: false } loads as is. request.content.features.chat is always serialized as the object form ({ type: 'user' }) or absent.

Existing stream refs and custom fields

// Access on a stream that already exists on the patient account (not provisioned)
request.addExistingStreamRef({
  streamId: 'cycle-app-notes',
  permissions: ['read'],          // 'read' | 'contribute' | 'manage', non-empty
  purpose: 'app-private',         // optional
  label: { en: 'Daily notes' }    // optional
});

// A template-private stream provisioned at acceptance
request.addCustomField({
  streamId: 'my-template-mood-note',   // must start with `${def.templateId}-`
  eventType: 'note/txt',               // note/txt | note/html | count/generic | date/iso-8601 | activity/plain
  def: { version: 'v1', templateId: 'my-template', key: 'mood-note', label: { en: 'Mood note' } }
});
request.getSectionByKey('daily-measures').addCustomFieldKey('mood-note');

The full custom-field rules (clientData.hdsCustomField, sandbox prefix, the three stream-reference modes) are in ts/appTemplates/CUSTOM-FIELDS-AND-SYSTEM.md.

Questionnaires

A request can bundle one or more questionnaires to deliver at first contact.

request.addQuestionnaire(questionnaire);       // a Questionnaire instance or raw request content
request.questionnaires;                        // QuestionnaireRequestContent[]
request.getQuestionnaire(0);                   // a fresh Questionnaire over entry 0, or null
request.removeQuestionnaire(0);                // true if it existed

// Do the request's permissions cover every item the questionnaire asks about?
const report = request.checkQuestionnaireCoverage(questionnaire);   // read-only
// report: { ok, perQuestion[], unknownItems[], proposedPermissions[] }

// Same, and add the missing permissions to the request
request.applyQuestionnaireCoverage(questionnaire);

The standalone helper is appTemplates.checkQuestionnaireCoverage(questionnaire, request); request can be any object with a permissions array.

Content

request.content returns:

{
  version: 1,
  title, consent, description,
  requester: { name },
  features: { chat? },
  permissionsExtra: [...],
  permissions: [...],
  app: { id, url, data },
  sections: [ { key, type, name, itemKeys, itemCustomizations?, customFieldKeys? } ],
  existingStreamRefs?: [...],      only when non-empty
  customFields?: [...],            only when non-empty
  questionnaires?: [...],          only when non-empty
  ...unknown keys given to setContent
}

CollectorRequest and FormSpec

The FormSpec (below) is the stored and shared form of a request. The two shapes overlap but are not identical:

CollectorRequest FormSpec
features.chat: { type } features.chat: boolean
app.data (appCustomData) appCustomData
requester, app.id, app.url, permissionsExtra not stored
questionnaires not in the FormSpec type, carried as an extra key on the snapshot

new CollectorRequest(formSpec) loads a FormSpec (the boolean features.chat and the plain-string texts are accepted); FormSpec-only keys such as appCustomData, source and openLink are kept as extra content, not mapped. To go the other way, build the FormSpec explicitly:

const formSpec = {
  version: 1,
  title: request.title,
  description: request.description,
  consent: request.consent,
  permissions: request.permissions,
  sections: request.sectionsData,
  features: { chat: request.hasChatFeature },
  ...(request.customFields.length > 0 ? { customFields: request.customFields } : {}),
  ...(request.existingStreamRefs.length > 0 ? { existingStreamRefs: request.existingStreamRefs } : {})
};

Questionnaire

A Questionnaire is a reusable set of questions with a temporal scope. It serializes to the content of a questionnaire/request-v1 event; answers are questionnaire/answer-v1 events. Storage shape: data-model/documentation/QUESTIONNAIRE.md.

const { Questionnaire } = appTemplates;

const q = new Questionnaire({ title: { en: 'Intake' } });
q.addQuestion('weight', {
  label: { en: 'Your weight' },
  itemRef: 'body-weight',
  scope: { type: 'latest', withinDays: 30 }   // or { type: 'ever' } / { type: 'window', withinDays }
});
q.questionKeys;                 // ['weight']
q.getQuestion('weight');
q.removeQuestion('weight');
q.toRequestEventContent();      // throws when there is no question

Questionnaire.fromRequestEvent(event);                            // load from a request event
Questionnaire.makeRequestEvent(content, streamIds, timeSeconds);  // ready-to-write events.create params
await Questionnaire.writeBundled(connection, { questionnaires }, streamIds); // one events.create per entry
Questionnaire.buildAnswerEvent(requestEventId, answers, knownKeys); // { content, clientData: { related } }

Question keys must match [a-zA-Z0-9_-]+. Answer statuses are answered (with non-empty references), no, unknown and declined (optional reason).


CMC FormSpec flow

The helpers below are exported as cmcFormSpec, cmcAppScope and cmcConstants (import { cmcFormSpec } from 'hds-lib'). The CMC SDK itself is exported as cmc.

FormSpec

interface FormSpec {
  version: 1;
  title: localizableText;
  description: localizableText;
  consent?: localizableText;
  permissions: Permission[];             // may be empty for a chat-only data set
  sections: AppTemplateSection[];
  features?: { chat?: boolean };
  customFields?: CustomFieldDeclaration[];
  existingStreamRefs?: ExistingStreamRef[];
  appCustomData?: { requiredBridges?: string[]; [key: string]: any };
  source?: FormSpecSource;               // imported from a data-set template URL
  openLink?: FormSpecOpenLink;           // the data set's permanent invite
}

Where a FormSpec lives:

Doctor side

import { cmc, cmcFormSpec, cmcAppScope, cmcConstants } from 'hds-lib';
const { CMC_APP_CODES } = cmcConstants;

// 1. Scope stream for the data set (creates the sub-scope if missing)
const collectorId = 'my-data-set';
const scopeStreamId = await cmcAppScope.ensureAppScope(connection, CMC_APP_CODES.COLLECTOR, collectorId);

// 2. Store the FormSpec (create or update the one hds-form-spec/v1 event)
const issues = cmcFormSpec.validateFormSpecItemKeys(formSpec); // [] when every item key resolves
await cmcFormSpec.saveFormSpec(connection, scopeStreamId, formSpec);

// 3. Read FormSpecs back
const records = await cmcFormSpec.listFormSpecs(connection);            // FormSpecRecord[]
const record = await cmcFormSpec.getFormSpecById(connection, collectorId); // { collectorId, formSpec, event } | null

// 4. Invite a patient, with the FormSpec snapshot on the offer
const { inviteEventId, capabilityUrl } = await cmcFormSpec.createInviteWithFormSpec(connection, {
  appCode: CMC_APP_CODES.COLLECTOR,
  scopeStreamId,
  displayName: 'Dr. Smith',
  requestedPermissions: cmcFormSpec.deriveCmcPermissions(formSpec),
  formSpec,
  features: { chat: !!formSpec.features?.chat },
  mode: 'single-use'                     // or 'open-link'
});

Notes:

Patient side

// Once per account: the stream behind the chat-only placeholder permission
await cmcFormSpec.provisionHdsNoop(connection);

// Before accepting (a single-use capability is consumed by the accept)
const { content, hdsFormSpec } = await cmcFormSpec.readOfferWithFormSpec(capabilityUrl, { pryv });

// Accept through the CMC SDK
const result = await cmc.acceptInvite(connection, capabilityUrl, {
  scopeStreamId: cmc.appScope(CMC_APP_CODES.PATIENT),
  accessName: 'my-app-' + someUniqueSuffix,
  waitForCompletion: true
});

// Keep the snapshot with the patient's own accept event
if (hdsFormSpec) {
  await cmcFormSpec.mirrorFormSpecOnAcceptEvent(connection, result.acceptEventId, hdsFormSpec);
}

// Materialize bundled questionnaires, if the snapshot carries any
if (hdsFormSpec?.questionnaires?.length) {
  await appTemplates.Questionnaire.writeBundled(connection, { questionnaires: hdsFormSpec.questionnaires }, [cmc.appScope(CMC_APP_CODES.PATIENT)]);
}

readOfferWithFormSpec throws when the offer stream is empty; an offer without a FormSpec (a bridge invite, for instance) returns hdsFormSpec: null.

Constants and scope helpers

Export Value / behaviour
cmcConstants.CMC_APP_CODES PATIENT: 'hds-patient', COLLECTOR: 'hds-collector', BRIDGE_MIRA: 'hds-bridge-mira', BRIDGE_ATHENA: 'hds-bridge-athena'
cmcConstants.CMC_EVENT_TYPES INVITE_TRIGGER: 'consent/request-cmc', ACCEPT: 'consent/accept-cmc'
cmcConstants.appSubScope(appCode, sub) :_cmc:apps:<appCode>:<sub>
cmcConstants.extractAppSubScopeSuffix(streamId, appCode) the <sub> part of such a stream id
cmcAppScope.ensureAppScope(conn, appCode, subPath?) returns :_cmc:apps:<appCode> (provisioned by the core), and creates :<subPath> under it when given
cmcAppScope.pryvErrorCode(error) the Pryv error id of a thrown error
cmcFormSpec.FORM_SPEC_EVENT_TYPE 'hds-form-spec/v1'
cmcFormSpec.HDS_NOOP_STREAM_ID / HDS_NOOP_PERMISSION 'hds-noop' / { streamId: 'hds-noop', level: 'read' }

Data-export requests

cmcDataExport carries a patient’s data-export request over the CMC system channel (notification/alert-cmc, fulfilled by the matching notification/ack-cmc), on the relationship’s <scope>:collectors:<peerSlug> stream (CmcRelationship.localCollectorStreamId on the patient side):

import { cmcDataExport } from 'hds-lib';
// Patient side: post the request on the own collectors stream
const { alertEventId, ackId } = await cmcDataExport.requestDataExport(patientConnection, { collectorStreamId, note: 'Please send my data' });

// Either side: list requests and their status ('pending' | 'fulfilled')
const requests = await cmcDataExport.listDataExportRequests(doctorConnection, doctorCollectorStreamId);

// Doctor side: acknowledge a received request
await cmcDataExport.fulfillDataExportRequest(doctorConnection, { collectorStreamId: doctorCollectorStreamId, alertEventId: requests[0].alertEventId, ackId: requests[0].ackId });

buildDataExportRequestContent and parseDataExportEvents are the pure parts of the same helpers.

Sharing one type of data at a time

cmcScope lets the person stop sharing one stream with a connection, share it again, and read those changes back, over the CMC scope-update path. Personal token only (the platform refuses scope updates from app tokens; apps holding one hand off to the account app with cmc.requestScopeUpdate). Each helper writes a consent/scope-update-cmc on the relationship’s localCollectorStreamId; the platform applies the new permission set to the grant (keeping its own :_cmc:* channel permissions) and delivers the content to the connection.

What the connection is told rides in the HDS sub-envelope content.hds, never in new top-level fields: { removed?: string[], added?: string[], asked?: string[], message?: { en, fr, … } }.

import { cmc, cmcScope, appTemplates } from 'hds-lib';
const rel = contact.cmcRelationships[0];     // { accessId, localCollectorStreamId, grantedPermissions }

// Can this stream be stopped on its own? ('not-shared' | 'shared-through-parent' | 'nothing-left' otherwise)
const plan = cmcScope.planStopSharing(rel.grantedPermissions, 'body-weight');

// Stop / share again: resolve once the grant changed; peerNotified false = changed, connection not told yet
await cmcScope.stopSharingStream(connection, rel, 'body-weight', { message: { en: '…', fr: '…' } });
await cmcScope.shareStreamsAgain(connection, rel, ['body-weight'], { message: { en: '…' } });

// The conversation's notes: 'stopped' | 'shared' | 'request' (with its answer), oldest first
const notes = await cmcScope.listSharingNotes(connection, rel.localCollectorStreamId);

// A connection's request: compare, then answer through cmc
const diff = cmcScope.scopeDiff(rel.grantedPermissions, request.permissions); // { added, removed, changed }
await cmc.acceptScopeUpdate(connection, request.id);   // or cmc.refuseScopeUpdate(connection, request.id)

Requests are answered with cmc.acceptScopeUpdate / refuseScopeUpdate, which write a fixed content: hds.asked is read by listSharingNotes when an answer carries it, but these calls cannot record it.

planStopSharing / stopSharingStream take { parentIds?, allowChatOnly? } for a stream the data model does not know: its parents in the account’s own tree, and whether a remainder of hds-noop only (chat-only) is accepted.

Updating a consented person’s data set

A person keeps a frozen copy of the data set (hdsFormSpec on her consent/accept-cmc event). A changed data set reaches her inside a scope request, content.hds = { formSpec, formSpecHash } (data-model 3.41.0, CUSTOM-FIELDS-AND-SYSTEM.md), and her acceptance replaces her copy. Every change goes this way, one within the current grant included (the request then carries the unchanged permission set). openLink never leaves the connection’s account: formSpecForPerson strips it, for invites (createInviteWithFormSpec) and requests alike.

import { cmcScope } from 'hds-lib';

// Connection side (the doctor), on the relationship's own collectors stream
const sent = await cmcScope.proposeDataSetUpdate(connection, rel.localCollectorStreamId, formSpec, {
  newPermissions,                      // default: what the data set needs; may be wider, never narrower
  message: { en: '…', fr: '…' },
  // expires: default now + 30 days (DATASET_REQUEST_TTL_SEC)
});                                    // { scopeRequestEventId, remoteScopeRequestEventId, status, formSpecHash }
const requests = await cmcScope.listScopeRequests(connection, rel.localCollectorStreamId);
// each: { delivery, answer: 'accepted' | 'refused' | 'expired' | null, superseded, formSpecHash, reason, asked, … }
const held = cmcScope.acceptedDataSetHash(requests) ?? await cmcFormSpec.formSpecFingerprint(inviteSnapshot);

// Person side: notes of kind 'request' carry `formSpec` and `superseded` (never offer a superseded one)
const res = await cmcScope.acceptDataSetUpdate(connection, note, acceptEventId); // { dataSetUpdated, … }

proposeDataSetUpdate throws HDSLibError with reason permissions-do-not-cover (before writing), previous-request-in-flight (an earlier request of the stream is neither delivered, failed nor expired: requests go out in order), delivery-failed or delivery-pending (with scopeRequestEventId). An unanswered request carrying a data set is superseded once a later one carries a data set, answered or not.

Documents in a conversation

cmcDocuments shares one document with one contact, takes it back, and lets a contact send documents (data-model 3.37.0, DOCUMENTS.md § document-shares). A document stays one event on documents; sharing adds the id of an opaque share child (document-shares/share-<12 base36>, one per relationship and direction) that only that contact is granted. The other side is told by a notification/alert-cmc with content.hds = { kind, eventId }.

import { cmcDocuments } from 'hds-lib';
// The person (personal token)
await cmcDocuments.shareDocument(connection, rel, eventId);       // child + read grant (once), id added, alert
await cmcDocuments.unshareDocument(connection, rel, eventId);     // id removed, 'document-unshared' alert
await cmcDocuments.stopSharingDocuments(connection, rel);         // the contact's read on the child removed
const { receiveStreamId } = await cmcDocuments.allowDocumentsFrom(connection, rel); // create-only (Route B)
await cmcDocuments.receiveDocument(connection, sentEventId, { maxBytes }); // checked, moved to ['documents']
await cmcDocuments.cleanupDocumentShares(connection, cmcDocuments.relationshipIdOf(rel.accessId)); // full revoke
// The contact (data grant + own connection)
const { receiveStreamId: to } = cmcDocuments.shareStreamsOf(grantPermissions); // { shared, received }
await cmcDocuments.sendDocument(personConnection, ownConnection, { receiveStreamId: to, collectorStreamId, content, file, filename });
// Either side: one bubble per (kind, eventId); a 'document-shared' is current until a later 'document-unshared'
const alerts = await cmcDocuments.listDocumentAlerts(connection, collectorStreamId);

shareDocument posts only the alert when the contact already reads documents or * (mode: 'all'). Stream changes are written, re-read and checked (concurrent-update when another device changed them meanwhile). Readers label a share child from its direction, never from its name.

cmcItemReminders lets a contact remind the person to fill in one item of its request (data-model 3.40.0, kind item-reminder): a notification/alert-cmc with content.hds = { kind: 'item-reminder', itemKey }. Nothing else changes: no reminder state, and the item’s own reminder definition (computeReminders) is not involved.

import { cmcItemReminders } from 'hds-lib';
// The contact: only an enterable item of a section of its request (else HDSLibError, reason not-in-request / ...)
await cmcItemReminders.postItemReminder(ownConnection, collectorStreamId, 'body-weight', { sections, locale: 'fr' });
const last = cmcItemReminders.lastRemindedAt(await cmcItemReminders.listItemReminders(ownConnection, collectorStreamId));
// The person: the delivered ones (direction 'received'), and whether the form opens, from that relationship's grant
const reminders = (await cmcItemReminders.listItemReminders(connection, collectorStreamId)).filter(r => r.direction === 'received');
const action = cmcItemReminders.reminderAction(itemKey, rel.hdsFormSpec.sections, rel.grantedPermissions); // { ok, section, sectionIndex } | { ok: false, reason }

reminderAction checks the stream the entry would be written to (the section’s itemCustomizations[itemKey].context when it is a model descendant of the item’s stream, else the item’s stream) against the grant (that stream, a model parent, or *, at a read level), trying every section that lists the item. sectionIndex is the index in the sections passed, not in an app’s list of all its contacts’ sections: map it, or open the form on section.

Permission labels

appTemplates.dataName(streamId, { locale?, fallback? }) names the data a stream holds from the data model (the item filed there). appTemplates.permissionView(permission, { locale? }) reads a requested or granted permission for a person: { kind, name, label, level, beyondRead, inModel, hasChildren, requesterLabel }. The name always comes from the model; a requester’s own label (defaultName) is only shown quoted when the model does not know the stream (inModel: false), so a wide grant cannot pose as a narrow one. beyondRead flags a level above read and *.


Contact

A Contact groups the CMC relationships a patient has with one counterparty (a doctor, a researcher, or a bridge service). It is built from the patient’s local counterparty accesses (clientData.cmc.role === 'counterparty'), which are the source of truth; accept events only add acceptedAt and acceptEventId. Accept events without a live counterparty access are dropped.

const accesses = await connection.apiOne('accesses.get', {}, 'accesses');
const accepts = await cmc.listAcceptedRelationships(connection);
const contacts = appTemplates.Contact.aggregateCmc(accesses, accepts, cmc.appScope(CMC_APP_CODES.PATIENT));

cmc.listAcceptedRelationships does not return hdsFormSpec. To get rel.hdsFormSpec (and so cmcFormSpecs / cmcFormSections), pass accept records that carry the mirrored content.hdsFormSpec of the patient’s consent/accept-cmc events.

Fields

Field Type Description
remoteUsername string \| null Counterparty username
displayName string Display name (the counterparty username, can be overridden)
counterparty { username, host } \| null CMC counterparty identity
kind 'person' \| 'service' \| 'unknown' service for an hds-bridge-* app code
cmcRelationships CmcRelationship[] One per counterparty access
accessObjects any[] The raw counterparty accesses

Each CmcRelationship has accessId, acceptEventId, counterparty, counterpartyApiEndpoint, remoteChatStreamId, remoteCollectorStreamId, localChatStreamId, localCollectorStreamId, appCode, features ({ chat, systemMessaging }), grantedPermissions, acceptedAt and hdsFormSpec.

Getters

Getter Description
status 'Active' when a relationship has acceptedAt, 'Incoming' when there are relationships but none accepted, else null
isActive a relationship has acceptedAt
isPerson remoteUsername !== null
hasChat / cmcHasChat a relationship has chat
cmcIsActive at least one relationship
appStreamIds distinct app codes
allPermissions / cmcAllPermissions granted permissions, deduplicated by streamId:level
accessIds ids of the backing accesses
cmcChatStreams { read, write, counterpartyApiEndpoint, accessId }[] for chat-enabled relationships
cmcFormSpecs the mirrored FormSpecs
cmcFormSections the sections of those FormSpecs

Methods

contact.initStreamCache(streamsById);   // build the set of streams this contact may see
contact.eventIsAccessible(event);       // person: by stream permissions; service: by author or bridge streams
contact.eventIsFromContact(event);      // event.modifiedBy matches one of the contact's accesses
contact.chatEventInfos(event);          // { source: 'me' | 'contact' | 'unknown' }
contact.addAccessObject(access);
appTemplates.Contact.cmcDetectKind(appCode);

Call initStreamCache before eventIsAccessible; until then a person contact sees nothing.

Pre-CMC chat (read-only)

Chat written before the June 2026 switch to CMC is still in the accounts as message/hds-chat-v1 events (data-model appStreams chat; the content is the text) in two streams named after the contact’s account: chat-<remoteUsername> (what the person wrote) and chat-<remoteUsername>-in (what the contact wrote). The CMC migration did not move them, and chatEventInfos only knows the CMC chat streams, so they need their own reader:

contact.legacyChatStreams;              // { outgoing: 'chat-drandy', incoming: 'chat-drandy-in' }, null without remoteUsername
contact.legacyChatSource(event);        // 'me' | 'contact' | 'unknown'
contact.legacyChatMessages(events);     // [{ id, text, time, fromContact }], oldest first

The same functions take the username as first argument at appTemplates.legacyChatStreams(username), appTemplates.legacyChatSource(event, username) and appTemplates.legacyChatMessages(username, events); appTemplates.LEGACY_CHAT_TYPE is the eventType. The stream gives the direction (each side could only write its own stream). Trashed events, empty texts and non-text contents are left out. Nothing is written or migrated: show them read-only, e.g. as “Earlier messages” above the CMC thread. Query the events with the two stream ids above and types: [LEGACY_CHAT_TYPE].


AppTemplate JSON

An AppTemplate is a JSON description of a data request: id, title, description, chat (boolean), sections[], and optionally customFields[], existingStreamRefs[] and license. A published data-set template is an AppTemplate with publication fields; see Data-set templates for the fields, the JSON Schema URL, versioning and the import helpers (templateToFormSpec, templateScopeHash, diffTemplateScope, …).

const tpl = appTemplates.loadTemplate(json);                 // validate; throws HDSLibError
const tpl2 = await appTemplates.loadTemplateFromUrl(url);    // https only, then loadTemplate
appTemplates.isCustomFieldDeclaration(value);
appTemplates.isExistingStreamRef(value);

loadTemplate checks the schema and cross-field rules: every customFields[].streamId starts with <id>-, def.templateId equals id, def.key matches the stream id suffix, section references resolve, an existing-stream ref is not also a custom field (and does not use the template prefix unless its purpose is app-private), and itemCustomizations[*].repeatable follows the data-model grammar.


Other appTemplates helpers

Export Purpose
getOrCreateBridgeAccess(conn, { name, permissions, clientData? }) Find a bridge access by name, or create it
ensureBridgeAccess(conn, { ..., updateIfDifferent? }) Same, and optionally update its permissions in place
findConnectorAccesses, getOrCreateConnectorAccess, connectorAccessRefusal, connectorAccessName, syncStatusLeafFor, connectorCmcAppCode, connectorIdFromCmcAppCode, markConnectorDisconnected, disconnectedStatusContent, CONNECTOR_STATUS_TYPE Catalogue connectors (bridges): the user’s connection to a connector, its minimal access, its sync-status/connector-v1 status
executeHook, executeDisconnect, expand Run a connector’s connect / disconnect hook descriptor; expand fills the ${variable} placeholders of a hook template
offerStreamsToCreate, offerStreamsToApiCalls Streams to create on the patient account for offered permissions
getSectionItemLabels, collectItemLabelsFromSections, collectItemLabels Per-item label overrides from section customizations (collectItemLabels walks contact.cmcRelationships[].hdsFormSpec.sections)
buildStreamMap, resolveStreamCustomField, resolveStreamCustomFieldDetailed, streamCustomFieldToVirtualItem, customFieldDeclarationToVirtualItem, isEmptyDef Custom-field resolution (see CUSTOM-FIELDS-AND-SYSTEM.md)

Interfaces

Shapes used above:

type RequestSectionType = 'recurring' | 'permanent';

interface CollectorSectionInterface {
  key: string;
  type: RequestSectionType;
  name: localizableText;
  itemKeys: string[];
  itemCustomizations?: Record<string, Record<string, unknown>>;
}

interface Permission {
  streamId: string;
  defaultName?: string;
  level: string;
}

interface ExistingStreamRef {
  streamId: string;
  permissions: Array<'read' | 'manage' | 'contribute'>;
  purpose?: string;
  label?: localizableText;
}

Questionnaire types (QuestionDef, QuestionScope, QuestionSubField, AnswerEntry, AnswerStatus, QuestionnaireRequestContent, QuestionnaireAnswerContent) are exported from appTemplates. Template types (AppTemplate, AppTemplateSection, CustomFieldDeclaration, ExistingStreamRef, DatasetTemplate) are exported from appTemplates and from the package root, as are FormSpec and FormSpecRecord.