Embedded Applications

Each embedded application belongs to a specific Canvas surface, such as a note, the scheduling modal, or the provider side menu. Embedded applications are ordinary handlers. You subclass a base class, register it under handlers in your CANVAS_MANIFEST.json, and Canvas renders it in the appropriate surface.

There are five kinds:

Base classSurface
NoteApplicationA tab within a patient’s note
SchedulingApplicationReplaces the built-in scheduling modal at every entry point
DockedApplicationA persistent pane pinned to a window edge, always visible
ProviderMenuApplicationAn entry in the provider side menu (the navigation sidebar, also called the hamburger menu)
PanelApplicationAn entry in the panel bar or the app drawer

How embedded applications work #

Embedded applications are handlers. You build one by subclassing one of the base classes above and registering it under handlers in your CANVAS_MANIFEST.json. The surface comes from that parent class.

Because the parent class defines the behavior, there’s very little to configure:

  • The surface comes from the class you inherit. You don’t set a scope. The provider menu and the panel bar each take a few extra class attributes, such as an icon URL, described in their own sections below.
  • When Canvas renders each kind:
    • Note and Scheduling Applications render on demand. When a note opens or a scheduling action is triggered, Canvas asks which embedded application is installed for that surface, then renders what your handler returns.
    • Docked Applications stay mounted at all times.
    • Provider Menu and Panel Applications are fetched when their menu loads and refreshed as the user navigates.
  • Embedded applications create no application record, so they don’t appear under Plugins_IO > Applications.
  • Only a Panel Application can appear in the app drawer, as described in Panel Applications.
  • If no embedded application is installed for a surface, Canvas falls back to its built-in behavior: an unmodified note, or the built-in scheduling modal.

Since the surface is inherited from the parent class, register these under handlers rather than applications.

Note Applications #

Note Applications appear as tabs within a patient’s note, allowing you to embed custom interfaces directly in the clinical documentation workflow.

Implementing a Note Application #

To create a Note Application, your handler class should inherit from NoteApplication and define the following class attributes:

AttributeDescription
NAME(Required) The display title shown on the tab (supports emojis)
IDENTIFIER(Required) A unique key for the application (recommended format: plugin_name__app_name)
PRIORITY(Optional) Controls tab order — lower values appear first. Defaults to 0

Tip: If your Note Application is named “Note”, it may cause confusion with the built-in Note tab. Users can rename the built-in tab by updating the Constance Config setting NOTE_BODY_TAB_LABEL in your instance Settings, to avoid duplication.

Your class must implement the on_open() method, which is called when the user clicks on the tab. This method should return an Effect or list of Effects, typically a LaunchModalEffect with target set to LaunchModalEffect.TargetType.NOTE

⚠️ Important If you have an existing plugin that overrides handle(), it will continue to work. However, handle() is deprecated — migrate to on_open() at your earliest convenience.

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import NoteApplication


class PatientIntakeApp(NoteApplication):
    """Note application for patient intake workflow."""

    NAME = "📋 Patient Intake"
    IDENTIFIER = "my_plugin__patient_intake"

    def on_open(self) -> Effect | list[Effect]:
        """Launch the intake form when the tab is clicked."""
        note_id = self.event.context.get("note_id")
        patient_id = self.event.context.get("patient", {}).get("id")

        return LaunchModalEffect(
            target=LaunchModalEffect.TargetType.NOTE,
            content="<html>Your form HTML here</html>",
            title="Patient Intake Form"
        ).apply()
note applications

Manifest Configuration #

Register your Note Application under the handlers section of your CANVAS_MANIFEST.json. There is no scope or icon — the note tab is driven by the NoteApplication base class and the NAME/IDENTIFIER class attributes.

{
  "components": {
    "handlers": [
      {
        "class": "my_plugin.apps.intake:PatientIntakeApp",
        "description": "In-note patient intake tab."
      }
    ]
  }
}

Context and Event Data #

Both on_open() and handle() have access to context data through self.event.context:

KeyDescription
note_idThe database ID of the current note
noteA dict containing the note’s external id (UUID)
patientA dict containing the patient’s id
userInformation about the current user

