This walk-through uses extensions/examples/addon-guide. It is a real addon in the addons workspace. crates/pelty-api/tests/addon_guide.rs installs it into a test server and checks every feature shown here, so the samples below always compile and work.
This writes addon.json, Cargo.toml and src/lib.rs. An addon is a WebAssembly component exporting the addon interface from the WIT world: describe, call(op, input) and on-event(name, payload).
structGuideAddon;implpelty_addon_sdk::Guest forGuideAddon{fndescribe()-> String{json!({"id":"guide","name":"Addon guide example"}).to_string()}fncall(op: String, input: String)->Result<String, String>{let v: Value =serde_json::from_str(&input).map_err(|e|e.to_string())?;let out =match op.as_str(){"hook"=>hook(&v),"mixin"=>mixin(&v),"http"=>http(&v)?,// `capabilities.jobs` runs this op every `every_secs` seconds.
"cleanup"=>{bump("cleanups");json!({"ok":true})}_=>returnErr(format!("unknown op {op}")),};Ok(out.to_string())}fnon_event(name: String, payload: String)->Result<(), String>{let v: Value =serde_json::from_str(&payload).unwrap_or(Value::Null);on_event(&name,&v);Ok(())}}pelty_addon_sdk::export!(GuideAddon with_types_in pelty_addon_sdk);
addon.json declares what the addon may do. The admin sees and approves these capabilities at install, and the host refuses everything else. Every key is listed in the manifest reference and the capabilities reference.
extensions/examples/addon-guide/addon.jsonJSON
{"id":"integration-guide","name":"Addon guide example","version":"0.1.0","kind":"integration","engine":"wasm","provides":"guide","abi":"0.3.0","wasm":"addon.wasm","description":"addons.desc.integration.guide","author":"MythicalSystems","permissions":[],"capabilities":{"kv":true,"http":["example.com"],"secrets":["api_token"],"events":["invoice.paid","user.registered"],"emits":["integration-guide:greeted"],"hooks":[{"event":"client.before_register","priority":"normal"},{"event":"login.before","priority":"high","fail_open":true,"timeout_ms":500},{"event":"before:integration-guide:greeted"},{"event":"order.before_place","priority":"highest","ignore_cancelled":true},{"event":"after:PUT /api/v1/client/profile","priority":"monitor"}],"mixins":[{"target":"GET /api/v1/client/profile","at":"after"}],"jobs":[{"op":"cleanup","every_secs":3600}],"routes":[{"method":"GET","path":"/hello","access":"public"},{"method":"POST","path":"/greet","access":"client"},{"method":"GET","path":"/stats","access":"admin","permission":"reports.read"}],"ui":["iframe"]},"ui":{"pages":[{"id":"stats","area":"admin","title":"plugin.integration-guide.stats_title","icon":"ChartBar","nav":true,"data":{"stats":{"get":"/api/v1/addons/integration-guide/stats"}},"body":{"type":"stack","children":[{"type":"text","text":"{{t:plugin.integration-guide.stats_hint}}"},{"type":"table","rows":"{{data.stats.rows}}","columns":[{"header":"plugin.integration-guide.key","value":"{{row.key}}"},{"header":"plugin.integration-guide.value","value":"{{row.value}}"}]}]}},{"id":"frame","area":"client","title":"plugin.integration-guide.frame_title","icon":"AppWindow","nav":true,"frame":{"src":"assets/ui.html","height":200}}],"slots":[{"slot":"client.dashboard","body":{"type":"alert","text":"{{t:plugin.integration-guide.hello}}"}},{"slot":"admin.invoices.toolbar","order":5,"body":{"type":"badge","text":"{{t:plugin.integration-guide.badge}}"}}]},"settings":[{"key":"greeting","label":"Greeting text","kind":"text","required":false},{"key":"api_token","label":"API token","kind":"secret","required":false}],"i18n":{"en":{"stats_title":"Guide stats","stats_hint":"Counters kept by the guide addon.","key":"Counter","value":"Value","frame_title":"Guide frame","hello":"Hello from the guide addon.","badge":"Guide"},"ro":{"stats_title":"Statistici ghid","stats_hint":"Contoare ținute de addon-ul ghid.","key":"Contor","value":"Valoare","frame_title":"Cadru ghid","hello":"Salut de la addon-ul ghid.","badge":"Ghid"}}}
/// Domain events listed in `capabilities.events` arrive here after the fact.
/// They cannot be cancelled; use them to sync, notify or count.
fnon_event(name:&str, payload:&Value){match name {"invoice.paid"=>bump("paid"),"user.registered"=>{bump("registered");let_=host::kv_set("last_user", payload["user_id"].as_str().unwrap_or(""));}_=>{}}}
The available events and their payloads are in the event registry.
/// Pre-events and lifecycle phases listed in `capabilities.hooks` call op
/// `hook`. Return `proceed`, `modify(payload)` or `cancel(reason)`.
fnhook(input:&Value)-> Value{let event = input["event"].as_str().unwrap_or("");if input["phase"].as_str().unwrap_or("before")!="before"{returnreply::proceed();// during/after/finished only observe
}letmut p = input["payload"].clone();bump(&format!("hook:{event}"));match event {// Cancel: refuse sign-in for one domain.
"login.before"if p["email"].as_str().is_some_and(|e|e.ends_with("@denied.test"))=>reply::cancel("domain not allowed"),// Modify: tidy up the display name before the account is created.
"client.before_register"=>{
p["display_name"]=json!(p["display_name"].as_str().unwrap_or("").trim().to_string());reply::modify(p)}// Own custom event (declared as `before:integration-guide:greeted`,
// delivered with `event` = the bare name and `phase` = "before").
"integration-guide:greeted"if p["name"]=="nobody"=>reply::cancel("no greeting for nobody"),"order.before_place"=>order_hook(input, p),_=>reply::proceed(),}}
A cancelled pre-event makes the API answer 409 with error.addon_hook_cancelled. Priorities, fail_open and timeout_ms are described in hooks and mixins.
/// Emit a custom event (`capabilities.emits`, name `<addon-id>:<event>`).
/// The `before` phase runs synchronously, so listeners can cancel or modify it.
fngreet(name:&str)->Result<Value, String>{let e =pelty_addon_sdk::emit("integration-guide:greeted",&json!({"name": name }))?;if e.cancelled {returnOk(json!({"status":409,"body":{"error": e.reason }}));}bump("greeted");Ok(json!({"status":200,"body":{"greeted": e.payload["name"]}}))}
/// Mixins wrap a core route (`capabilities.mixins`). An `after` mixin may
/// change values of keys the response already has; new keys are dropped.
fnmixin(input:&Value)-> Value{letmut resp = input["response"].clone();// Opt-in per request so this example never changes other callers.
let wanted = input["request"]["query"].as_str().unwrap_or("").contains("guide=1");if input["at"]=="after"&& wanted {ifletSome(c)= resp["city"].as_str(){
resp["city"]=json!(c.to_uppercase());}returnjson!({"response": resp });}json!({})}