Canvas Provider Companion Application Patterns
This skill captures conventions for building Canvas provider companion plugins — mobile-oriented modals launched from the provider companion harness. They read as small single-page apps rendered inside an iframe, served by a SimpleAPI handler.
When to Use This Skill
Invoke this skill when you are:
- Creating a new plugin that declares
provider_companion_global, provider_companion_patient_specific, or provider_companion_note_specific in its manifest.
- Reviewing or modifying a plugin whose
Application subclass emits a LaunchModalEffect that points at a SimpleAPI endpoint served by the same plugin.
- Deciding where a piece of logic should live between the
Application, the SimpleAPI, and the client-side main.js.
- Choosing between client-side render and server-round-trip for a new interaction.
It complements plugin-patterns (generic Canvas plugin patterns) and testing (mock-based test discipline) rather than replacing them.
Quick Reference
Reference companion_app_patterns_context.txt in this skill directory for the full rule set with rationale and examples. High-level rules:
- Scope awareness. Only read the event context keys your scope actually provides. Global apps get no
patient or note; patient-specific gets patient.id; note-specific gets both.
- Mutations via effects, never
.save(). Writes flow through SDK effects (UpdateTask, AddTaskComment, etc.). The plugin sandbox disallows direct ORM writes.
- Auth with
StaffSessionAuthMixin, never bare SessionCredentials. Provider companion handlers MUST inherit from StaffSessionAuthMixin so non-staff sessions are rejected at the auth layer. A custom authenticate that only checks logged_in_user is not None lets any logged-in patient reach the endpoint; "mine"-style filters are not a substitute for auth.
- Server enforces permissions; client renders. The task serializer sets
can_complete / can_assign_to_me; the client gates button visibility on those flags, and the mutating endpoint re-verifies before emitting the effect.
- FK lookups by public UUID use
fk__id=<uuid>. assignee_id / provider_id target the integer dbid PK and will raise ValueError: Field 'dbid' expected a number if passed a UUID string.
- Timezone: client sends ISO, server parses ISO. Client builds date-range boundaries from local
Date objects and calls .toISOString(). Server parses with datetime.fromisoformat (supporting Z). Display uses toLocaleTimeString / toLocaleDateString.
- One HTML shell +
main.js + styles.css per plugin. Vanilla JS, no framework, no bundler. The shell is served by @api.get("/"); JS and CSS are served by sibling routes.
- Material-style card language. 4px radius, layered
box-shadow for elevation, consistent neutral/accent palette. Tap-to-expand cards get a visible, rotating chevron. Carrying the launcher icon's accent color through the UI (blue icon → blue primary buttons and links) is a nice polish touch that makes the modal feel like the same thing the user tapped.
- Mobile-first scroll isolation.
body is a full-height flex column with overflow: hidden; only the content region scrolls so header/filter chrome stays pinned.
- Deep links break out of the iframe via
target="_top". Navigating to another part of Canvas (e.g., a patient page) must replace the top frame; don't try to reproduce host chrome inside the modal.
- One plugin = one surface, one purpose. Ship them self-contained: own
README.md leading with end-user usage, own LICENSE, own 256×256 icon (SVG source + PNG), own tests at 100% coverage. Don't cross-reference sibling companion plugins — fork, don't import.
- Realtime push via WebSocket uses a deterministic channel name. The URL path segment is the channel name —
/plugin-io/ws/<plugin_name>/<channel_name>/ (trailing slash required). Use a scheme like staff-<uuid> derived from the logged-in user so the browser can compute it and a separate BaseHandler subscribed to a model event (e.g. MESSAGE_CREATED) can emit Broadcast(channel=f"staff-{uuid}", message={...}) without any registry. Unwrap the broadcast envelope client-side (the wire format is {"message": {...}}).
- Originate commands on the launching note (note-scope only). Note-scope companion apps receive
note.id in self.event.context and should pass it through the launcher URL to the handler. The handler uses it as note_uuid when constructing an SDK command (e.g. VitalsCommand(note_uuid=…, …)) and calling command.originate(). Return the resulting effect alongside the JSON response — the platform materializes the command in the note.
- Self-dismiss via the
INIT_CHANNEL / CLOSE_MODAL contract. The host transfers a MessagePort to the modal iframe via a {type:'INIT_CHANNEL'} postMessage with event.ports[0]. Store the port and, when done (e.g. after a successful submit), call port.postMessage({type:'CLOSE_MODAL'}). Fall back to window.close() if the port wasn't delivered. Don't invent alternate message shapes (window.parent.postMessage, lowercase close, etc.); stick to the documented contract.
Reference Implementations
Five companion-scope plugins demonstrate this skill end-to-end in Medical-Software-Foundation/canvas:
extensions/provider_schedule_companion/ — the logged-in provider's schedule with day/week/month views (provider_companion_global).
extensions/provider_task_dashboard_companion/ — filterable task list with inline comment thread, assign-to-me, and mark-complete actions (provider_companion_global).
extensions/provider_my_panel_companion/ — the logged-in provider's patient panel with last/next visit and open-task counts (provider_companion_global).
extensions/provider_patient_messages_companion/ — live SMS-style messaging surface with WebSocket push on MESSAGE_CREATED (provider_companion_global).
extensions/provider_note_vitals_companion/ — mobile vitals entry form that originates a Vitals command on the current note (provider_companion_note_specific).
All ship with detailed READMEs, 100% pytest coverage, and MIT licensing. The messaging plugin is the canonical reference for realtime push (rule 12); the vitals plugin is the canonical reference for note-scope command origination and the INIT_CHANNEL / CLOSE_MODAL dismiss contract (rules 13–14).
1---2name: companion-app-patterns3description: Design, UI, data-access, and packaging conventions for Canvas provider companion applications (scopes `provider_companion_global`, `provider_companion_patient_specific`, `provider_companion_note_specific`). Use when building or reviewing a plugin whose manifest declares one of those scopes, or whose `Application.on_open` emits a `LaunchModalEffect` for the companion surface.4---56# Canvas Provider Companion Application Patterns78This skill captures conventions for building Canvas **provider companion** plugins — mobile-oriented modals launched from the provider companion harness. They read as small single-page apps rendered inside an iframe, served by a `SimpleAPI` handler.910## When to Use This Skill1112Invoke this skill when you are:1314- Creating a new plugin that declares `provider_companion_global`, `provider_companion_patient_specific`, or `provider_companion_note_specific` in its manifest.15- Reviewing or modifying a plugin whose `Application` subclass emits a `LaunchModalEffect` that points at a `SimpleAPI` endpoint served by the same plugin.16- Deciding where a piece of logic should live between the `Application`, the `SimpleAPI`, and the client-side `main.js`.17- Choosing between client-side render and server-round-trip for a new interaction.1819It complements `plugin-patterns` (generic Canvas plugin patterns) and `testing` (mock-based test discipline) rather than replacing them.2021## Quick Reference2223Reference `companion_app_patterns_context.txt` in this skill directory for the full rule set with rationale and examples. High-level rules:24251. **Scope awareness.** Only read the event context keys your scope actually provides. Global apps get no `patient` or `note`; patient-specific gets `patient.id`; note-specific gets both.262. **Mutations via effects, never `.save()`.** Writes flow through SDK effects (`UpdateTask`, `AddTaskComment`, etc.). The plugin sandbox disallows direct ORM writes.273. **Auth with `StaffSessionAuthMixin`, never bare `SessionCredentials`.** Provider companion handlers MUST inherit from `StaffSessionAuthMixin` so non-staff sessions are rejected at the auth layer. A custom `authenticate` that only checks `logged_in_user is not None` lets any logged-in patient reach the endpoint; "mine"-style filters are not a substitute for auth.284. **Server enforces permissions; client renders.** The task serializer sets `can_complete` / `can_assign_to_me`; the client gates button visibility on those flags, and the mutating endpoint re-verifies before emitting the effect.295. **FK lookups by public UUID use `fk__id=<uuid>`.** `assignee_id` / `provider_id` target the integer `dbid` PK and will raise `ValueError: Field 'dbid' expected a number` if passed a UUID string.306. **Timezone: client sends ISO, server parses ISO.** Client builds date-range boundaries from local `Date` objects and calls `.toISOString()`. Server parses with `datetime.fromisoformat` (supporting `Z`). Display uses `toLocaleTimeString` / `toLocaleDateString`.317. **One HTML shell + `main.js` + `styles.css` per plugin.** Vanilla JS, no framework, no bundler. The shell is served by `@api.get("/")`; JS and CSS are served by sibling routes.328. **Material-style card language.** 4px radius, layered `box-shadow` for elevation, consistent neutral/accent palette. Tap-to-expand cards get a visible, rotating chevron. Carrying the launcher icon's accent color through the UI (blue icon → blue primary buttons and links) is a nice polish touch that makes the modal feel like the same thing the user tapped.339. **Mobile-first scroll isolation.** `body` is a full-height flex column with `overflow: hidden`; only the content region scrolls so header/filter chrome stays pinned.3410. **Deep links break out of the iframe via `target="_top"`.** Navigating to another part of Canvas (e.g., a patient page) must replace the top frame; don't try to reproduce host chrome inside the modal.3511. **One plugin = one surface, one purpose.** Ship them self-contained: own `README.md` leading with end-user usage, own `LICENSE`, own 256×256 icon (SVG source + PNG), own tests at 100% coverage. Don't cross-reference sibling companion plugins — fork, don't import.3612. **Realtime push via WebSocket uses a deterministic channel name.** The URL path segment is the channel name — `/plugin-io/ws/<plugin_name>/<channel_name>/` (trailing slash required). Use a scheme like `staff-<uuid>` derived from the logged-in user so the browser can compute it and a separate `BaseHandler` subscribed to a model event (e.g. `MESSAGE_CREATED`) can emit `Broadcast(channel=f"staff-{uuid}", message={...})` without any registry. Unwrap the broadcast envelope client-side (the wire format is `{"message": {...}}`).3713. **Originate commands on the launching note (note-scope only).** Note-scope companion apps receive `note.id` in `self.event.context` and should pass it through the launcher URL to the handler. The handler uses it as `note_uuid` when constructing an SDK command (e.g. `VitalsCommand(note_uuid=…, …)`) and calling `command.originate()`. Return the resulting effect alongside the JSON response — the platform materializes the command in the note.3814. **Self-dismiss via the `INIT_CHANNEL` / `CLOSE_MODAL` contract.** The host transfers a `MessagePort` to the modal iframe via a `{type:'INIT_CHANNEL'}` postMessage with `event.ports[0]`. Store the port and, when done (e.g. after a successful submit), call `port.postMessage({type:'CLOSE_MODAL'})`. Fall back to `window.close()` if the port wasn't delivered. Don't invent alternate message shapes (`window.parent.postMessage`, lowercase `close`, etc.); stick to the documented contract.3940## Reference Implementations4142Five companion-scope plugins demonstrate this skill end-to-end in [Medical-Software-Foundation/canvas](https://github.com/Medical-Software-Foundation/canvas):4344- `extensions/provider_schedule_companion/` — the logged-in provider's schedule with day/week/month views (`provider_companion_global`).45- `extensions/provider_task_dashboard_companion/` — filterable task list with inline comment thread, assign-to-me, and mark-complete actions (`provider_companion_global`).46- `extensions/provider_my_panel_companion/` — the logged-in provider's patient panel with last/next visit and open-task counts (`provider_companion_global`).47- `extensions/provider_patient_messages_companion/` — live SMS-style messaging surface with WebSocket push on `MESSAGE_CREATED` (`provider_companion_global`).48- `extensions/provider_note_vitals_companion/` — mobile vitals entry form that originates a Vitals command on the current note (`provider_companion_note_specific`).4950All ship with detailed READMEs, 100% pytest coverage, and MIT licensing. The messaging plugin is the canonical reference for realtime push (rule 12); the vitals plugin is the canonical reference for note-scope command origination and the `INIT_CHANNEL` / `CLOSE_MODAL` dismiss contract (rules 13–14).