on_open() — recommended #

When using on_open(), the patient is available through self.event.context:

from canvas_sdk.effects import Effect

def on_open(self) -> Effect | list[Effect]:
    note_id = self.event.context.get("note_id")
    patient_id = self.event.context.get("patient", {}).get("id")
    ...

self.event.target.id contains the application identifier used internally for routing, not the patient.

handle() — deprecated #

When using the deprecated handle(), self.event.target.id is automatically set to the patient UUID before handle() is called, preserving the original behavior that old plugins relied on:

from canvas_sdk.effects import Effect

def handle(self) -> list[Effect]:
    patient_id = self.event.target.id  # backfilled from patient context
    ...

Note: This backfilling only happens when handle() is called. Plugins that override on_open() directly should read the patient from self.event.context as shown above.

Propertyon_open()handle() (deprecated)
self.event.target.idApplication identifier (for routing)Patient UUID (backfilled)
self.event.context["patient"]["id"]Patient UUIDPatient UUID
self.event.actorAuthenticated userAuthenticated user

Controlling Visibility #

You can control when your Note Application tab is visible by overriding the visible() method. This method has access to the same context and event data as on_open():

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import NoteApplication


class ConditionalIntakeApp(NoteApplication):
    NAME = "📋 Intake"
    IDENTIFIER = "my_plugin__conditional_intake"

    def visible(self) -> bool:
        """Only show for specific conditions."""
        # Add your visibility logic here
        return True

    def on_open(self) -> Effect | list[Effect]:
        return LaunchModalEffect(
            target=LaunchModalEffect.TargetType.NOTE,
            content="<html>Form content</html>",
            title="Intake"
        ).apply()

Opening by Default #

You can make a Note Application tab open automatically when a note is first viewed by overriding open_by_default(). If multiple applications return True, the first one (by priority order) will be opened.

When a user opens a note from a command permalink, the note starts on the built-in Note tab instead, so the linked command is visible. Opening the note any other way still opens the default application.

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import NoteApplication


class AutoOpenApp(NoteApplication):
    NAME = "📋 Intake"
    IDENTIFIER = "my_plugin__auto_open_intake"

    def open_by_default(self) -> bool:
        """Open automatically when the note is viewed."""
        return True

    def on_open(self) -> Effect | list[Effect]:
        return LaunchModalEffect(
            target=LaunchModalEffect.TargetType.NOTE,
            content="<html>Form content</html>",
            title="Intake"
        ).apply()

Tab Ordering #

You can control the order in which Note Application tabs appear by setting the PRIORITY class attribute. Tabs are sorted in ascending order, so lower values appear first. The default is 0.

from canvas_sdk.handlers.application import NoteApplication

class HighPriorityApp(NoteApplication):
    NAME = "First Tab"
    IDENTIFIER = "my_plugin__first"
    PRIORITY = 1

class LowPriorityApp(NoteApplication):
    NAME = "Second Tab"
    IDENTIFIER = "my_plugin__second"
    PRIORITY = 10

Note: Note Applications do not support notification badges.

Scheduling Applications #

Scheduling Applications replace the built-in scheduling modal throughout Canvas. When you install a plugin with a scheduling application, it takes over all scheduling entry points: the schedule page, patient chart, calendar drag-and-drop, calendar reschedule, and note reschedule flows.

Implementing a Scheduling Application #

To create a Scheduling Application, your handler class should inherit from SchedulingApplication and define two required class attributes:

AttributeDescription
NAMEThe display title shown in the modal
IDENTIFIERA unique key for the application (recommended format: plugin_name__app_name)

Your class must implement the on_open() method, which is called when a scheduling action is triggered. This method should return an Effect or list of Effects, typically a LaunchModalEffect.

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import SchedulingApplication


