# Canvas Plugin Architecture Patterns

This document provides practical patterns and best practices for building Canvas plugins.

## Canonical Project Structure

**CRITICAL:** The `canvas init` command creates a specific nested folder structure. Understanding this structure is essential for correct plugin development.

### Container vs Inner Folder Pattern

When you run `canvas init` with a plugin name, it creates two levels:

```
example-plugin-name/              # Container folder (kebab-case)
├── pyproject.toml                # Container level - dev/test tooling only
├── tests/                        # Container level - mirrors inner structure
│   ├── __init__.py
│   ├── conftest.py
│   └── handlers/
│       └── test_handler.py       # Mirrors inner/handlers/handler.py
└── example_plugin_name/          # Inner folder (snake_case, same name converted)
    ├── CANVAS_MANIFEST.json      # MUST be inside inner folder
    ├── README.md                 # Plugin documentation
    ├── screenshots/              # Images referenced from README.md
    │   └── main.png
    └── handlers/
        ├── __init__.py
        └── my_handler.py
```

### Key Rules

1. **Container folder** is kebab-case: `my-cool-plugin`
2. **Inner folder** is snake_case: `my_cool_plugin` (same name, different format)
3. **CANVAS_MANIFEST.json** MUST be inside the inner folder, never at container level
4. **tests/** MUST be at container level, parallel to inner folder
5. **pyproject.toml** is at container level (for local dev/test only)
6. **README.md** is inside the inner folder
7. **`screenshots/`** is inside the inner folder, sibling to `README.md` — this is the canonical home for README images so relative paths like `![](screenshots/main.png)` render on GitHub and Notion without any rewriting. Screenshots are a requirement for open-source publishing; keeping them here keeps the plugin self-contained.

### Name Conversion

The inner folder name is derived from the container name by replacing hyphens with underscores:

| Plugin Name (container) | Inner Folder |
|------------------------|--------------|
| `my-plugin` | `my_plugin` |
| `vitals-alert` | `vitals_alert` |
| `patient-chart-customizations` | `patient_chart_customizations` |

### Common Structure Mistakes

| Mistake | Problem | Fix |
|---------|---------|-----|
| `CANVAS_MANIFEST.json` at container level | Canvas CLI won't find it | Move inside inner folder |
| `tests/` inside inner folder | Import paths break | Move to container level |
| No inner folder (flat structure) | Plugin won't install | Re-run `canvas init` |
| Inner folder has kebab-case | Python imports fail | Rename to snake_case |
| Two levels of nesting inside inner | Over-complicated structure | Flatten to single inner folder |

### Verification Commands

After `canvas init`, verify the structure:

```bash
# Get inner folder name (convert kebab to snake)
INNER=$(basename "$PWD" | tr '-' '_')

# CANVAS_MANIFEST.json should be inside inner folder
test -f "$INNER/CANVAS_MANIFEST.json" && echo "OK: Manifest in correct location" || echo "ERROR: Manifest missing from $INNER/"

# tests/ should be at container level
test -d tests && echo "OK: tests/ at container level" || echo "ERROR: tests/ missing"

# pyproject.toml should be at container level
test -f pyproject.toml && echo "OK: pyproject.toml present" || echo "ERROR: pyproject.toml missing"
```

---

## Plugin Complexity Levels

### Simple Plugins (1-2 files, ~45 real-world examples)
Best for: Single event → single effect workflows

**Characteristics:**
- Single handler
- 20-100 lines of code
- Direct event → effect mapping
- No external API calls
- No UI component

**When to use:**
- Creating alerts based on clinical data (e.g., high blood pressure alert)
- Creating tasks when specific events occur (e.g., lab order fasting reminder)
- Simple data validation or transformation
- Automated workflow triggers

**Structure:**
```
simple-plugin/
├── CANVAS_MANIFEST.json
├── handlers/
│   └── handler.py
├── screenshots/                # Screenshots referenced from README.md
└── README.md
```

**Example - Blood Pressure Alert:**
```python
from canvas_sdk.effects import Effect
from canvas_sdk.effects.banner_alert import AddBannerAlert
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler

class BPAlertHandler(BaseHandler):
    RESPONDS_TO = [
        EventType.Name(EventType.VITALS_COMMAND__POST_COMMIT)
    ]

    def compute(self) -> list[Effect]:
        vitals = self.event.target.instance
        systolic = vitals.blood_pressure_systolic
        diastolic = vitals.blood_pressure_diastolic

        if not systolic or not diastolic:
            return []

        patient_id = self.event.context["patient"]["id"]

        # Stage 2 Hypertension
        if systolic >= 140 or diastolic >= 90:
            return [
                AddBannerAlert(
                    patient_id=patient_id,
                    key="bp-stage2",
                    narrative=f"Stage 2 Hypertension: {systolic}/{diastolic}",
                    placement=[AddBannerAlert.Placement.TIMELINE],
                    intent=AddBannerAlert.Intent.WARNING
                ).apply()
            ]

        return []
```

---

### Medium Plugins (8-15 files, ~3 real-world examples)
Best for: Multi-handler workflows or API-based plugins

**Characteristics:**
- Multiple handlers with different responsibilities
- API endpoints (SimpleAPI routes)
- Utility modules for shared logic
- 500-2000 lines total
- May have simple UI served via API

**When to use:**
- Workflows requiring multiple event triggers
- Plugins needing HTTP endpoints for webhooks or UI
- Integration with external systems
- Complex business logic with shared utilities

**Structure:**
```
medium-plugin/
├── CANVAS_MANIFEST.json
├── handlers/
│   ├── __init__.py
│   ├── event_handler.py
│   └── webhook_handler.py
├── api/
│   ├── __init__.py
│   └── routes.py
├── utils/
│   ├── __init__.py
│   └── helpers.py
├── screenshots/                # Screenshots referenced from README.md
└── README.md
```

---

### Complex Plugins (15+ files, rare)
Best for: Full-featured applications with UI

**Characteristics:**
- Application handler for UI
- Multiple handlers and API endpoints
- Static assets (JS, CSS)
- HTML templates
- Possibly LLM integration
- 2000+ lines of code

**When to use:**
- Interactive UI applications (requires icon - use icon-generation skill)
- AI/LLM-powered features
- Complex multi-step workflows with user interaction
- Real-time data processing and display

**Structure:**
```
complex-plugin/
├── CANVAS_MANIFEST.json
├── applications/
│   ├── __init__.py
│   └── my_app.py
├── assets/                  # Required for Application icons
│   └── icon.png            # 48x48 PNG icon
├── handlers/
│   ├── __init__.py
│   └── listener.py
├── api/
│   ├── __init__.py
│   ├── routes.py
│   └── static.py
├── llms/                    # Optional
│   ├── __init__.py
│   └── client.py
├── static/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── templates/
│   └── index.html
├── utils/
│   └── helpers.py
├── screenshots/                # Screenshots referenced from README.md
│   └── main.png
└── README.md
```

---

## Common Handler Patterns

### Pattern 1: Event Listener → Effect
The simplest and most common pattern.

```python
class MyHandler(BaseHandler):
    RESPONDS_TO = [EventType.Name(EventType.SOME_EVENT__POST_COMMIT)]

    def compute(self) -> list[Effect]:
        # 1. Extract data from event
        patient_id = self.event.context["patient"]["id"]
        data = self.event.target.instance

        # 2. Apply business logic
        if some_condition(data):
            # 3. Return effects
            return [SomeEffect(...).apply()]

        return []
```

### Pattern 2: Webhook Handler
For receiving data from external systems.

```python
class WebhookHandler(SimpleAPI):
    PREFIX = "/webhook"

    def authenticate(self, credentials: Credentials) -> bool:
        api_key = self.request.headers.get("X-API-Key")
        return api_key == self.secrets.get("WEBHOOK_API_KEY")

    @api.post("/receive")
    def receive_data(self) -> list[JSONResponse | Effect]:
        body = self.request.json()

        # Process incoming data
        # Create Canvas commands/effects

        return [JSONResponse({"status": "ok"}, status_code=HTTPStatus.OK)]
```

### Pattern 3: Application with API Backend
For interactive UI applications.

**CRITICAL: Applications REQUIRE an icon (48x48 PNG).**
- Generate the icon IMMEDIATELY after creating the Application class
- Invoke the icon-generation skill: `Skill(skill="icon-generation")`
- Create assets directory: `mkdir -p {plugin_name_snake}/assets`
- Save icon files to `{plugin_name_snake}/assets/`
- Update CANVAS_MANIFEST.json applications entry: `"icon": "assets/{filename}.png"`
- Verify icon exists: `ls -lh {plugin_name_snake}/assets/*.png`

```python
# applications/my_app.py
from datetime import datetime, timezone

# Cache bust: timestamp generated once at module load, changes on every deploy/restart
# NOTE: Canvas sandbox forbids Path/json.load — cannot read CANVAS_MANIFEST.json at runtime
_CACHE_BUST = str(int(datetime.now(timezone.utc).timestamp()))

class MyApp(Application):
    def on_open(self) -> Effect:
        patient_id = self.context.get("patient", {}).get("id", "")
        # Cache bust: append ?v={token} so browser fetches latest content
        return LaunchModalEffect(
            url=f"/plugin-io/api/my_plugin/ui/{patient_id}?v={_CACHE_BUST}",
            target=LaunchModalEffect.TargetType.RIGHT_CHART_PANE,
            title="My App"
        ).apply()

# api/routes.py
from datetime import datetime, timezone

_CACHE_BUST = str(int(datetime.now(timezone.utc).timestamp()))

class MyAPI(SimpleAPI):
    PREFIX = "/my_plugin"

    @api.get("/ui/<patient_id>")
    def get_ui(self) -> list[HTMLResponse | Effect]:
        # Always pass cache_bust for cache busting in templates
        html = render_to_string("templates/index.html", {
            "cache_bust": _CACHE_BUST,
            # ... other context
        })
        return [HTMLResponse(html, status_code=HTTPStatus.OK)]
```

**Cache busting in HTML templates:** All external `<script src>` and `<link href>` URLs, and plugin-served static asset URLs, must include `?v={{ cache_bust }}`:

```html
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js?v={{ cache_bust }}"></script>
<link href="https://fonts.googleapis.com/css?family=Roboto&v={{ cache_bust }}" rel="stylesheet">
<link rel="stylesheet" href="/plugin-io/api/{{ plugin_name }}/static/styles.css?v={{ cache_bust }}">
<script src="/plugin-io/api/{{ plugin_name }}/static/main.js?v={{ cache_bust }}"></script>
```

---

## CANVAS_MANIFEST.json Patterns

### Minimal Manifest (Simple Plugin)
```json
{
    "sdk_version": "0.1.4",
    "plugin_version": "0.0.1",
    "name": "my_plugin",
    "description": "Brief description of what the plugin does",
    "components": {
        "handlers": [
            {
                "class": "my_plugin.handlers.handler:MyHandler",
                "description": "Handles X events and creates Y effects"
            }
        ]
    },
    "secrets": [],
    "readme": "./README.md"
}
```

### Full Manifest (Complex Plugin)
```json
{
    "sdk_version": "0.74.1",
    "plugin_version": "0.0.1",
    "name": "my_plugin",
    "description": "Full description",
    "components": {
        "handlers": [
            {
                "class": "my_plugin.handlers.listener:EventListener",
                "description": "Listens for events"
            },
            {
                "class": "my_plugin.api.routes:MyAPI",
                "description": "API endpoints"
            }
        ],
        "applications": [
            {
                "class": "my_plugin.applications.my_app:MyApp",
                "name": "My Application",
                "description": "Interactive UI",
                "icon": "assets/icon-name.png",  # Required: 48x48 PNG
                "scope": "patient_specific",
                "show_in_panel": true,
                "panel_priority": 100
            }
        ]
    },
    "secrets": [
        "API_KEY",
        "WEBHOOK_SECRET"
    ],
    "readme": "./README.md"
}
```

---

## Event Types Reference

### Most Common Events

**Vitals:**
- `VITALS_COMMAND__POST_COMMIT` - Vitals saved
- `VITALS_COMMAND__POST_UPDATE` - Vitals modified

**Lab Orders:**
- `LAB_ORDER_COMMAND__POST_COMMIT` - Lab ordered
- `LAB_ORDER_COMMAND__POST_UPDATE` - Lab order modified

**Prescriptions:**
- `PRESCRIBE_COMMAND__POST_COMMIT` - Prescription created
- `PRESCRIBE_COMMAND__POST_UPDATE` - Prescription modified

**Diagnoses:**
- `DIAGNOSE_COMMAND__POST_COMMIT` - Diagnosis added
- `DIAGNOSE_COMMAND__POST_UPDATE` - Diagnosis modified

**Patient:**
- `PATIENT__POST_CREATE` - New patient created
- `PATIENT__POST_UPDATE` - Patient record modified

**Notes:**
- `NOTE__POST_SAVE` - Note saved
- `NOTE__POST_SIGN` - Note signed

---

## Effect Types Reference

### AddBannerAlert
Display alerts in patient timeline or chart header.

```python
AddBannerAlert(
    patient_id=patient_id,
    key="unique-key",           # Unique identifier
    narrative="Alert message",   # Display text
    placement=[
        AddBannerAlert.Placement.TIMELINE,    # In timeline
        AddBannerAlert.Placement.CHART_HEADER # At top of chart
    ],
    intent=AddBannerAlert.Intent.ALERT,  # ALERT, WARNING, INFO
    href="/some/url"            # Optional link
).apply()
```

### AddTask
Create tasks for staff members or teams.

```python
AddTask(
    patient_id=patient_id,
    title="Task title",
    team_id=team_id,           # Assign to team
    # OR
    assignee_id=staff_id,      # Assign to individual
    due=datetime_obj,          # Due date
    labels=["label1"]          # Optional labels
).apply()
```

### LaunchModalEffect
Open UI modals or panels. Always cache bust URLs with a timestamp token (`_CACHE_BUST`).

```python
LaunchModalEffect(
    url=f"/plugin-io/api/my_plugin/ui?v={_CACHE_BUST}",  # Cache-busted URL
    # OR
    content="<html>...</html>",          # Inline HTML (external resources still need ?v=)
    target=LaunchModalEffect.TargetType.RIGHT_CHART_PANE,
    title="Modal Title"
).apply()

# Target types:
# - DEFAULT_MODAL: Centered modal
# - NEW_WINDOW: New browser window (requires URL)
# - RIGHT_CHART_PANE: Sidebar panel
# - RIGHT_CHART_PANE_LARGE: Wider sidebar
# - PAGE: Full page
```

---

## Best Practices

### 1. Use Absolute Imports

**CRITICAL:** Canvas plugins MUST use absolute imports with the full package path. Relative imports will fail in the Canvas runtime.

```python
# GOOD - absolute import with full package path
from my_plugin_name.handlers.handler import MyHandler
from my_plugin_name.utils.helpers import format_date

# BAD - relative imports (will fail in Canvas)
from .handler import MyHandler
from ...utils.helpers import format_date
```

The package name in the import must match the inner folder name (snake_case):
- Inner folder: `vitals_alert/`
- Import: `from vitals_alert.handlers.handler import ...`

### 2. Let Exceptions Propagate
**Do NOT wrap handler code in try-except blocks.** Exceptions must bubble up so they appear in Canvas logs with full tracebacks for debugging.

```python
# GOOD - exceptions propagate to logs with full traceback
def compute(self) -> list[Effect]:
    patient_id = self.event.context["patient"]["id"]
    vitals = self.event.target.instance

    # If something fails, the exception bubbles up and appears in logs
    if vitals.blood_pressure_systolic >= 140:
        return [AddBannerAlert(...).apply()]
    return []
```

Use explicit checks for **expected** missing data, not try-except:

```python
# GOOD - explicit guards for optional data
def compute(self) -> list[Effect]:
    patient = self.event.context.get("patient")
    if not patient:
        return []  # Expected: some events lack patient context

    patient_id = patient["id"]
    # Continue with logic...
```

### 2. Logging
Use the logger for debugging and monitoring.

```python
from logger import log

log.info(f"Processing event for patient {patient_id}")
log.warning("Unexpected condition encountered")
log.error(f"Failed to process: {error}")
```

### 3. Secrets Management
Never hardcode credentials. Use secrets.

```python
api_key = self.secrets.get("API_KEY")
if not api_key:
    log.error("API_KEY secret not configured")
    return []
```

### 4. Data Validation
Validate data before processing.

```python
def compute(self) -> list[Effect]:
    vitals = self.event.target.instance

    # Check required fields exist
    if not vitals.blood_pressure_systolic:
        return []

    # Validate data ranges
    if vitals.blood_pressure_systolic < 0 or vitals.blood_pressure_systolic > 300:
        log.warning(f"Invalid BP reading: {vitals.blood_pressure_systolic}")
        return []
```

### 5. Idempotency
Design handlers to be safe if called multiple times.

```python
# Use unique keys for alerts to prevent duplicates
AddBannerAlert(
    patient_id=patient_id,
    key=f"bp-alert-{patient_id}-{date.today()}",  # Unique per patient per day
    ...
)
```

---

## Anti-Patterns to Avoid

### 1. Swallowing Exceptions with try-except
**Never wrap handler code in try-except blocks.** This hides errors from logs and makes debugging impossible.

```python
# BAD - exceptions are swallowed, nothing useful in logs
def compute(self) -> list[Effect]:
    try:
        patient_id = self.event.context["patient"]["id"]
        # logic
    except Exception as e:
        log.error(f"Error: {e}")  # Only get message, no traceback!
        return []

# GOOD - let exceptions propagate with full traceback
def compute(self) -> list[Effect]:
    patient_id = self.event.context["patient"]["id"]
    # logic - failures appear in logs with full traceback
```

### 2. Blocking Operations
Don't perform long-running operations in event handlers.

```python
# BAD - blocks event processing
def compute(self):
    response = requests.get(slow_api, timeout=30)  # Too slow!

# BETTER - use async patterns or task queues
```

### 3. Not Guarding Optional Data
Use explicit checks for data that may not exist.

```python
# BAD - crashes on missing data without useful context
patient_id = self.event.context["patient"]["id"]

# GOOD - explicit guard with early return
patient = self.event.context.get("patient")
if not patient:
    return []
patient_id = patient["id"]
```

### 4. Hardcoded Values
Don't hardcode configuration.

```python
# BAD
api_url = "https://api.example.com"

# GOOD
api_url = self.secrets.get("API_URL")
```

### 5. Overly Complex Plugins
Start simple, add complexity only when needed.

```python
# BAD - building complex plugin for simple task
# 15 files for creating an alert

# GOOD - match complexity to requirements
# 2 files for alert, scale up if needed
```

---

## Testing Patterns

### Unit Testing Handlers

```python
import pytest
from unittest.mock import MagicMock, patch

def test_bp_alert_triggers_on_high_bp():
    # Mock the event
    mock_event = MagicMock()
    mock_event.context = {"patient": {"id": "test-123"}}
    mock_event.target.instance.blood_pressure_systolic = 150
    mock_event.target.instance.blood_pressure_diastolic = 95

    # Create handler with mocked event
    handler = BPAlertHandler()
    handler.event = mock_event

    # Execute
    effects = handler.compute()

    # Assert
    assert len(effects) == 1
    # Verify effect properties
```

### Integration Testing

```bash
# Deploy to test instance
uv run canvas install my_plugin --host plugin-testing

# Monitor logs (use unbuffered for real-time output)
unbuffer uv run canvas logs --host plugin-testing

# Perform manual tests and verify behavior
```

---

## README Template

```markdown
# [Plugin Name]

## Description
[What problem does this plugin solve?]

## Features
- [Feature 1]
- [Feature 2]

## Triggers
- [Event that triggers this plugin]

## Effects
- [What the plugin creates/modifies]

## Configuration
Secrets required:
- `SECRET_NAME`: [Description]

## Installation
```bash
uv run canvas install [plugin_name] --host [target]
```

## Testing
[How to test the plugin]

## Demo
[Link to demo video if available]
```

---

## Quick Decision Guide

**Need to react to a clinical event?**
→ Simple plugin with BaseHandler

**Need to receive data from external system?**
→ Medium plugin with SimpleAPI webhook

**Need interactive UI for users?**
→ Complex plugin with Application + API

**Need scheduled tasks?**
→ Use CronTask handler

**Need to customize search dropdowns?**
→ Use search result filter handlers

**Need to add buttons to notes?**
→ Use ActionButton handler

---

## Architectural Patterns (High-Level)

The number of handlers in a plugin is the best proxy for complexity.
Always start with `echo "plugin_name" | uv run canvas init` and adapt from there.

### Single Handler Plugins (1 handler)

**When:** One trigger, one response, no user interaction

**Examples:**
- Event → Alert (vitals trigger banner)
- Event → Task (lab order creates follow-up task)
- Questionnaire response processing
- Webhook receiver (external system posts data)

**Architecture decisions:**
- One file in `handlers/`
- No `api/` directory needed
- No `applications/` directory needed
- 0-2 secrets (maybe API key for webhook auth)

---

### Multi-Handler Plugins (2-5 handlers)

**When:** Multiple triggers OR trigger + UI display OR inbound + outbound integration

**Examples:**
- Respond to multiple event types (vitals AND labs AND diagnoses)
- Event handler + API endpoint for external queries
- ActionButton + display handler pair
- Scheduled task + event-driven updates

**Architecture decisions:**
- Multiple files in `handlers/`, optionally grouped into subdirectories by responsibility
- May need `api/` for HTTP endpoints
- Consider `utils/` for shared logic
- 2-5 secrets typical

**Handler combinations:**
- BaseHandler + BaseHandler (multiple events)
- BaseHandler + SimpleAPI (event + webhook)
- ActionButton + SimpleAPI (button + display)
- CronTask + BaseHandler (scheduled + reactive)

---

### Application Plugins (1+ Application handlers)

**When:** Interactive UI that users launch from Canvas

**Examples:**
- Patient-specific tool launched from chart
- Global utility launched from app drawer
- Multi-step workflow with user interaction

**Architecture decisions:**
- `applications/` directory with Application handler
- `api/` directory for backend endpoints serving UI
- `templates/` for HTML
- `static/` for CSS/JS if needed
- Application opens modal/panel, API serves content
- Consider scope: `patient_specific` vs `global`

**Handler combinations:**
- Application + SimpleAPI (minimum for UI app)
- Application + SimpleAPI + BaseHandler (UI + background processing)

---

### LLM-Integrated Plugins

**When:** AI/ML processing of clinical data, voice, or text

**Examples:**
- Voice transcription and command generation
- Clinical decision support with LLM reasoning
- Automated documentation from conversations

**Architecture decisions:**
- `llms/` directory for LLM client abstractions
- Support multiple providers (OpenAI, Anthropic, Google)
- Separate prompt management from LLM calls
- Heavy use of secrets for API keys and configuration
- Consider `structures/` for complex data models
- May need WebSocket for real-time streaming

**Typical secrets:** 10+ (LLM keys, configuration flags, feature toggles)

---

### Extreme Complexity (10+ handlers)

**Real-world example:** Hyperscribe (12 handlers, 1 application, 123 files, 26 secrets)

**When:** Full-featured product, not a simple integration

**Characteristics:**
- Multiple ActionButtons for different entry points
- Multiple display handlers for different views
- Background processing handlers
- WebSocket for real-time updates
- Extensive command/data structure libraries
- Custom tuning and configuration UIs

**Architecture decisions:**
- `commands/` for domain-specific command builders
- `structures/` for data models
- `libraries/` for shared utilities
- Version tagging in manifest
- Extensive secrets for configuration flexibility

---

## Pattern Selection from Spec

When reading a `.cpa-workflow-artifacts/plugin-spec.md`, map to patterns:

| Spec says... | Pattern |
|--------------|---------|
| Single event trigger, alert/task effect | Single Handler |
| Multiple event triggers | Multi-Handler |
| External webhook | Single Handler (SimpleAPI) |
| Questionnaire processing | Single Handler |
| Scheduled/periodic | Single Handler (CronTask) |
| "Interactive UI" or "panel" | Application Plugin |
| "Custom UI" with backend | Application + API |
| LLM/AI processing | LLM-Integrated |
| Multiple entry points | Multi-Handler with ActionButtons |

---

## Directory Evolution

As plugins grow, directory structure evolves:

```
Simple (1 handler):
plugin/
├── handlers/handler.py
├── CANVAS_MANIFEST.json
└── README.md

Medium (3-5 handlers):
plugin/
├── handlers/
│   ├── event_handler.py
│   ├── webhook.py
│   └── cron_task.py
├── utils/helpers.py
├── CANVAS_MANIFEST.json
└── README.md

Complex (Application + handlers):
plugin/
├── applications/app.py
├── api/routes.py
├── handlers/listener.py
├── templates/index.html
├── static/css/, js/
├── utils/
├── CANVAS_MANIFEST.json
└── README.md

Extreme (product-level):
plugin/
├── handlers/          # 10+ handler files
├── commands/          # Domain command builders
├── structures/        # Data models
├── libraries/         # Shared utilities
├── llms/              # LLM integrations
├── api/
├── applications/
├── templates/
├── static/
├── CANVAS_MANIFEST.json
└── README.md
```

---

## Secrets Complexity Guide

| Complexity | Typical Secrets |
|------------|-----------------|
| Simple | 0-2 (maybe webhook auth) |
| Medium | 2-5 (API keys, config) |
| Complex | 5-10 (LLM keys, feature flags) |
| Extreme | 10-30 (full configuration system) |

If a spec requires many configurable behaviors, plan for more secrets.

---

## pyproject.toml

**CRITICAL: Keep pyproject.toml minimal.** This file is only used for local development and testing—Canvas has its own plugin packaging process that ignores it.

### Correct pyproject.toml Structure

```toml
# This pyproject.toml is only used for local development and testing.
# The Canvas plugin has its own packaging process that doesn't use this file.

[project]
name = "my-plugin-name"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
    "arrow>=1.3.0",
    "django>=4.2.0",
]

[dependency-groups]
dev = [
    "pytest>=8.0.0",
    "pytest-cov>=4.1.0",
    "pytest-django>=4.7.0",
    "pytest-mock>=3.12.0",
]

[tool.coverage.run]
omit = ["tests/*"]
```

### What to Include

- **`[project]`**: Plugin name, version `0.0.0`, Python version
- **`dependencies`**: Only packages your plugin imports at runtime (arrow, django, httpx, etc.)
- **`[dependency-groups].dev`**: Test tooling only (pytest and plugins)
- **`[tool.coverage.run]`**: Exclude tests from coverage reports

### What NOT to Include

- Build system configuration (`[build-system]`, setuptools, hatch, etc.)
- Complex tool configurations (black, ruff, mypy settings)
- Scripts or entry points
- Optional dependencies groups beyond `dev`
- Package metadata (authors, license, classifiers, URLs)
- Any configuration Canvas won't use

### Anti-Pattern: Over-Configured pyproject.toml

```toml
# BAD - way too much unnecessary configuration
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-plugin"
version = "0.1.0"
description = "A Canvas plugin"
readme = "README.md"
license = "MIT"
authors = [{ name = "...", email = "..." }]
classifiers = [...]
urls = { Homepage = "...", Repository = "..." }
dependencies = [...]

[project.optional-dependencies]
dev = [...]
test = [...]
lint = [...]

[tool.hatch.build.targets.wheel]
packages = ["src/my_plugin"]

[tool.black]
line-length = 88

[tool.ruff]
line-length = 88
select = ["E", "F", "I"]

[tool.mypy]
strict = true
```

None of this is needed. Canvas doesn't use your build system, and you don't need linting configuration in the project file.

---

## Client Libraries

The Canvas SDK provides native clients for common integrations. **Use these SDK clients directly** — do NOT copy custom client files into your plugin. See the **canvas-sdk** skill for full documentation of each client.

### AWS S3 — Use the Canvas SDK Native Client

**Do NOT copy a custom S3 client.** The Canvas SDK provides a native S3 client. See the **canvas-sdk** skill (`https://docs.canvasmedical.com/sdk/clients-aws-s3/`) for full documentation.

**When to use:** Plugin needs to store or retrieve files, data persistence beyond Canvas, file uploads/downloads.

**Features:**
- AWS Signature Version 4 authentication (no boto3 dependency)
- Upload text and binary files
- Download, delete, and list objects
- Generate presigned URLs for temporary access

**Usage:**
```python
from canvas_sdk.clients.aws import S3, Credentials

credentials = Credentials(
    key=self.secrets["S3_ACCESS_KEY"],
    secret=self.secrets["S3_SECRET_KEY"],
    region=self.secrets["S3_REGION"],
    bucket=self.secrets["S3_BUCKET"],
)
client = S3(credentials)

if not client.is_ready():
    return []

# Upload text data
client.upload_text_to_s3("my-data.json", json.dumps(data))

# Download data
response = client.access_s3_object("my-data.json")
if response and response.status_code == 200:
    data = response.json()

# Upload binary data
client.upload_binary_to_s3("image.png", binary_data, "image/png")

# List objects
items = client.list_s3_objects("prefix/")

# Delete an object
client.delete_object("my-data.json")

# Generate a presigned URL (1 hour)
url = client.generate_presigned_url("my-data.json", 3600)
```

**Required secrets:** `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_BUCKET`

**No extra dependency needed** — the S3 client is included in the Canvas SDK.

---

### LLMs (Anthropic Claude, OpenAI ChatGPT, Google Gemini) — Use the Canvas SDK Native Client

**Do NOT copy a custom LLM client.** The Canvas SDK provides native LLM clients for multiple providers. See the **canvas-sdk** skill (`https://docs.canvasmedical.com/sdk/clients-llms/`) for full documentation.

**When to use:** Plugin needs AI/LLM capabilities, text generation, clinical recommendations, structured data extraction, image/PDF analysis.

**Features:**
- Unified interface for OpenAI (GPT), Anthropic (Claude), and Google (Gemini)
- Text conversations with multi-turn support
- File attachments (images, PDFs, text) via URL or direct content
- Structured JSON output with Pydantic models
- Built-in retry logic with `attempt_requests()`
- Token usage tracking

**Usage (Anthropic Claude example):**
```python
from http import HTTPStatus
from canvas_sdk.clients.llms import LlmAnthropic
from canvas_sdk.clients.llms.structures.settings import LlmSettingsAnthropic

client = LlmAnthropic(LlmSettingsAnthropic(
    api_key=self.secrets["ANTHROPIC_API_KEY"],
    model="claude-sonnet-4-5-20250929",
    temperature=0.0,
    max_tokens=8192,
))

# Simple conversation
client.set_system_prompt(["You are a clinical assistant."])
client.set_user_prompt(["Summarize these vitals..."])

response = client.request()
if response.code == HTTPStatus.OK:
    summary = response.response

# Multi-turn conversation
client.set_model_prompt(["Here is the summary..."])
client.set_user_prompt(["Now provide recommendations."])
response = client.request()

# Retry logic
responses = client.attempt_requests(attempts=3)
last = responses[-1]
if last.code == HTTPStatus.OK:
    content = last.response
```

**For structured JSON output:**
```python
from pydantic import Field
from canvas_sdk.clients.llms.structures import BaseModelLlmJson

class Recommendation(BaseModelLlmJson):
    recommendation: str = Field(description="Clinical recommendation")
    urgency: str = Field(description="Urgency level")

client.set_system_prompt(["Return structured clinical recommendations."])
client.set_user_prompt(["Analyze this lab result..."])
client.json_schema = Recommendation

response = client.request()
if response.code == HTTPStatus.OK:
    data = Recommendation.model_validate_json(response.response)
```

**Other providers:**
```python
# OpenAI
from canvas_sdk.clients.llms import LlmOpenai
from canvas_sdk.clients.llms.structures.settings import LlmSettingsGpt4
client = LlmOpenai(LlmSettingsGpt4(api_key=self.secrets["OPENAI_API_KEY"], model="gpt-4o"))

# Google Gemini
from canvas_sdk.clients.llms import LlmGoogle
from canvas_sdk.clients.llms.structures.settings import LlmSettingsGemini
client = LlmGoogle(LlmSettingsGemini(api_key=self.secrets["GOOGLE_API_KEY"], model="models/gemini-2.0-flash"))
```

**Required secrets:** Provider API key (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_API_KEY`)

**No extra dependency needed** — the LLM clients are included in the Canvas SDK.

---

### Twilio SMS/MMS — Use the Canvas SDK Native Client

**Do NOT copy a custom Twilio client.** The Canvas SDK provides a native Twilio client. See the **canvas-sdk** skill (`https://docs.canvasmedical.com/sdk/clients-twilio/`) for full documentation.

**When to use:** Plugin needs to send SMS/MMS messages, patient notifications, appointment reminders, inbound message handling.

**Features:**
- SMS and MMS sending with `SmsMms` structured objects
- Phone number management
- Message history retrieval
- Inbound message webhook handling with TwiML responses
- Status callback support
- Error handling via `RequestFailed` exception

**Usage:**
```python
from canvas_sdk.clients.twilio.libraries import SmsClient
from canvas_sdk.clients.twilio.structures import Settings, SmsMms, RequestFailed

settings = Settings(
    account_sid=self.secrets["TWILIO_ACCOUNT_SID"],
    key=self.secrets["TWILIO_API_KEY"],
    secret=self.secrets["TWILIO_API_SECRET"],
)
client = SmsClient(settings)

# Get the account phone number
phones = list(client.account_phone_numbers())
phone = phones[0]

# Send SMS
sms = SmsMms(
    number_from=phone.phone_number,
    number_from_sid=phone.sid,
    number_to="+15559876543",
    message="Your appointment is confirmed for tomorrow at 2pm.",
    media_url="",
    status_callback_url="",
)
message = client.send_sms_mms(sms)
log.info(f"SMS sent: {message.sid}")
```

**Required secrets:** `TWILIO_ACCOUNT_SID`, `TWILIO_API_KEY`, `TWILIO_API_SECRET`

**No extra dependency needed** — the Twilio client is included in the Canvas SDK.

---

### SendGrid Email — Use the Canvas SDK Native Client

**Do NOT copy a custom SendGrid client.** The Canvas SDK provides a native SendGrid client. See the **canvas-sdk** skill (`https://docs.canvasmedical.com/sdk/clients-sendgrid/`) for full documentation.

**When to use:** Plugin needs to send emails, patient communications, reports, notifications.

**Features:**
- Structured email composition with `Email`, `Address`, `Recipient`, and `BodyContent` objects
- HTML and plain text email support
- File attachments and inline images
- CC/BCC recipients
- Email log querying
- Inbound parse and outbound event webhooks
- Error handling via `RequestFailed` exception

**Usage:**
```python
from canvas_sdk.clients.sendgrid.libraries import EmailClient
from canvas_sdk.clients.sendgrid.constants import RecipientType
from canvas_sdk.clients.sendgrid.structures import (
    Address, BodyContent, Email, Recipient, RequestFailed, Settings,
)

client = EmailClient(Settings(key=self.secrets["SENDGRID_API_KEY"]))

email = Email(
    sender=Address(email="notifications@clinic.com", name="Clinic"),
    reply_tos=[Address(email="reply@clinic.com", name="Reply To")],
    recipients=[
        Recipient(
            address=Address(email="patient@example.com", name="Patient"),
            type=RecipientType.TO,
        )
    ],
    subject="Your Lab Results Are Ready",
    bodies=[
        BodyContent(type="text/plain", value="Your lab results are now available..."),
        BodyContent(type="text/html", value="<h1>Lab Results</h1><p>Your results are now available...</p>"),
    ],
    attachments=[],
    send_at=Email.now(),
)

client.simple_send(email)
```

**Required secrets:** `SENDGRID_API_KEY`

**No extra dependency needed** — the SendGrid client is included in the Canvas SDK.

---

### Extend AI Document Processing — Use the Canvas SDK Native Client

The Canvas SDK provides a native Extend AI client. See the **canvas-sdk** skill (`https://docs.canvasmedical.com/sdk/clients-extend-ai/`) for full documentation.

**When to use:** Plugin needs intelligent document processing — extracting structured data from documents, classifying documents into categories, or splitting multi-page documents into logical sections.

**Features:**
- Document extraction (extract structured data from PDFs and images using a defined schema)
- Document classification (classify documents into predefined categories)
- Document splitting (split multi-page documents into logical sections)
- Processor management (list, create, configure processors)
- File management (list, delete uploaded files)
- Async processing with status polling

**Usage:**
```python
import time
from canvas_sdk.clients.extend_ai.libraries import Client
from canvas_sdk.clients.extend_ai.constants import RunStatus
from canvas_sdk.clients.extend_ai.structures import RequestFailed

client = Client(key=self.secrets["EXTEND_AI_API_KEY"])

# Run an extraction processor on a document
run = client.run_processor(
    processor_id="proc_xxxxxxxxxxxxxxxxx",
    file_name="document.pdf",
    file_url="https://example.com/document.pdf",
    config=None,  # Use processor's default configuration
)

# Poll for completion
while run.status in (RunStatus.PENDING, RunStatus.PROCESSING):
    time.sleep(2)
    run = client.run_status(run.id)

# Get results
if run.status == RunStatus.PROCESSED:
    extracted_data = run.output.value
    # Clean up files after processing
    for file in run.files:
        client.delete_file(file.id)

# List available processors
for processor in client.list_processors():
    log.info(f"Processor: {processor.name} ({processor.type.value})")
```

**Required secrets:** `EXTEND_AI_API_KEY`

**No extra dependency needed** — the Extend AI client is included in the Canvas SDK.

---

### How to Use a Canvas SDK Client in Your Plugin

1. **Import** the client directly from the Canvas SDK in your handler:
   ```python
   from canvas_sdk.clients.aws import S3, Credentials
   from canvas_sdk.clients.llms import LlmAnthropic
   from canvas_sdk.clients.twilio.libraries import SmsClient
   from canvas_sdk.clients.sendgrid.libraries import EmailClient
   from canvas_sdk.clients.extend_ai.libraries import Client as ExtendAiClient
   ```

2. **Add secrets** to `CANVAS_MANIFEST.json`:
   ```json
   "secrets": ["API_KEY", "API_SECRET"]
   ```

3. **Initialize** the client in your handler using `self.secrets`:
   ```python
   client = S3(Credentials(
       key=self.secrets["S3_ACCESS_KEY"],
       secret=self.secrets["S3_SECRET_KEY"],
       region=self.secrets["S3_REGION"],
       bucket=self.secrets["S3_BUCKET"],
   ))
   ```

No extra dependencies or file copying needed — all clients are included in the Canvas SDK.
