Telehealth scheduling, with notes the platform cannot read
Consent, identity, calendars and reminders — the integrations are the easy half. The interesting half is proving who can see what.
The integrations are the easy half: OTP, consent, calendars and reminders are all configured, not coded. The half that takes real time is proving Clinic A cannot see Clinic B — and encrypting the notes so that Supero cannot read them either.
What we are building
A multi-clinic telehealth group needs patients to book a video consult, complete an intake form, sign a consent document, and get reminded. Clinicians need a queue of today's visits with the intake attached. Practice managers need to run their own clinic and nothing else. Billing needs a card on file.
The integration list writes itself: one-time-passcode sign-in, SMS and WhatsApp reminders, transactional email, a shared calendar with video links, a payment gateway, object storage for uploads. What does not write itself is the boundary — a practice manager at Clinic A must not be able to enumerate Clinic B's patients, and "my appointments" has to mean mine even if someone edits the request.
Architecture at a glance
What actually happens on the wire
success:false and status_code: 409 inside. Branch on the body. Treat that refusal as a normal race — checking availability and taking the slot are deliberately two separate decisions.How it works
The clinic is a tenant, not a column
Supero's hierarchy is Domain → Project → Tenant → user or API key. Model each clinic as a tenant and isolation stops being application logic: the caller's credential resolves to a scope, and every read and write is enforced against it. A practice manager cannot widen their own scope by editing a request, because the scope was never in the request.
| Who | Role | Sees |
|---|---|---|
| Group IT | domain_admin | Everything, across every clinic |
| Regional lead | project_admin | One project, across its clinics |
| Practice manager | tenant_admin | One clinic: its users, its records |
| Clinician | tenant_user (or a custom role) | One clinic — and only their own records once you add an owner rule per entity. Without one, a tenant_user has full CRUD across the clinic. |
| Patient | tenant_user | Only their own appointments and documents |
| Auditor | viewer | Read-only within a clinic |
At login the platform derives a ui_view — admin for the managing roles, customer for everyone else — so the same deployed app renders the clinician console or the patient portal without a single role-string check in your UI code.
Per-owner scoping is the rule you would otherwise forget
A PolicyRule can pin rows to their owner. The platform stamps the caller onto the record; the client never supplies the owner, so "my appointments" cannot be forged by changing an id in a request. filter_match accepts exactly three values — $user.email, $user.uuid, $user.name — and filter_field must be a scalar field on the record holding that value, not a reference name. Get either wrong and the rule resolves to nothing: no filter is applied, and every patient sees every record. This is the one sample in this guide worth testing as a non-admin before you trust it.
POLICIES = [
PolicyDef(role="tenant_admin", default_access="full", rules=[]),
# A patient: their own visits and documents, and nothing else at all.
PolicyDef(role="tenant_user", default_access="none", rules=[
PolicyRule(entity="appointment", can_read=True, can_create=True,
filter_field="owner_username", filter_match="$user.name"),
PolicyRule(entity="consent_document", can_read=True,
filter_field="owner_username", filter_match="$user.name"),
PolicyRule(entity="provider", can_read=True), # to pick one
]),
PolicyDef(role="viewer", default_access="none", rules=[
PolicyRule(entity="appointment", can_read=True),
]),
]Encryption, precisely
There is no "encrypted": true schema flag, and that is the honest design. Protection for clinical free-text is client-side, and the guarantee is the strong one: you generate a 32-byte AES-256 key and hold it in your own secrets manager, Supero stores ENC:v1:<base64> values, and Supero never sees the key. Not "we promise not to look" — structurally cannot. That is a sentence worth saying out loud to a buyer, and it is the reason this architecture answers the PHI question rather than working around it.
from py_api_lib import ApiLib
# You generate and hold this. Supero never sees it and cannot decrypt without it.
encryption_key = my_secrets_manager.get("clinical-notes-key")
client = ApiLib(
api_key="ak_live_...",
enable_encryption=True,
encryption_key=encryption_key,
)- Encryption is a property of the client, not the field. Everything an encryption-enabled client writes is encrypted, bar a fixed skip list. To keep metadata filterable you need two clients — one with the key for notes, one without it for everything else — and to route each write through the right one.
- Nine fields are never encrypted:
uuid,fq_name,parent_uuid,parent_type,obj_type,name,topic, plus the two encryption meta fields. Timestamps survive because the server stamps them itself.display_nameanddescriptionare not on that list, so a record title written through an encrypted client renders as ciphertext. - A client without the key that reads a single record fails outright, naming the fields it could not decrypt. List and bulk reads behave differently: encrypted records are silently omitted. A short list, not an error, is the failure you will actually hit in a queue view — test both paths.
- Encrypted fields cannot be filtered, sorted or matched. Keep
status,start_time/end_timeand any amount outside the encrypted set. - Lose the key and the data is gone. There is no support-assisted recovery.
Integration points and data sources
| Need | service_id | Notes |
|---|---|---|
| Passcode sign-in / step-up | auth_otp | Issue and verify one-time passcodes over a configured channel |
| Reminders | sms, whatsapp | Twilio-backed; free trial test credentials exercise the API without sending |
| Confirmations, summaries | email or email_smtp | SendGrid, or your own SMTP host |
| Video visit + invite | google_calendar | create_event {summary, start_time, end_time, add_meet} |
| Card on file / copay | stripe_checkout, payment | Hosted session, mirrored onto a payment record |
| Uploads (referrals, images) | amazon_s3 (integration) + attachment (transactional) | Your bucket; the attachment service owns the upload/scan/archive lifecycle |
| Practice-management system | Database connector | PostgreSQL/MySQL/SQL Server in live_read — nothing copied into Supero |
| Anything not in the catalog | Plugin registry | Register your own HTTP endpoint with auth, retry and circuit-breaker settings |
How to build it on Supero
Describe the app, then fix the tenancy shape first
Generate from a description, then immediately answer one question before anything else: what is a tenant? Here it is a clinic. Get that wrong and every policy afterwards is compensating for it. Register the clinics in config.py's tenants list, default-tenant first.
⏱ ~30 minutes
Extend the two lifecycle services
Appointments and consent are not yours to model. Inherit them.
Appointment = {"schema_type": "object", "name": "Appointment", "namespace": "clinic",
"parent_type": "tenant", "extends": "appointment:base_appointment",
"fields": [{"name": "visit_reason", "type": "text"},
{"name": "modality", "type": "string",
"values": ["video", "in_person"], "default-value": "video"}],
"references": [{"name": "Provider", "cardinality": "one",
"back_ref_name": "appointments"},
# the reminder binding reads patient.email through this
{"name": "Patient", "cardinality": "one",
"back_ref_name": "appointments"}]}
# document_signature is a two-schema service: a document and its signatures
ConsentDoc = {"schema_type": "object", "name": "ConsentDocument", "namespace": "clinic",
"parent_type": "tenant", "extends": "document_signature:base_document",
"fields": []}
ConsentSig = {"schema_type": "object", "name": "ConsentSignature", "namespace": "clinic",
"parent_type": "tenant", "extends": "document_signature:base_signature",
"fields": []}⏱ ~30 minutes
Write the access policy before the UI
Policies are the load-bearing part of this app. Start every non-admin role at default_access="none" and add back exactly what it needs, with owner scoping on anything patient-specific. It is far easier to open a door later than to discover one was never closed.
⏱ ~45 minutes
Import the integrations with test credentials
Add auth_otp, sms, whatsapp, email, google_calendar, stripe_checkout, amazon_s3 — and workflows last. Every one of these has a free sandbox: Twilio test credentials, SendGrid sandbox_mode: true, Stripe test keys, a Google service account shared onto one calendar.
⏱ ~45 minutes
Bind the reminders to data events
One binding on @create:clinic:appointment covers confirmation; use reference hydration to pull the patient's contact details from the linked record in the same dotted path.
{"event": "@create:clinic:appointment",
"workflow_id": "appointment_confirmed",
"input_map": {"appointment_uuid": "uuid",
"start_time": "start_time",
"patient_email": "patient.email",
"patient_phone": "patient.phone",
"provider_name": "provider.full_name"}}A path that does not resolve is simply omitted rather than failing the trigger — so mark those fields optional in the workflow's input_schema and let on_error: "continue" keep one dead channel from blocking the rest.
⏱ ~30 minutes
Split the clients, then turn on encryption
Two build notes first: the key must be raw 32 bytes, not the string your secrets manager hands back, and encryption is enabled on the low-level ApiLib client — the high-level supero client does not forward these arguments, so confirm your SDK build ships the encryption module before you design on it. Then split the note schema deliberately — encrypted free-text on one side, filterable metadata on the other — and route each through its own client, because encryption is per-client not per-field. Then run the real test twice: read a single record from a client without the key and confirm it fails, and list through that same client and confirm you notice the rows that quietly disappeared.
⏱ ~1 hour, mostly key management
Prove the boundary before you ship
The claim worth testing is not "the booking flow works" — it is "Clinic A cannot see Clinic B". Sign in as a tenant_admin of one clinic and try to read the other's appointments by uuid. Then do the same as a patient against another patient's record. Run the platform's quality gates on the bundle, then deploy — managed URL, your own AWS/GCP (only the UI container moves; auth and data stay on the Supero backend), or self-hosted for strict residency. Check the last one carefully: on a self-hosted install the integrations catalog this app depends on is a separately licensed add-on.
⏱ ~2 hours for the cross-clinic tests — then most of a second day on key handling, the clinician queue and the intake form itself, which is the part no generator gets right first time.
What this build does not give you
- Encrypted fields cannot be filtered, sorted or matched, and a client without the key silently drops them from list reads. Every clinical query you want has to run on unencrypted metadata — design the split before you write the schema, not after.
- Key custody is an operational problem you now own. Browser-held keys mean thirty clinicians hold copies and no recovery path; a server-side reader means one custodian and a service to run. There is no third option.
- Vendor credentials are per project, so every clinic in a project shares one Twilio and one SendGrid account. A clinic that needs its own needs its own project.
- No clinical content of any kind. No coding systems, no drug database, no e-prescribing, no HL7 or FHIR interface. This is scheduling and intake around a clinical record you model yourself.
- Controls are not a certification. Tenant isolation, role enforcement and audit logging are engineering; the compliance conversation is separate and it is yours.
Time to deliver
| Phase | On Supero | A scheduling SaaS you outgrow, then rebuild |
|---|---|---|
| Data model, CRUD APIs, clinician + patient screens | ~1 h | 2–3 weeks |
| Appointment + consent lifecycles | 0 — inherited via extends | 2–3 weeks |
| OTP, SMS, WhatsApp, email, calendar, payments | ~45 min of configuration | 3–4 weeks |
| Multi-clinic isolation + role model + owner scoping | ~2 h of policy authoring | 3–6 weeks |
| Client-side field encryption | ~1 h | 1–2 weeks |
| Audit logging | 0 — platform default | 1 week |
| Total | ~2 days | ~3 months |
Spend your time on the boundary
Half a day of this build is integrations, and integrations turned out to be configuration. The rest is the part that actually deserves a developer's attention: deciding what a clinic is, which role sees which record, and which fields are so sensitive that not even the platform should be able to read them.
So budget it backwards from a normal project: an afternoon for everything the vendors do, and the rest for the two decisions nobody can make for you — where the tenant boundary falls, and who holds the encryption key. Both are cheap to get right before there is patient data behind them, and expensive in a way no refactor fixes afterwards.
Build this on Supero
Describe it and get schemas, APIs, SDKs and a governed multi-tenant backend — or point your own AI editor at Supero over MCP and build it there.