class CustomScheduler(SchedulingApplication):
    """Scheduling application for custom appointment booking."""

    NAME = "Schedule Appointment"
    IDENTIFIER = "my_plugin__scheduler"

    def on_open(self) -> Effect | list[Effect]:
        """Launch the scheduling form when triggered."""
        patient = self.event.context.get("patient", {})
        provider = self.event.context.get("provider", {})
        start = self.event.context.get("start", "")
        mode = self.event.context.get("mode", "schedule")

        return LaunchModalEffect(
            url=f"https://scheduler.example.com/book?patient={patient.get('id', '')}&provider={provider.get('id', '')}&start={start}&mode={mode}",
            title="Schedule Appointment"
        ).apply()

Context Data #

When on_open() is called, scheduling context is available through self.event.context. The available keys depend on which entry point triggered the scheduling action.

Entity Objects #

Entities are delivered as {"id": <external id>} objects, resolvable with the conventional .objects.get(id=...):

FieldResolves ToValueAvailable From
patientPatient{"id": <patient id>}Patient chart, reschedule flows
providerStaff{"id": <staff id>}Calendar, patient chart
locationPracticeLocation{"id": <practice location id>}Current location context
appointmentAppointment{"id": <appointment id>}Reschedule flows
noteNote{"id": <note id>}Note reschedule flow

Scalar Values #

KeyDescription
startISO-8601 datetime of the selected slot (all entry points)
endISO-8601 datetime for the slot end (calendar drag-and-drop only)
durationSlot length in minutes (reschedule flows only). Either end or duration is present, never both
modeOne of schedule, reschedule, or followup
originThe launching surface: schedule_page, patient_chart, calendar, calendar_reschedule, or note_reschedule

When end is not provided, derive it from start + duration.

Origins #

origin tells you which surface launched the scheduling action, which in turn determines the mode and whether the slot length arrives as end or duration:

originLaunching surfacemodeSlot length
schedule_pageNew appointment from the schedule page (no patient context)schedule or followupneither (start only)
patient_chartNew appointment from a patient’s chartschedule or followupneither (start only)
calendarDrag-and-drop on the calendar to create a slotscheduleend
calendar_rescheduleRescheduling an existing appointment from the calendarrescheduleduration
note_rescheduleRescheduling an appointment from within a noterescheduleduration

Which entities accompany each origin is shown in the Entity Objects table’s “Available From” column above — for example, patient_chart and the reschedule flows include a patient, while schedule_page and calendar do not.

Manifest Configuration #

Register your Scheduling Application under the handlers section of your CANVAS_MANIFEST.json. There is no scope or icon — inheriting from SchedulingApplication is what tells Canvas to use it as the scheduling-modal override.

{
  "components": {
    "handlers": [
      {
        "class": "my_plugin.apps.scheduler:CustomScheduler",
        "description": "Custom appointment scheduling that overrides the built-in modal."
      }
    ]
  }
}

When installed, this application replaces the built-in scheduling modal. If no scheduling application is installed, the existing built-in modal continues to work unchanged.

Docked Applications #

A Docked Application mounts as a persistent docked pane pinned to a window edge. It stays in place as the user moves between pages, including between a patient chart and global pages, rather than opening fresh each time.

Reach for a docked pane when a surface needs to follow the user instead of being opened and re-opened: a telephony or messaging bar that has to survive navigation mid-call, a live worklist the user works through while moving between charts, or an ambient scribe that keeps recording as the clinician moves around a note. Because the pane stays mounted, whatever state it holds survives that navigation, whether that’s a scroll position, a half-filled form, or an open connection. A right chart pane keeps its state only while the user stays in one patient’s chart. It closes when they open a different patient or leave the chart, while a docked pane stays open.

Implementing a Docked Application #

A Docked Application is a handler that inherits from DockedApplication, declares the pane’s placement as class attributes, and implements on_open() to mount the pane’s content:

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import DockedApplication, DockEdge
from canvas_sdk.templates import render_to_string
from canvas_sdk.v1.data.task import Task, TaskStatus


