Writing a Sync Plugin
This guide walks through writing a sync plugin in Rust — a WebAssembly component that icp-cli runs during icp sync to perform post-deployment work against a canister. If you only want to use an existing plugin (for example, one emitted by a recipe), you don’t need this guide; see Plugin Sync in the Configuration Reference instead.
For a complete, runnable project, see the icp-sync-plugin example.
Prerequisites
A plugin compiles to the wasm32-wasip2 target. Add it once:
rustup target add wasm32-wasip2You also need the plugin interface definition, sync-plugin.wit. Copy it into your plugin crate (e.g. as sync-plugin.wit) so the build can generate bindings from it. The .wit file is the source of truth for the interface.
Set Up the Crate
A plugin is a cdylib crate. Its Cargo.toml needs candid (to encode call arguments) and wit-bindgen (to generate the interface bindings):
[package]name = "my-plugin"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib"]
[dependencies]candid = "0.10"wit-bindgen = { version = "0.56", features = ["realloc"] }Generate Bindings and Implement exec
wit_bindgen::generate! reads the WIT at build time and produces the Guest trait you implement, the input/request types, and the canister_call host function. The exec export is your entry point — it returns Ok(()) on success or Err(message) to fail the sync step.
wit_bindgen::generate!({ world: "sync-plugin", path: "sync-plugin.wit",});
use candid::{Encode, Principal};
struct Plugin;
impl Guest for Plugin { fn exec(input: SyncExecInput) -> Result<(), String> { // stdout: transient progress, discarded when the step ends. println!( "syncing canister {} (environment: {})", input.canister_id, input.environment );
// Encode the Candid argument yourself; the host forwards the bytes unchanged. let uploader = Principal::from_text(&input.identity_principal) .map_err(|e| format!("invalid identity principal: {e}"))?; let arg = Encode!(&uploader).map_err(|e| format!("encode arg: {e}"))?;
// Call a method on the canister being synced. canister_call(&CanisterCallRequest { method: "set_uploader".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, direct: false, // route update calls through the proxy if one is configured cycles: 0, })?;
// stderr: printed persistently after the step completes — use for summaries. eprintln!("set_uploader: ok"); Ok(()) }}
export!(Plugin);A few things to note:
- You encode the arguments.
argis raw Candid bytes. Encode withcandid::Encode!; decode any response (Vec<u8>) withcandid::Decode!. - The target is fixed.
canister_callalways reaches the canister ininput.canister_id— there is no field to target another canister. directandcyclescontrol proxy routing. Withdirect: false, update calls go through the proxy canister when one is configured, andcyclescan fund the forwarded call. Withdirect: true, the call always goes straight to the target. See The Plugin Interface for the full semantics.
Read Declared Files and Directories
A plugin can’t see the filesystem freely — only what you grant it in the manifest’s dirs: and files:.
Directories in dirs: are preopened read-only at the same relative path. Traverse them with standard std::fs:
for dir in &input.dirs { for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? { let path = entry.map_err(|e| e.to_string())?.path(); let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; // ... encode and send to the canister ... }}Files in files: are read by the host up front and passed inline — read them from the input struct, not from disk:
for file in &input.files { println!("{} = {}", file.name, file.content.trim());}Writes, paths outside a preopen, and .. traversal are all rejected by the sandbox. See The Sandbox for the full capability list and resource limits.
Build
cargo build --target wasm32-wasip2 --releaseThe output .wasm (under target/wasm32-wasip2/release/) is loaded directly by icp-cli — no extra component-packaging step is required.
Wire It Into the Manifest
Reference the built wasm from a plugin sync step and declare the files and directories the plugin needs:
sync: steps: - type: plugin path: target/wasm32-wasip2/release/my_plugin.wasm dirs: - seed-data files: - config.txtThen run the sync phase:
icp sync my-canisterFor remote distribution, host the .wasm and reference it with url plus a required sha256. See Plugin Sync for all manifest fields.
Next Steps
- Sync Plugins — The mechanism, interface, and sandbox in depth
- Plugin Sync (Configuration Reference) — The manifest fields
- Proxy Canister — How proxied update calls and cycles work
icp-sync-pluginexample — A complete working project