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

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 {
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. arg is raw Candid bytes. Encode with candid::Encode!; decode any response (Vec<u8>) with candid::Decode!.
  • You choose the target. target: CallTarget::Host reaches the canister being synced. To call another canister, declare it in the manifest’s canisters: list and address it with CallTarget::Name("ledger".into()) — the name matches the entries in input.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.
  • 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 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

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

The 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: 3

path 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:

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