class TaskDock(DockedApplication):
    """Docked application that keeps the signed-in user's open tasks on screen."""

    NAME = "My Tasks"
    IDENTIFIER = "my_plugin__task_dock"
    DOCK_EDGE = DockEdge.RIGHT
    DOCK_SIZE = "320px"

    def on_open(self) -> Effect | list[Effect]:
        """Mount the docked pane's content."""
        user_id = self.event.context.get("user", {}).get("id")
        tasks = Task.objects.filter(assignee__id=user_id, status=TaskStatus.OPEN).order_by("due")

        return LaunchModalEffect(
            target=LaunchModalEffect.TargetType.DOCKED_PANE,
            content=render_to_string("templates/task_dock.html", {"tasks": tasks}),
            title="My Tasks",
        ).apply()

The pane’s markup lives in a Django template in your plugin rather than in a Python string, rendered by render_to_string. Here that template is templates/task_dock.html:

<!DOCTYPE html>
<html>
  <head>
    <title>My Tasks</title>
  </head>
  <body>
    <h1>My Tasks</h1>
    <ul>
      {% for task in tasks %}
        <li>{{ task.title }} (due {{ task.due }})</li>
      {% endfor %}
    </ul>
  </body>
</html>

on_open() returns a LaunchModalEffect with target set to LaunchModalEffect.TargetType.DOCKED_PANE. That target is what mounts the effect’s rendered content (or a url) in the pane rather than in a modal. Canvas draws no chrome around a docked pane, so title is never displayed: it becomes the pane’s accessible name for screen readers.

There is no app drawer entry that opens a docked pane and no Canvas-provided control that closes or minimizes it. For the staff who get one, the pane is simply on screen for the whole session, and which staff those are is the plugin’s decision: see Controlling who gets a pane. The pane’s own content can remove itself at runtime, which is how a plugin offers its own collapse or close control. See Sizing, Resizing, and Collapsing.

Class attributes #

AttributeRequiredDescription
NAMERequiredThe display title for the pane
IDENTIFIEROptionalA unique key for the application. When omitted, the identifier defaults to one derived automatically from the class’s module and name. Set it explicitly in the recommended plugin_name__app_name format to give the application a stable, readable identifier.
DOCK_EDGERequiredWhich window edge to pin the pane to, given as a DockEdge value
DOCK_SIZERequiredThe pane’s initial size and the initial ceiling for the plugin’s own resize requests, as a CSS length string (for example, 320px). See Sizing, Resizing, and Collapsing.
PRIORITYOptionalAn integer controlling stacking order when multiple panes share an edge — lower values sit nearer the window edge. Defaults to 0. See Multiple Docked Panes.

DockEdge #

DockEdge is an enum of the four window edges a pane can be pinned to.

NameValueEdge to pin to
LEFTleftLeft edge of the window
RIGHTrightRight edge of the window
TOPtopTop edge of the window
BOTTOMbottomBottom edge of the window

Controlling who gets a pane #

A docked pane is not all-or-nothing across an instance. Override visible() to decide, per staff member, whether the pane exists for them at all:

def visible(self) -> bool:
    """Only dock the pane for the care coordination team."""
    staff_id = self.event.context.get("user", {}).get("id")

    return Staff.objects.filter(id=staff_id, teams__name="Care Coordination").exists()

Returning False means that user gets no pane: Canvas never calls on_open() for them and sends them no context changes. visible() defaults to True, so a Docked Application that does not override it docks for everyone.

Two things to know about when this runs. Canvas asks for docked applications once as the EHR shell loads, so visible() is evaluated then and not again as the user navigates inside the shell. A change in the answer therefore takes effect on that user’s next full page load. And the context visible() receives holds only scope and user, with no patient or note, because the question being asked is which panes this session gets rather than what is on screen. Gate on the staff member, or on anything you can look up from them, rather than on what they are currently viewing.

Do not use on_open() for this. Returning no docked-pane effect from on_open() also leaves the pane unmounted, but panes are mounted once per session and never retried, so the pane stays gone for the rest of the session with no way back.

Pane Context and Navigation #

Like other embedded applications, a Docked Application reads request context from self.event.context. It receives that context twice over: once when the pane first mounts, through on_open(), and again on each navigation, through on_context_change(). Both entry points get the same three keys:

