# Companion App Patterns

> 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.

- Skill: `canvas-medical/companion-app-patterns` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add canvas-medical/companion-app-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/canvas-medical/companion-app-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: canvas-medical (https://skillmd.com/u/canvas-medical)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/canvas-medical/companion-app-patterns

---


# 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:

1. **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.
2. **Mutations via effects, never `.save()`.** Writes flow through SDK effects (`UpdateTask`, `AddTaskComment`, etc.). The plugin sandbox disallows direct ORM writes.
3. **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.
4. **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.
5. **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.
6. **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`.
7. **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.
8. **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.
9. **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.
10. **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.
11. **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.
12. **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": {...}}`).
13. **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.
14. **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](https://github.com/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).

