@embeddables/protocols
v0.2.0
Published
Protocol navigation and eligibility client for Embeddables medical intake flows.
Keywords
Readme
@embeddables/protocols
Protocol navigation and eligibility for Embeddables medical intake flows.
Composes on @embeddables/core
for initialization parity and reads answers from initialized
@embeddables/forms
FormInstances — no form-data arguments on protocol instance methods.
Install
npm install @embeddables/protocols @embeddables/core @embeddables/formsFor React apps, also install react (peer dependency) and wrap your tree in
EmbeddablesProvider from @embeddables/core/react. Add protocols() to
modules, and pass your protocols through the provider's protocols prop.
React
Import hooks from @embeddables/protocols/react:
Example:
import { EmbeddablesProvider } from '@embeddables/core/react'
import { forms } from '@embeddables/forms/react'
import { protocols, useProtocol } from '@embeddables/protocols/react'
const schemas = {
'pre-intake': {
id: 'pre-intake',
fields: [
{ key: 'bmi', label: 'BMI', type: 'text', protocolFieldId: 'bmi' },
],
},
intake: {
id: 'intake',
fields: [
{
key: 'conditions',
label: 'Conditions',
type: 'multiselect',
protocolFieldId: 'exclusionary_conditions',
},
],
},
} as const
const myProtocol = {
id: 'my-protocol',
name: 'My Protocol',
version: '1.0.0',
ehr: 'openloop_health',
medicalIntakeQuestions: {
getFirstMedicalIntakeQuestionId: () => 'exclusionary_conditions',
questions: [
{
id: 'exclusionary_conditions',
type: 'multiselect',
text: 'Do any apply?',
shouldShowThisQuestion: () => true,
getContinueStatus: (formsData) =>
formsData.getValueByProtocolFieldId('exclusionary_conditions')
? { status: 'can_continue' }
: { status: 'no_reply' },
getNextQuestionId: () => null,
getPrevQuestionId: () => null,
},
],
},
canUserStartMedicalIntake: (formsData) =>
formsData.getValueByProtocolFieldId('bmi') !== undefined,
isUserEligible: () => ({ status: 'qualified', modality: 'async', statusReasons: [] }),
getQuestionById(id) {
return this.medicalIntakeQuestions.questions.find((question) => question.id === id)
},
} as const
function IntakeQuestion() {
const { question, continueStatus, goToNextQuestion } = useProtocol({
protocolId: 'my-protocol',
})
return (
<div>
<p>{question?.text}</p>
<button
type="button"
disabled={continueStatus?.status !== 'can_continue'}
onClick={() => goToNextQuestion()}
>
Continue
</button>
</div>
)
}
export function App() {
return (
<EmbeddablesProvider
modules={[forms(), protocols()]}
protocols={{ formIds: ['pre-intake', 'intake'], protocols: [myProtocol] }}
>
<IntakeQuestion />
</EmbeddablesProvider>
)
}formIdslists the forms your protocols may read. They are resolved once and shared by every protocol inprotocols, so all of them see the same answers.- Protocols read those answers only through
protocolFieldId, never through a form's field keys — seegetValueByProtocolFieldIdin the example above. - Configuring
protocolsrequiresforms()inmodules. The order ofmodulesdoes not matter; the provider initializes Forms first on its own. useProtocol({ protocolId })returns{ instance, currentQuestionId, question, shouldShowQuestion, continueStatus, canStartMedicalIntake, eligibility, goToNextQuestion, goToPreviousQuestion, goToQuestion }. It throws ifprotocols()was not registered orprotocolIdis unknown.useProtocolalso returnsinstance: ProtocolInstancefor direct access to any method (arbitrary question ids,id/name/version, etc.) beyond the current-question convenience fields.
Quick start
import { initEmbeddables } from '@embeddables/core'
import { initForms, type FormSchema } from '@embeddables/forms'
import { initProtocols, type Protocol } from '@embeddables/protocols'
const preIntakeSchema = {
id: 'pre-intake',
fields: [
{ key: 'bmi', label: 'BMI', type: 'number', protocolFieldId: 'bmi' },
{
key: 'sex',
label: 'Sex assigned at birth',
type: 'select',
protocolFieldId: 'sex_assigned_at_birth',
},
],
} as const satisfies FormSchema
const intakeSchema = {
id: 'intake',
fields: [
{
key: 'conditions',
label: 'Conditions',
type: 'multiselect',
protocolFieldId: 'exclusionary_conditions',
},
],
} as const satisfies FormSchema
const core = initEmbeddables({
projectId: 'proj_…',
forms: [],
experiments: [],
})
const { getForm } = initForms({
core,
schemas: { 'pre-intake': preIntakeSchema, intake: intakeSchema },
})
const preIntakeForm = getForm({ formId: 'pre-intake' })
const intakeForm = getForm({ formId: 'intake' })
const protocol = {
id: 'my-protocol',
name: 'My Protocol',
version: '1.0.0',
ehr: 'openloop_health',
medicalIntakeQuestions: {
getFirstMedicalIntakeQuestionId: () => 'exclusionary_conditions',
questions: [
{
id: 'exclusionary_conditions',
type: 'multiselect',
text: 'Do any apply?',
shouldShowThisQuestion: () => true,
getContinueStatus: (formsData) =>
formsData.getValueByProtocolFieldId('exclusionary_conditions')
? { status: 'can_continue' }
: { status: 'no_reply' },
getNextQuestionId: () => null,
getPrevQuestionId: () => null,
},
],
},
canUserStartMedicalIntake: (formsData) =>
formsData.getValueByProtocolFieldId('bmi') !== undefined,
isUserEligible: () => ({ status: 'qualified', modality: 'async', statusReasons: [] }),
getQuestionById(id) {
return this.medicalIntakeQuestions.questions.find((question) => question.id === id)
},
} satisfies Protocol
const instance = initProtocols({ core }).initProtocol({
protocol,
forms: [preIntakeForm, intakeForm],
})
const firstId = instance.getFirstQuestionId()
const question = instance.getQuestionById(firstId)
const visible = instance.shouldShowQuestion(firstId)
const continueStatus = instance.getContinueStatus(firstId)
const nextId = instance.getNextQuestionId(firstId)
const prevId = instance.getPrevQuestionId(firstId)
const canStart = instance.canStartMedicalIntake()
const eligibility = instance.isEligible()
void question
void visible
void continueStatus
void nextId
void prevId
void canStart
void eligibilityDeclare protocolFieldId on form fields that protocol logic reads. Pass
every initialized form to initProtocol({ protocol, forms }) once — instance
methods never take a form-data argument.
Protocol contract
The Protocol interface and related section/question/result types ship from this
package. A vendored per-project implementation can satisfies Protocol against
those types.
QuestionType
"text" | "email" | "number" | "boolean" | "select" | "multiselect" | "file"
ProtocolFieldValue
string | readonly string[] | number | boolean | FormFileRef | null | undefined
The bridge returns compatible values unchanged. Numbers must be finite, and file
references must have status: "done". null is an explicit skip; undefined
is unanswered. json form fields cannot declare protocolFieldId.
Variations
A variation is a Variation object exported from a vendored
embeddables/_protocols/<id>/<version>/variations/<variationId>/schema.ts.
Its id must match the folder name. name and description are structured
fields on the object for author ergonomics; a sibling README.md carries
additional prose documentation of the delta vs the base protocol.
Multiple variations compose in the order listed under variations: in
embeddables/config.yaml. The CLI inlines the base protocol and each configured
variation into one self-contained
embeddables/_dist/protocols/<id>/<version>/schema.ts via nested
.transform calls. There is no runtime chain-runner function to call — import
the generated schema export only.
Helpers for vendored protocols
getQuestionById, canUserStartMedicalIntake,
getContinueStatusForRequiredAlwaysContinueQuestions,
getContinueStatusForRequiredDisqualifyingQuestions,
getContinueStatusForNonRequiredAlwaysContinueQuestions, getAlwaysShowQuestion,
getConditionalShowQuestionOrContent, getNextVisibleQuestionId, and
getPrevVisibleQuestionId are protocol-agnostic — they only depend on the
Protocol contract shape, never on a specific protocol's questions or rules.
A vendored embeddables/_protocols/{protocolId}/{version}/schema.ts imports and
composes them directly instead of reimplementing this logic per protocol:
import {
getContinueStatusForRequiredAlwaysContinueQuestions,
getNextVisibleQuestionId,
type Protocol,
} from '@embeddables/protocols'
export const myProtocol: Protocol = {
// …
medicalIntakeQuestions: {
questions: [
{
id: 'some_question',
getContinueStatus: (formsData) =>
getContinueStatusForRequiredAlwaysContinueQuestions({
fieldId: 'some_question',
formsData,
}),
getNextQuestionId: (formsData) =>
getNextVisibleQuestionId({
protocol: myProtocol,
currentId: 'some_question',
formsData,
}),
// …
},
],
},
}Options
initProtocols
| Option | Purpose |
| ------ | ------- |
| core | Initialized @embeddables/core instance (required) |
initProtocol
| Option | Purpose |
| ------ | ------- |
| protocol | Protocol definition (required) |
| forms | Initialized @embeddables/forms instances whose fields declare protocolFieldId (required) |
When two forms declare the same protocolFieldId, the later form in the
forms array wins.
Errors
| When | Error |
| ---- | ----- |
| Bad core at init | ProtocolsError (thrown) |
| Bad protocol at init | ProtocolSchemaError (thrown) |
| Unknown question id on navigation/eligibility helpers | UnknownQuestionError (thrown; branch on .questionId) |
All SDK errors extend ProtocolsError.