KeyValueDescription
urlstrThe path of the Canvas page the user is on. Always one of the pages a pane sees.
user{"id": str, "type": str}The signed-in user, always present. id is the Staff id; type is the name of the person record behind the login, which is Staff in the EHR.
patient{"id": str}The patient whose chart is open. Present only on a /patient/<patient id> path and absent entirely elsewhere, so read it as self.event.context.get("patient", {}).

On a patient chart the whole context looks like this:

{
    "url": "/patient/b80b1cdc2e6a4aca90ccebc02e683f35",
    "user": {"id": "5eede137ecfe4124b8b773040e33be14", "type": "Staff"},
    "patient": {"id": "b80b1cdc2e6a4aca90ccebc02e683f35"},
}

On the schedule, where no patient is in the path, the patient key is simply not there:

{
    "url": "/schedule",
    "user": {"id": "5eede137ecfe4124b8b773040e33be14", "type": "Staff"},
}

on_open() #

Fires once, when the pane mounts, and returns the effect that gives the pane its content:

def on_open(self) -> Effect | list[Effect]:
    url = self.event.context.get("url")
    user_id = self.event.context.get("user", {}).get("id")
    patient_id = self.event.context.get("patient", {}).get("id")
    ...

on_context_change() #

Fires on each navigation after that, with the new url and the patient derived from the new path. This is how a pane stays context-aware as the user moves around Canvas.

It defaults to a no-op, so a pane that does not override it keeps whatever it last rendered. Override it to return the plugin’s own content or hosted URL, rebuilt from the new context, such as a hosted URL that carries the new patient id:

def on_context_change(self) -> Effect | list[Effect]:
    patient_id = self.event.context.get("patient", {}).get("id", "")
    return LaunchModalEffect(
        target=LaunchModalEffect.TargetType.DOCKED_PANE,
        url=f"https://task-dock.example.com/panel?patient={patient_id}",
    ).apply()

The pane’s document reloads only when on_context_change() returns a different url or content. Returning the same url or content as before leaves the pane’s current document in place, preserving its scroll position and state, and so does returning no effect at all (None or an empty list). Whether to reload is your plugin’s choice.

Pages a pane sees #

url is the path of the Canvas page around the pane, never the URL loaded inside the pane’s own iframe. A pane navigating its own document is invisible to Canvas and produces no context change, and neither does a change to the page URL’s hash.

Docked panes mount in the EHR shell, so url is always one of the paths that shell routes. Some of these pages are gated by permission or by a feature flag, so which of them a given user reaches will vary:

PathPage
/patient/<patient id>A patient’s chart, including its sub-paths. The only path that carries a patient in context
/scheduleThe schedule calendar
/patientsThe patient list
/panelA patient panel
/populationsPopulation health
/campaignsCampaigns
/revenueRevenue and claims
/data-integrationData integration
/questionnaire-builderThe questionnaire builder
/applicationA full-page plugin application
/403Permission denied

Everything else on the domain sits outside that shell, including /admin, /login, /app/..., /companion/... and /plugin-io/.... Moving to one of those is a full page load rather than a navigation: the pane is not on screen while the user is there, and returning to the EHR mounts it again from scratch, so on_open() fires rather than on_context_change().

The patient portal is outside the shell as well, so a docked pane cannot appear there. A Docked Application is a staff-facing surface only.

Because the pane stays mounted across every navigation inside the shell, it keeps its state there. A right chart pane, by contrast, closes when the user opens a different patient or leaves the chart.

Multiple Docked Panes #

Docked panes stack rather than being limited to one per edge. Each edge holds up to two panes. When an edge already holds its two panes, an additional pane for that edge is not displayed — it is ignored rather than raising an install-time error.

Panes sharing an edge share a single track, and they split it evenly. On a left or right edge they stack top to bottom, each as wide as the track; on a top or bottom edge they sit side by side, each as tall as it. So two panes on one edge each get half the length of that edge, and the edge itself is only as thick as the larger of their two DOCK_SIZE values, since those sizes are alternatives rather than additions.

