| title | Sync Plugins |
|---|---|
| description | How sync plugins extend the sync phase with sandboxed WebAssembly components that run arbitrary post-deployment logic against a canister. |
A sync plugin is a WebAssembly component that runs during the sync phase to perform arbitrary post-deployment work against a single canister. icp-cli loads the plugin into a sandboxed wasmtime WASI runtime, hands it the ID of the canister being synced, and lets it make canister calls and read declared files — nothing more.
You declare a sync plugin in your manifest with a plugin sync step. For the exact manifest fields, see Plugin Sync in the Configuration Reference. To author your own plugin, see Writing a Sync Plugin.
The built-in script sync step covers simple post-deployment commands, but shelling out has drawbacks: scripts are unstructured, run with your full user privileges, and can't be distributed as a single verifiable artifact.
Sync plugins fill that gap. A plugin is:
- Portable — written in any language that compiles to
wasm32-wasip2, distributed as one.wasmfile (local path or remote URL +sha256). - Sandboxed — it cannot open network sockets, spawn subprocesses, or touch the filesystem outside the directories you explicitly grant it.
- Scoped to one canister — it can call update and query methods, but only on the canister being synced. The target is fixed by the host; the plugin cannot choose a different one.
The most common way to get a sync plugin is through a recipe. For example, the @dfinity/asset-canister recipe emits a plugin sync step (starting with v2.2.1) that uploads your built static files to the asset canister — so for everyday frontend deployment you never write a plugin yourself.
When a plugin sync step executes for a canister, icp-cli:
- Resolves the wasm — reads the local
path, or downloads theurlto the package cache. - Verifies the
sha256checksum if one is given (required forurl). - Reads any files listed in
files:and preopens any directories listed indirs:read-only. - Instantiates the component in a WASI sandbox and calls its
exec()export. - Forwards the plugin's output to the CLI and reports success or the returned error.
icp sync
└─ host loads plugin.wasm into the WASI sandbox
├─ exec(sync-exec-input) called
│ canister-id = <canister being synced>
│ identity-principal = <your signing identity>
│ dirs / files = what you declared in the manifest
│
└─ plugin makes canister-call(...) to the target canister (× N)
The interface is defined as a WIT world. The host provides one import (canister-call); the plugin provides one export (exec):
world sync-plugin {
// Host import: call the canister being synced.
import canister-call: func(req: canister-call-request) -> result<list<u8>, string>;
// Plugin export: run the sync step.
export exec: func(input: sync-exec-input) -> result<_, string>;
}The authoritative interface, including all record fields, lives in sync-plugin.wit in the icp-cli repository.
| Field | Description |
|---|---|
canister-id |
Textual principal of the canister being synced |
environment |
Name of the environment being synced (e.g. local, production) |
dirs |
The directories you declared in dirs:; the host preopened each one read-only |
files |
The files you declared in files:, each as a (name, content) pair read by the host |
identity-principal |
Textual principal of the signing identity used for canister calls |
proxy-canister-id |
Textual principal of the proxy canister if one was configured via --proxy, otherwise absent |
The plugin calls methods on the target canister through the canister-call import. It supplies the method name, Candid-encoded argument bytes (the host forwards them unchanged), and a few routing options:
| Request field | Meaning |
|---|---|
method |
The canister method to call |
arg |
Candid-encoded argument bytes (the plugin encodes; the host forwards as-is) |
call-type |
update or query |
direct |
When false (default), update calls are routed through the proxy canister if one is configured; when true, the call always goes directly to the target. Query calls always go directly regardless. |
cycles |
Cycles to attach to a proxied update call; only meaningful when direct is false, a proxy is configured, and call-type is update |
The host always calls the canister named in sync-exec-input.canister-id. There is no field for a different canister ID — the single-canister restriction is structural, not a policy the plugin can opt out of.
The plugin's stdout and stderr are captured by the host (no logging import is needed — use ordinary println! / eprintln!):
- stdout is shown as transient progress in the rolling step view and discarded when the step ends. Use it for in-flight chatter.
- stderr is shown in the rolling view and printed persistently after the step completes successfully. Use it for messages the user must still see afterward — warnings, summaries, deprecation notices.
Each stream is capped at 1 MiB; output beyond that is truncated with a note.
The plugin runs with a deliberately narrow capability surface.
- Each directory in
dirs:is preopened read-only. The plugin sees it at the same relative path it used in the manifest (e.g.dirs: ["assets"]is visible asassets/inside the guest) and traverses it with standard filesystem APIs (std::fsin Rust). - Files in
files:are read by the host up front and passed inline insync-exec-input.files. The plugin reads their content from the input struct, not from disk. - Any path outside a preopen is invisible. Writes, creates, deletes, renames, and symlinks that escape a preopen are rejected. Paths in
dirs:/files:must be relative and may not contain...
| Capability | Available? | Notes |
|---|---|---|
Read declared dirs: |
yes | read-only preopens |
Clocks, RNG, wasi:io |
yes | Rust's HashMap, chrono, etc. work normally |
process::exit / panics |
yes | abort the guest cleanly; the host surfaces the error |
| Canister calls | yes | only to the canister being synced |
| Environment variables / args | no | the WASI environment is empty; use sync-exec-input.environment |
| Network sockets / DNS | blocked | treat the network as unavailable |
| Filesystem writes | blocked | no writable preopens |
| Spawning subprocesses | blocked | no process interface is linked |
| Resource | Limit |
|---|---|
| Wasm call-stack depth | 512 KiB |
| Pure compute time | 60 seconds |
| Linear memory | wasm32 address space (≤ 4 GiB) |
| stdout / stderr per stream | 1 MiB |
The 60-second budget counts only wasm instruction execution. Time spent waiting for a canister-call to return over the network is not charged against it — the host grants that time back when the call completes. A plugin can make as many canister calls as it needs without the network latency eating into its compute limit.
- Writing a Sync Plugin — Author your own plugin in Rust
- Plugin Sync (Configuration Reference) — The manifest fields
- Build, Deploy, Sync — Where the sync phase fits in the lifecycle
- Recipes — How recipes can emit a
pluginsync step for you