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 host functions (canister_call, canister_metadata_section). 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 { target: CallTarget::Host, // the canister being synced 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!. - You choose the target.
target: CallTarget::Hostreaches the canister being synced. To call another canister, declare it in the manifest’scanisters:list and address it withCallTarget::Name("ledger".into())— the name matches the entries ininput.canister_ids. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. A name is always the one the plugin’s own project uses, so hardcoding it stays correct when that project is vendored into a workspace as a subproject. 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 Canister Metadata
canister_metadata_section reads a metadata section off a canister — useful for inspecting what is actually deployed before acting on it, e.g. its candid:service interface:
let interface = canister_metadata_section(&MetadataSectionRequest { target: CallTarget::Host, // same targets, same rules, as canister_call name: "candid:service".to_string(), direct: false, // route through the proxy if one is configured})?;
match interface { Some(bytes) => println!("interface: {}", String::from_utf8_lossy(&bytes)), // `None` means the canister provably has no such section — not a failure. // A section you may not read, or a canister that does not exist, is an error. None => println!("canister exposes no Candid interface"),}direct picks who the target sees asking, which is what decides whether a private section is readable: a direct read is signed by the sync identity, a proxied one is made by the proxy canister on your behalf. See Reading canister metadata 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 files:. That one setting holds directories and files alike, named: seed: assets/seed-data. The host splits them by what is on disk and hands you input.dirs and input.files, so a plugin never declares up front which an entry will be.
Directories arrive in input.dirs, preopened read-only at the same relative path. Each entry gives you its path plus the key it was declared under. Traverse them with standard std::fs:
for dir in &input.dirs { for entry in std::fs::read_dir(&dir.path).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; dir.key groups related dirs ... }}Files arrive in input.files, read by the host up front and passed inline — read them from the input struct, not from disk. Each entry carries its key, name (the path), and content:
for file in &input.files { println!("{} = {}", file.name, file.content.trim());}Every entry carries the key it was declared under, so a plugin can group or label paths (for example, tell seed: directories from migrations:) without hardcoding paths. A name holding a list of paths yields several entries sharing that key.
Open each entry at the path it arrives with, whatever it looks like: a manifest may declare a directory elsewhere in the project (../shared/assets), and the preopen carries that same spelling. Writes, and paths that escape a preopen, are rejected by the sandbox at runtime. See The Sandbox for the full capability list and resource limits.
Read Declared Fields
Key-value pairs declared in the manifest’s fields: are passed inline as string values. Use them for small configuration a plugin needs without shipping a file:
for field in &input.fields { println!("{} = {}", field.name, field.value);}A value always arrives as a string, so parse the ones you want as another type — a manifest may write retries: 3 unquoted, and the plugin receives "3".
Know Where the Network Is
input.api_url is the endpoint the host submits your canister calls to, and input.gateway_url is the HTTP gateway serving canisters over HTTP — absent when the network exposes none. You have no sockets, so neither is something to fetch: use them to tell the user where something landed, or to hand a canister the address it is reachable at.
match &input.gateway_url { Some(gateway) => eprintln!("{} is served from {gateway}", input.canister_id), None => eprintln!("{} synced ({} has no HTTP gateway)", input.canister_id, input.environment),}Both arrive normalized, so a URL with no path carries a trailing slash (http://127.0.0.1:4943/) — strip it before joining a path onto it.
Build
cargo build --target wasm32-wasip2 --releaseThe output .wasm is loaded directly by icp-cli — no extra component-packaging step is required.
It lands in <cargo-target-dir>/wasm32-wasip2/release/, which is ./target only for a standalone
crate with no CARGO_TARGET_DIR or build.target-dir set; in a workspace it sits at the workspace root.
Wire It Into the Manifest
Reference the built wasm from a plugin sync step and declare the files, directories, and fields the plugin needs:
sync: steps: - type: plugin path: target/wasm32-wasip2/release/my_plugin.wasm files: seed: seed-data config: config.txt fields: api_url: https://example.com retries: 3path is a plain path relative to the canister directory — it is not expanded, so it cannot
reference $CARGO_TARGET_DIR. If your target directory is elsewhere, point path at the real
location or copy the plugin wasm into the canister directory as a build step.
Then 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