Which pane comes first is decided by priority: the lower PRIORITY value goes first, which means the top of the stack on a left or right edge and the left-hand position on a top or bottom edge. Panes with equal priority are ordered by their identifier. This mirrors the way the PRIORITY class attribute orders Note Application tabs. To get a predictable order, set an explicit PRIORITY on each pane, and set an explicit IDENTIFIER rather than relying on the auto-derived one.

Panes cannot crowd Canvas out of its own window. The left and right panes together take up at most half the window’s width, and the top and bottom panes together at most half its height. If the panes on one of those pairs would exceed their half, all of them are scaled down in proportion rather than any one being dropped.

Sizing, Resizing, and Collapsing #

DOCK_SIZE sets the pane’s initial size — its width on the LEFT and RIGHT edges, its height on the TOP and BOTTOM edges — and the initial ceiling for the plugin’s own resize requests. The user and the plugin resize the pane under different rules.

A user can resize a pane by dragging its edge or with the keyboard arrow keys, using the standard splitter the pane exposes. A user resize can make the pane larger or smaller than DOCK_SIZE: it stops at 48px, and at the half-the-window limit described above, but is not otherwise bound by DOCK_SIZE. The size a user drags to becomes the pane’s new ceiling, replacing DOCK_SIZE, and is stored in the browser (localStorage) keyed by the pane’s identifier, so it survives page reload and navigation. Once a user has resized a pane, that persisted size takes precedence on future loads, so changing DOCK_SIZE in a later plugin version does not affect panes a user has already resized, until the stored size is cleared.

There is no host-provided control to close or minimize the pane. A plugin can, however, resize or collapse its own pane from inside its iframe (for example, by collapsing it to a thin rail). A plugin’s own resize can shrink the pane freely, down to a thin rail. It cannot grow the pane past the current ceiling: a request at or above the ceiling is clamped to the ceiling rather than applied as given, so a plugin restores the pane to its full (ceiling) size by requesting any value at or above it. A plugin-driven resize is not bound by the 48px floor that applies to user resizing.

A pane is removed only when the plugin’s own content requests it, by posting the same CLOSE_MODAL message that applications use to dismiss modals. See Closing Modals from Applications for the full mechanism. Once removed, nothing re-mounts the pane short of a page reload.

A docked pane cannot navigate the host application directly; navigation is issued through a redirect effect. It does not reset the session idle-logout timer.

Manifest Configuration #

Register your Docked Application under the handlers section of your CANVAS_MANIFEST.json. As with Note and Scheduling Applications, there is no scope or icon — inheriting from DockedApplication is what tells Canvas to mount it as a docked pane.

{
  "components": {
    "handlers": [
      {
        "class": "my_plugin.apps.task_dock:TaskDock",
        "description": "Open task list docked to the right edge."
      }
    ]
  }
}

Provider Menu Applications #

A provider menu application adds an entry to the provider side menu, the navigation sidebar also called the hamburger menu. A provider can open the menu from any page in the EHR. Use one when a plugin needs a launch point that isn’t tied to a patient chart or a note.

Implementing a Provider Menu Application #

Subclass ProviderMenuApplication and implement on_open() to return the effect that runs when the entry is clicked, typically a LaunchModalEffect:

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import MenuPosition, ProviderMenuApplication


class CareGaps(ProviderMenuApplication):
    """Provider menu entry that opens the care-gaps worklist."""

    NAME = "Care gaps"
    IDENTIFIER = "my_plugin__care_gaps"
    MENU_POSITION = MenuPosition.BOTTOM

    def on_open(self) -> Effect | list[Effect]:
        """Launch the worklist when the menu entry is clicked."""
        return LaunchModalEffect(
            url="https://care-gaps.example.com/worklist",
            title="Care gaps",
        ).apply()

Configure the entry with these class attributes:

AttributeRequiredDescription
NAMERequiredThe label shown for the menu entry.
IDENTIFIEROptionalA unique key for the application. Recommended in the plugin_name__app_name format; when omitted, it defaults to one derived from the class’s module and name.
MENU_POSITIONOptionalThe group the entry joins, as a MenuPosition value. Defaults to MenuPosition.TOP.
ICON_URLOptionalNot displayed. The provider menu shows only the NAME label, so setting this has no visible effect.
PRIORITYOptionalAn integer that orders entries within a group. Lower values appear first. Entries sort together with provider_menu_item drawer applications, which are ordered by their menu_order manifest field. Defaults to 0.

