| name | companion-app-patterns |
| description | 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. |
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).