-
-
Notifications
You must be signed in to change notification settings - Fork 2
Contributor Integration Guide
Generated from
docs/contributor-integrations.md. Edit the canonical source through a pull request.
This is the practical companion to Canopy Architecture. Use it to find the existing bus, registry, adapter, or component seam before adding a new one.
Canopy is extensible, but it is not a general plugin host. Contributions land through explicit, typed integration points. The central rule is:
Extend the owner of the behavior. Do not create a second owner, a second event path, or a second component for the same concept.
Each contribution type has a focused, diagrammed playbook under
docs/contributions/.
| Contribution | Playbook | Reuse this seam |
|---|---|---|
| Theme or visual skin | Theme |
SKINS registry and CSS token contract |
| Reusable UI primitive | Component | Shared component plus platform adapters |
| Desktop-only UI | Desktop feature | Existing feature component and ProjectView composition |
| New project tab or panel | Project surface | Existing tab union, opener, render dispatch, restore paths |
| Native capability | Native capability | Tauri command, managed state, typed src/ipc.ts wrapper |
| Agent-visible tool | Agent tool | Token-gated context bridge and MCP descriptors |
| Search result source | Search source |
registerSpotSource and optional registerSpotIcon
|
| Issue tracker | Tracker |
TrackerProvider registry plus native fetch command if needed |
| Coding-agent CLI | Agent CLI | Agent CLI registry and verified launch/resume templates |
| Automated micro-task | Micro-task |
MicroTaskDef and MICRO_TASKS registry |
| Durable Canopy data | Durable store | Rust store, atomic write, store invalidation, frontend cache |
| Canopy Remote feature | Remote feature | Manifest request, Rust grant, generic RPC, panel registry |
| New file renderer | File viewer |
ViewerKind, extension dispatch, byte-based offline viewer |
| Keyboard shortcut | Shortcut | Shared manifest consumed by Rust menus and WebView handlers |
| Team collaboration message | Relay message | Existing relay protocol and app-wide RelayHandle
|
If the contribution does not fit a row, identify its authoritative state owner before writing code. A feature without one clear owner usually becomes two implementations that drift.
Most complete features use the same six-part shape.
- Authority: one module owns the state or native resource.
- Contract: a typed value, command, manifest, or adapter describes the boundary.
- Transport: an existing bus carries requests or invalidations.
- Projection: frontend code converts authoritative state into a view model.
- Presentation: a component renders the projection.
- Parity guard: tests prevent registries, transports, and consumers from silently disagreeing.
For example, notes use notes.rs as authority, Tauri commands as the mutation
contract, store:change as invalidation, src/notes.ts as the cache, Notes
components as presentation, and store guard tests as parity enforcement.
Do not begin with a new global event name. Begin with the authority and choose a bus from the next section.
Canopy deliberately has several buses because their trust, lifetime, and delivery guarantees differ. There is no universal application event bus.
| Need | Use | Do not use |
|---|---|---|
| Parent and child communicate | Typed props and callbacks | A global window event |
| Several React consumers observe one in-memory value |
createChannel in src/channel.ts with useChannel or useSyncExternalStore
|
Duplicated module-level listener sets |
| Rust tells the trusted WebView about native state | One typed listener wrapper in src/ipc.ts over a Tauri event or Channel
|
Browser polling or direct Tauri imports across components |
| Any writer changed a durable Rust store |
change::pulse -> store:change -> registerStore
|
Announcing only from the frontend caller |
App routes a targeted request to the correct project |
Existing canopy:* CustomEvent pattern with project or PTY identity |
A second app-wide state store |
| Agent HTTP request needs renderer-owned state | Context bridge ticket -> WebView event -> browser_result
|
Giving the agent direct Tauri access |
| Operation must wait for a not-yet-mounted preview |
dispatchBrowserOp / registerBrowserTarget
|
Dropping the request or polling for the component |
| Remote browser calls Rust | Existing WebSocket act / act-ack RPC and Rust grant table |
A feature-specific WebSocket connection |
| Two Canopy instances communicate | Encrypted team relay protocol | The local context bridge or Remote RPC |
| Two local agents coordinate |
canopy_claim and canopy_message_agent through context.rs
|
A repository file used as a lock or inbox |
Use React props, callbacks, and locally owned state when all participants are in one mounted tree. This is the default. A child should not dispatch a global event merely to reach a parent that can pass a callback.
Lift state only to its real shared owner. Application-wide state belongs in
App; project-wide operational state belongs in its ProjectView; durable or
privileged state usually belongs in Rust.
src/channel.ts is the standard module-store primitive. It provides a current
value, safe subscription, equality suppression, subscriber lifecycle hooks, and
React bindings.
const board = createChannel<State>(initialState)
export const state = board.get
export const subscribeState = board.subscribe
export const useState = () => useChannel(board)Keep domain policy in the owning module. createChannel is the wire, not a
place to put feature logic. Use onActive and onIdle when the first subscriber
should start a resource and the last should stop it.
Existing examples include prWatchStore.ts, companionContext.ts,
clipboardStore.ts, and activeView.ts.
Use Tauri IPC only for the trusted desktop WebView and Rust boundary.
| Shape | Use when | Example |
|---|---|---|
invoke request/response |
One bounded operation returns one result | Filesystem read, Git status, note mutation |
Tauri Channel
|
One invocation opens a high-volume or ordered stream | PTY bytes, LSP messages, companion output |
| Tauri event | Rust publishes invalidation or lifecycle state independent of one call |
pty:exit, pty:stats, store:change, filesystem changes |
Frontend feature code should call a typed wrapper in src/ipc.ts. Add the Rust
command to tauri::generate_handler! in src-tauri/src/lib.rs. Keep one global
native subscription per service and fan it out through a store or router when
many components need it.
Do not use an event when the caller requires a result. Do not use repeated
invoke calls for a continuous stream. Do not put large authoritative payloads
in invalidation events when readers can refetch only the slice they display.
Every durable store can have several writers: the WebView, a Tauri command, an agent through the context bridge, or Remote. Therefore the write boundary in Rust announces the change.
Rust store write
-> change::pulse(Store, scope, id)
-> debounced store:change Tauri event
-> src/stores.ts single listener
-> registerStore(storeId, handler)
-> owning frontend cache refresh
-> feature-specific UI event or channel update
To add a store to this bus:
- Add a
change::Storevariant and stableas_str()value. - Call
change::pulseonly after a successful authoritative mutation. - Register a module-scope handler with
registerStorein the frontend store. - Refresh from Rust rather than treating the event as complete state.
- Extend the store parity guard instead of bypassing it.
The pulse is scoped and debounced with a maximum wait. Do not emit once per keystroke or create one Tauri listener per component.
Some native or agent requests arrive at App before their target project is
mounted or awake. App resolves the project, wakes it if necessary, and emits a
targeted CustomEvent such as canopy:agent-action, canopy:agent-browser,
canopy:deep-link, or canopy:attach-terminal.
Use this pattern only for routing across independently mounted project trees.
The event detail must include enough stable identity for non-target
ProjectViews to ignore it. Register and remove the listener in the same
effect. If a direct callback or shared channel can express the relationship,
prefer that instead.
Agents are untrusted relative to the Tauri command surface. They receive a
per-PTY context credential and call the loopback bridge in context.rs. Rust
can answer native operations directly. Renderer-owned operations use a ticketed
request:
agent MCP tool
-> bearer-token loopback HTTP
-> Rust authenticates PTY identity
-> pending one-shot ticket
-> App and ProjectView route the request
-> UI or PreviewView executes it
-> browser_result(ticket, result)
-> waiting HTTP response completes
To add an agent tool:
- Add its MCP descriptor and dispatch in
canopy_hook.rs. - Add or reuse a context bridge endpoint and enforce the correct caller type.
- For UI-owned work, add a typed request shape in
src/ipc.tsand route it through the existing ticket/result path. - Add the user-facing tool row in
src/agentTools.tsso it can be disabled. - Decide whether the app-wide companion may call it with the anonymous root token or whether it requires a named PTY agent.
- Add timeout and error completion so a failed UI request cannot hang the agent.
Tool names are duplicated intentionally between the sidecar's model-facing
descriptors and src/agentTools.ts's human-facing descriptions. Keep them in
parity.
src/previewAgent.ts is a narrow queued bus between project routing and a
specific PreviewView. Use it for browser operations that may arrive before
the target view mounts. Registering a target drains queued operations and
returns an unregister function.
Do not generalize this queue into an application bus. Its per-tab buffering and mount semantics are specific to preview automation.
Remote uses one WebSocket. Panels call the generic promise wrapper in
portal/src/rpc.ts; Rust authorizes each action against
src-tauri/src/remote/mod.rs.
panel -> act{id, action, args} -> Rust grant/scope check -> handler -> act-ack
A new Remote feature should not add a new socket or wire message. Declare its
needs in shared/remote/modules/, deliberately grant the command in Rust, and
add a panel. Commands that spawn or mutate non-idempotently should use the
existing replay and single-flight protection.
The relay is an app-wide encrypted peer transport owned by Rust and projected
to views through RelayHandle in src/types.ts. Chat, review requests, file
transfer, and collaboration frames are protocol message types, not browser
events.
To add a relay message:
- Define and validate the wire payload in
relay.rs. - Preserve encryption, frame-size limits, and peer identity checks.
- Add a typed IPC projection in
src/ipc.ts. - Let
Appupdate the one app-wideRelayHandle. - Pass the handle to project views rather than opening another relay connection.
- Add protocol and UI behavior tests.
Collaborative editing has a separate owner-sequenced OT layer in src/collab.ts
and src/collab-ot.ts. Extend that protocol instead of sending raw editor
mutations through chat.
canopy_claim and canopy_message_agent are coordination tools served by the
context bridge.
- Claims are advisory, app-run-scoped ownership notices over paths. They do not block filesystem writes.
- Messages are attributed records delivered to another agent terminal. They are not equivalent to user keystrokes without provenance.
- Both derive sender identity from the context credential, not a body field.
Use claims before editing shared files in a multi-agent checkout. Use messages for handoff or collision warnings. Do not invent lockfiles or hidden inbox files inside the repository.
Canopy calls complete themes skins. A skin supplies the desktop token palette, Settings preview, xterm palette, and Monaco surface as one definition. The registry prevents a theme from recoloring the shell while leaving the terminal or editor behind.
For a skin with ID harbor:
src/skins/harbor.ts SkinDef: metadata, preview, xterm, Monaco
src/skins/harbor.css complete CSS token block
src/skins/skins.css @import "./harbor.css"
src/skins/registry.ts import and add to SKINS in picker order
src/skins/skins.test.ts verifies that every registered skin has every part.
Do not add a separate switch in Settings, terminal themes, or Monaco setup;
those consumers already read SKINS.
Copy an existing skin such as orchard.ts, then provide:
- a stable lowercase
idthat is also persisted and used asdata-theme; - a title-cased picker
label; - a short lowercase
notewithout a trailing period; - four preview colors;
- all xterm foreground, background, cursor, selection, and ANSI colors;
- a Monaco base and at least
editor.background.
Never rename a shipped skin ID. Existing settings persist it. Removing a skin is safe because unknown IDs fall back to Gotham, but renaming unexpectedly changes a user's selection.
The selector must be:
:root[data-theme="harbor"] {
/* complete token contract */
}Declare every palette token required by skins.test.ts, plus --accent-soft,
--ring, and color-scheme. A light skin must also provide its shadow tokens.
Use semantic variables in component CSS; do not add a component-specific color
override merely to make one skin pass.
Remote receives resolved theme tokens from the desktop and applies them through
shared/model.ts. A well-tokenized shared component should inherit the skin
without Remote-specific theme code.
Run:
npm run test -- src/skins/skins.test.ts
npm run typecheckThen inspect the Settings preview, app chrome, Monaco, terminal ANSI colors,
dialogs, focus rings, disabled controls, and both light/dark color-scheme
behavior where applicable.
| Component scope | Location | Rule |
|---|---|---|
| Used by desktop and Remote |
shared/ or shared/ui/
|
Browser-safe; inject platform operations |
| Desktop feature only | src/components/ |
May call typed domain modules; avoid direct native imports |
| Remote-only navigation or presentation | portal/src/ |
Use Remote RPC and shared models |
| Publishable external primitive | packages/ui/ |
Independent package API; not automatically used by the apps |
Do not put a component in packages/ui expecting the desktop to start using it.
Today the live applications import shared/ directly.
Before creating another implementation, check:
| Need | Existing primitive |
|---|---|
| Button | shared/ui/Button.tsx |
| Dialog, confirmation, prompt | shared/Dialog.tsx |
| Context menu | shared/ContextMenu.tsx |
| Large list | shared/WindowedList.tsx |
| File tree |
shared/FileTree.tsx with FileTreeFs adapter |
| Escape layering | shared/useEscape.ts |
| Icons |
shared/icons.tsx and src/components/icons.tsx
|
| Agent cards and marks |
shared/components.tsx, shared/agentGlyphs.tsx
|
| Design tokens |
shared/tokens.css and app skin variables |
The shared Button uses semantic variants: default, accent, danger, and
ghost. Add a variant only when it represents a reusable job, not a one-off
color. Icon-only buttons require an accessible name.
The shared Dialog already handles focus trapping, Escape ownership, Enter,
focus restoration, scroll locking, and exit animation. Do not recreate those
details in a feature modal.
A cross-shell component must describe the operations it needs without importing a platform.
export interface ThingAdapter {
read(id: string): Promise<Thing>
save?(thing: Thing): Promise<void>
}The desktop adapter can call src/ipc.ts. The Remote adapter can call
portal/src/rpc.ts. Optional mutation methods allow Remote to remain read-only
without fake implementations. shared/FileTree.tsx and its FileTreeFs
interface are the reference implementation.
- Use semantic CSS variables instead of literal colors.
- Put component CSS beside the shared component when both shells use it.
- Preserve the mounted-surface lifecycle for terminals, editors, previews, and stateful panels.
- Prefer controlled props and callbacks over global events.
- Keep expensive parsing, sanitization, and model creation out of render.
- Use established overlay, escape, and native-browser occlusion infrastructure.
- Add accessibility labels, keyboard behavior, focus handling, and a behavior test.
Some tests intentionally scan source code. For example,
shared/sharedComponents.test.ts ensures shared/ does not import the desktop,
the file tree is not duplicated, and Remote mutations remain gated. If such a
test fails, fix the architecture mismatch rather than adding an exemption.
| Feature shape | Integration path |
|---|---|
| Pure calculation or policy | Framework-free TypeScript or Rust module plus unit tests |
| Desktop presentation over existing data | Feature component plus existing domain store |
| New project surface | Tab/panel union, opener, render dispatch, restore/deep-link/search integration |
| Native or privileged operation | Rust owner, command registration, typed IPC wrapper, cleanup |
| Cross-shell behavior | Shared model/component plus desktop and Remote adapters |
| Remote exposure | Manifest request, explicit Rust grant, generic RPC, panel |
| Agent automation | Context bridge/MCP tool or MicroTaskDef, depending on who initiates it |
- Identify whether state is app-wide, project-wide, component-local, or durable.
- Reuse an existing store, channel, or feature domain module.
- Put pure behavior outside
App.tsxandProjectView/index.tsx. - Add a tab or panel only when the feature needs navigation or long-lived mounted state.
- Use shared components and tokenized CSS.
- Add SpotSearch, deep-link, shortcut, or restore integration only when the feature genuinely needs to be discoverable through that surface.
- Add behavior tests at the owner layer.
- Put ownership in one Rust module or managed service.
- Reuse
WorkspaceManagerfor path scope. - Reuse the existing blocking boundary for synchronous system commands.
- Bound output, queues, bodies, and retained history.
- Register the Tauri command in
lib.rs. - Add a typed wrapper in
src/ipc.ts. - Emit lifecycle or invalidation events from the authoritative write/resource boundary.
- Add shutdown handling before considering the feature complete.
- Keep Remote denied unless a separate least-privilege design is approved.
Tab contributions commonly touch more than the render switch:
src/components/ProjectView/helpers.ts tab or side-panel type
src/components/ProjectView/index.tsx open, select, render, close
src/tabKind.ts classification
src/deepLinks.ts direct navigation, if needed
src/spotSources.ts discovery, if needed
hibernation/restoration code persistence, if needed
Search for every switch over the relevant discriminated union before adding a new member. Preserve stable IDs and keep stateful panes mounted while hidden.
Registries are the preferred extension point when one exists.
Call registerSpotSource with a stable ID, section, timing, and row producer.
Use instant only for synchronous in-memory work. Use deferred for I/O; it is
debounced and isolated so one failed source does not empty the palette.
If the source introduces a new row kind, call registerSpotIcon. Both
registrations return cleanup functions. Test ordering, minimum query behavior,
failure isolation, and cleanup.
Add a TrackerProvider to TRACKERS in src/trackers.ts. The panel already
iterates providers. Define whether it is repository-scoped or global, how
availability is detected, how tickets are fetched, and whether local secret
configuration is required.
If native network or CLI work is needed, add a scoped Rust command and typed IPC wrapper. Map provider-specific states into the existing unified status model.
Add a CLI definition to the registry in src/projects.ts. Use a stable ID and
verified executable, prompt, and resume templates. Unknown resume behavior must
remain unsupported rather than guessed.
If the CLI supports hooks or MCP, extend installation and healing in
agents.rs. If it supports an app-wide companion transport, add that only after
the structured protocol is verified; otherwise use the existing terminal
fallback.
Add a MicroTaskDef in src/microTasks.ts and register it in MICRO_TASKS.
Define its source surface, payload, working directory, environment, brief,
effect, and progress protocol. Do not add generic tasks with invented progress
steps; a task's rail must reflect work the task can actually report.
Extend ViewerKind, viewerKindFor, source-view behavior, and the render
dispatch in src/components/viewers.tsx and its caller. Viewers receive bytes
read by Rust and should work offline. Lazy-load heavy parsers, sanitize rendered
HTML, revoke object URLs, and define size/binary refusal behavior before reading
unbounded files.
Add the command once to shared/shortcuts.json. The manifest is consumed by
WebView handlers and Rust native menus. Define platform chords, surfaces, and
terminal collision policy there instead of hardcoding key checks in a
component. Keep shared TypeScript and Rust parity tests green.
Contributors do not need to understand the entire application. Pick the profile closest to the change and read its minimum set.
| Profile | Minimum reading | Typical contribution |
|---|---|---|
| Visual designer |
src/skins/types.ts, existing skin pair, skins.test.ts, shared tokens |
New skin or token-safe polish |
| React component contributor | Shared primitives, nearest component, owning domain module, colocated tests | New view or reusable component |
| Rust/native contributor |
lib.rs, owning module, fsx.rs scope pattern, blocking.rs, exit cleanup |
Native capability or platform support |
| Agent integration contributor |
projects.ts, agents.rs, canopy_hook.rs, context.rs, agentTools.ts
|
New CLI, hook, model metadata, or MCP tool |
| Remote contributor |
portal.rs, remote/, shared/remote/, portal RPC and panel registry |
New read/drive surface |
| Knowledge workflow contributor | notes/research Rust and TypeScript stores, change.rs, stores.ts, Spot index |
New durable workflow or indexing |
| Collaboration contributor |
relay.rs, wsbridge.rs, collab.ts, collab-ot.ts
|
Peer message, transfer, or live editing |
| Search contributor |
spotSources.ts, SpotSearch.tsx, spotIcons.tsx, Spot index if persistent |
New discoverable source |
| Documentation contributor |
README.md, CONTRIBUTING.md, architecture pages, nearest code comments |
Guides, diagrams, examples, corrections |
Before opening a pull request, answer these questions in its description:
- Which module is the authority for the new state or resource?
- Which existing registry or bus carries it?
- Why are props,
createChannel, Tauri IPC, store invalidation, Remote RPC, or relay the correct transport? - Which duplicate implementation did the change avoid?
- What bounds memory, payload size, retries, and lifetime?
- What cleanup runs when the view, project, connection, or app closes?
- Which parity or behavior test proves every side is wired?
Common warning signs:
- a second WebSocket for one Remote panel;
- direct
@tauri-apps/apiimports scattered through feature components; - a new module with its own hand-rolled subscriber set;
- a frontend mutation that announces itself instead of the Rust store write;
- a desktop and Remote copy of the same component;
- a new modal that implements its own focus trap;
- a theme that changes CSS but not xterm or Monaco;
- a command added to Remote because it already exists for desktop;
- a hidden JSON file used as a local agent message bus;
- a new switch statement beside an existing registry.
Run focused tests while iterating, then the repository gate from
CONTRIBUTING.md.
| Change | Focused verification |
|---|---|
| Skin | npm run test -- src/skins/skins.test.ts |
| Shared component | Component test plus shared/sharedComponents.test.ts
|
| SpotSearch source |
src/spotSources.test.ts and src/components/SpotSearch.test.tsx
|
| Durable store | Rust store tests, frontend cache tests, store parity guard |
| Remote feature |
shared/remote/registry.test.ts and portal/src/remote.test.ts
|
| Shortcut | TypeScript shortcut tests and Rust shortcut tests |
| Agent tool | Sidecar/context Rust tests and frontend tool parity/route tests |
| Collaboration | OT unit/fuzz tests and relay protocol tests |
Repository gate:
npm run typecheck
npm run lint
npm run test
npm run build
cargo test --manifest-path src-tauri/Cargo.toml --no-default-featuresGenerated from FluidWorksApp/canopy-ide. Canonical documentation changes belong in the main repository.
Canopy Architecture
- Home
- Architecture
- Core Rust System
- LLM Context
- Integration Guide
- Contribution Playbooks
- Testing and Coverage
- Publish the Wiki
Playbooks