MenuPosition #

MenuPosition is an enum of the two groups in the provider menu:

NameValueGroup
TOPtopTop of the provider menu
BOTTOMbottomBottom of the provider menu

Any other MENU_POSITION value raises a ValueError when Canvas loads the menu.

Visibility #

Provider menu and panel applications decide whether their entry appears by overriding visible(). Return True to show the entry and False to hide it. The default returns True, so the entry appears for every user on every page.

Canvas calls visible() when the menu loads and again each time the user navigates to another page, so an entry can appear on some pages and not others. on_context_change() is separate: it runs only while your application is open, not each time the menu refreshes its entries.

visible() reads the page and the user from self.event.context:

# On a patient chart
{
    "user": {"type": "Staff", "id": "4150cd20de8a470aa570a852859ac87e"},
    "url": "/patient/b80b1cdc2e6a4aca90ccebc02e683f35",
    "patient": {"id": "b80b1cdc2e6a4aca90ccebc02e683f35"},
}

# On any other page
{
    "user": {"type": "Staff", "id": "4150cd20de8a470aa570a852859ac87e"},
    "url": "/schedule",
}
KeyDescription
userThe signed-in user. id is the Staff id.
urlThe path of the current page, without the domain. See Pages a pane sees for the paths Canvas sends.
patientPresent only on a patient chart. id is the patient’s id.

Show the entry only on patient charts:

def visible(self) -> bool:
    """Show the entry only while a patient chart is open."""
    return self.event.context.get("patient") is not None

Show the entry only to staff with a given role, using the Staff data model:

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import ProviderMenuApplication
from canvas_sdk.v1.data import Staff

ALLOWED_ROLE_CODES = ["MD", "NP"]


class CareGaps(ProviderMenuApplication):
    """Provider menu entry that only prescribers can see."""

    NAME = "Care gaps"
    IDENTIFIER = "my_plugin__care_gaps"

    def visible(self) -> bool:
        """Show the entry only to staff who hold an allowed role."""
        staff_id = self.event.context.get("user", {}).get("id")
        if not staff_id:
            return False

        return Staff.objects.filter(
            id=staff_id,
            roles__internal_code__in=ALLOWED_ROLE_CODES,
        ).exists()

    def on_open(self) -> Effect | list[Effect]:
        """Launch the worklist, or an access message for staff without a role."""
        if not self.visible():
            return LaunchModalEffect(
                content="<p>You don't have access to Care gaps.</p>",
                title="Care gaps",
            ).apply()

        return LaunchModalEffect(
            url="https://care-gaps.example.com/worklist",
            title="Care gaps",
        ).apply()

Role codes come from your instance’s staff role setup, so use the internal_code values your instance’s staff roles carry. To allow a fixed list of people instead, compare staff_id against a set of Staff ids.

Badges #

Override compute_notification_badge(), inherited from Application and described under Notification Badges, to show a count on the entry. Return a non-negative integer, or None (the default) for no badge. A count of 0 shows no badge.

Canvas computes the count when the menu loads, not on each navigation. To change the count after load, emit an ApplicationNotificationBadge effect for the application’s identifier, as described under Live updates.

Manifest Configuration #

Register your provider menu application under the handlers section of your CANVAS_MANIFEST.json:

{
  "components": {
    "handlers": [
      {
        "class": "my_plugin.apps.care_gaps:CareGaps",
        "description": "Care-gaps worklist in the provider menu."
      }
    ]
  }
}

Panel Applications #

A panel application adds an entry beside the panel buttons, both inside and outside patient charts. By default, the entry appears in the app drawer with its icon and name, alongside drawer applications. Set SHOW_IN_DRAWER = False to show the icon directly in the panel bar instead. The panel bar shows only the icon, with no label, so every panel application sets an ICON_URL.

Implementing a Panel Application #

