For AI agents: Documentation index at https://cli.internetcomputer.org/llms.txt

Skip to content
ICP CLI
Feedback welcome! Report issues on GitHub, ask questions on the Forum, or chat with us on Discord.

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:

Terminal window
rustup target add wasm32-wasip2

You 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.

src/lib.rs
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. arg is raw Candid bytes. Encode with candid::Encode!; decode any response (Vec<u8>) with candid::Decode!.
  • The target is fixed. canister_call always reaches the canister in input.canister_id — there is no field to target another canister.
  • direct and cycles control proxy routing. With direct: false, update calls go through the proxy canister when one is configured, and cycles can fund the forwarded call. With direct: 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

Terminal window
cargo build --target wasm32-wasip2 --release

The 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.txt

Then run the sync phase:

Terminal window
icp sync my-canister

For remote distribution, host the .wasm and reference it with url plus a required sha256. See Plugin Sync for all manifest fields.

Next Steps