Subclass PanelApplication, set ICON_URL, and implement on_open() to return the effect that runs when the icon is clicked:

from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.handlers.application import PanelApplication


class Inbox(PanelApplication):
    """Panel bar entry that opens the messaging inbox."""

    NAME = "Inbox"
    IDENTIFIER = "my_plugin__inbox"
    ICON_URL = "https://assets.example.com/inbox-icon.png"
    SHOW_IN_DRAWER = False

    def on_open(self) -> Effect | list[Effect]:
        """Launch the inbox when the panel icon is clicked."""
        return LaunchModalEffect(
            url="https://inbox.example.com",
            title="Inbox",
        ).apply()

Configure the entry with these class attributes:

AttributeRequiredDescription
NAMERequiredThe entry’s name, shown under the icon in the app drawer. The panel bar shows only the icon.
ICON_URLRequiredThe icon shown for the entry, as an https:// URL or a data: URI. A path to a file in your plugin package does not work. See Panel Icons.
IDENTIFIEROptionalA unique key for the application. Recommended in the plugin_name__app_name format; when omitted, it defaults to one derived from the class’s module and name.
SHOW_IN_DRAWEROptionalWhen True (the default), the entry appears in the app drawer. When False, the icon shows directly in the panel bar.
PRIORITYOptionalAn integer that orders entries. Lower values appear first. Entries sort together with drawer applications, which are ordered by their panel_priority manifest field. Defaults to 0.

A PanelApplication subclass without an ICON_URL raises ImproperlyConfigured when the class is defined, so the plugin fails to load rather than showing an entry with nothing to click. To share behavior across several panel entries, define an intermediate base class with abstract = True. That class may omit ICON_URL, but each subclass of it must set one:

class InboxBase(PanelApplication):
    abstract = True

    def on_open(self) -> Effect | list[Effect]:
        return LaunchModalEffect(url="https://inbox.example.com").apply()


class TeamInbox(InboxBase):
    NAME = "Team inbox"
    ICON_URL = "https://assets.example.com/team-inbox-icon.png"

Panel applications support visible() and notification badges as described under Visibility and Badges.

Panel Icons #

Canvas uses the ICON_URL string as the icon’s image source, exactly as you set it. This differs from the icon field of a drawer application in the manifest, which is a path to a file in your plugin package that Canvas uploads and stores when the plugin is installed. A PanelApplication creates no application record, so nothing uploads its icon, and a package path such as assets/inbox.png doesn’t load.

ICON_URL takes either of these:

  • An https:// URL to an image hosted outside Canvas:

    ICON_URL = "https://assets.example.com/inbox-icon.png"
    
  • A data: URI that holds the image itself, which keeps the icon inside your plugin code. An SVG can be written inline with its special characters percent-encoded (< as %3C, > as %3E, # as %23):

    ICON_URL = (
        "data:image/svg+xml,"
        "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E"
        "%3Ccircle cx='12' cy='12' r='10' fill='%23336699'/%3E"
        "%3C/svg%3E"
    )
    

    A PNG works as a base64-encoded data:image/png;base64,... string.

Canvas has no fallback for an icon that fails to load, so a broken URL shows a broken image in the panel bar. Host the image somewhere that stays available.

Patient Charts and Other Pages #

A panel application has one scope for every page, so it has no equivalent of a drawer application’s patient_specific and global scopes. Its entry appears in the panel bar on patient charts and on every other page unless visible() says otherwise. Its context includes a patient only on a patient chart, so check for one to choose where the entry appears:

def visible(self) -> bool:
    """Show the entry only on patient charts, like a patient_specific application."""
    return self.event.context.get("patient") is not None
def visible(self) -> bool:
    """Show the entry only outside patient charts, like a global application."""
    return self.event.context.get("patient") is None

To show the entry on every page, leave visible() as it is.

Manifest Configuration #

Register your panel application under the handlers section of your CANVAS_MANIFEST.json:

{
  "components": {
    "handlers": [
      {
        "class": "my_plugin.apps.inbox:Inbox",
        "description": "Messaging inbox in the panel bar."
      }
    ]
  }
}