Not released yet. PeltyCMS is under development and not released yet; these docs describe the upcoming release and may change.
PeltyCMS Developer docs
User docsDevelopers
v0.1.0

Developer docs/Guides

Write a plugin in Rust

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.

#1. Scaffold

Shell
pelty-cli addon wasm-scaffold integration-hello --kind integration --lang rust --dir extensions/addons

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

#2. The entry point

All host calls arrive at call with an op name and a JSON input. The ops the host uses are hook, mixin and http, plus your own job ops.

extensions/examples/addon-guide/src/lib.rs · snippet guestRust
struct GuideAddon;
impl pelty_addon_sdk::Guest for GuideAddon {
    fn describe() -> String {
        json!({ "id": "guide", "name": "Addon guide example" }).to_string()
    }
    fn call(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 })
            }
            _ => return Err(format!("unknown op {op}")),
        };
        Ok(out.to_string())
    }
    fn on_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);

#3. The manifest

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" }
  }
}

#4. Listen to events

extensions/examples/addon-guide/src/lib.rs · snippet listenRust
/// Domain events listed in `capabilities.events` arrive here after the fact.
/// They cannot be cancelled; use them to sync, notify or count.
fn on_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.

#5. Cancel or modify with hooks

extensions/examples/addon-guide/src/lib.rs · snippet hooksRust
/// Pre-events and lifecycle phases listed in `capabilities.hooks` call op
/// `hook`. Return `proceed`, `modify(payload)` or `cancel(reason)`.
fn hook(input: &Value) -> Value {
    let event = input["event"].as_str().unwrap_or("");
    if input["phase"].as_str().unwrap_or("before") != "before" {
        return reply::proceed(); // during/after/finished only observe
    }
    let mut 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.

#6. Emit your own event

extensions/examples/addon-guide/src/lib.rs · snippet emitRust
/// Emit a custom event (`capabilities.emits`, name `<addon-id>:<event>`).
/// The `before` phase runs synchronously, so listeners can cancel or modify it.
fn greet(name: &str) -> Result<Value, String> {
    let e = pelty_addon_sdk::emit("integration-guide:greeted", &json!({ "name": name }))?;
    if e.cancelled {
        return Ok(json!({ "status": 409, "body": { "error": e.reason } }));
    }
    bump("greeted");
    Ok(json!({ "status": 200, "body": { "greeted": e.payload["name"] } }))
}

#7. Wrap a core route with a mixin

extensions/examples/addon-guide/src/lib.rs · snippet mixinRust
/// Mixins wrap a core route (`capabilities.mixins`). An `after` mixin may
/// change values of keys the response already has; new keys are dropped.
fn mixin(input: &Value) -> Value {
    let mut 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 {
        if let Some(c) = resp["city"].as_str() {
            resp["city"] = json!(c.to_uppercase());
        }
        return json!({ "response": resp });
    }
    json!({})
}

#8. Your own HTTP routes

extensions/examples/addon-guide/src/lib.rs · snippet routesRust
/// Routes in `capabilities.routes` are mounted at `/api/v1/addons/<id>/…`
/// and call op `http` with `{method, path, query, params, body, user}`.
fn http(input: &Value) -> Result<Value, String> {
    let m = input["method"].as_str().unwrap_or("");
    let path = input["path"].as_str().unwrap_or("");
    Ok(match (m, path) {
        ("GET", "/hello") => json!({ "status": 200, "body": { "message": format!("{}, world", greeting()) } }),
        ("POST", "/greet") => greet(input["body"]["name"].as_str().unwrap_or(""))?,
        ("GET", "/stats") => {
            let rows: Vec<Value> = ["paid", "registered", "greeted", "cleanups", "hook:client.before_register"].iter().map(|k| json!({ "key": k, "value": counter(k) })).collect();
            json!({ "status": 200, "body": { "rows": rows } })
        }
        _ => json!({ "status": 404, "body": null }),
    })
}

#9. Settings, secrets and storage

extensions/examples/addon-guide/src/lib.rs · snippet settingsRust
/// Admin settings (addon.json `settings`) and secrets (`capabilities.secrets`).
fn greeting() -> String {
    let _token_present = host::secret("api_token").is_some();
    pelty_addon_sdk::setting("greeting").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "Hello".into())
}
extensions/examples/addon-guide/src/lib.rs · snippet kvRust
/// Addon-private key/value storage (`capabilities.kv: true`).
fn bump(k: &str) {
    let _ = host::kv_set(k, &(counter(k) + 1).to_string());
}

#10. Outbound HTTP

extensions/examples/addon-guide/src/lib.rs · snippet httpRust
/// Outbound HTTP to hosts in `capabilities.http` only.
#[allow(dead_code)]
fn ping_example() -> Result<u16, String> {
    let (status, _body) = pelty_addon_sdk::fetch("GET", "https://example.com/", vec![], String::new())?;
    Ok(status)
}

#11. Background jobs

extensions/examples/addon-guide/src/lib.rs · snippet jobsRust
// `capabilities.jobs` runs this op every `every_secs` seconds.
"cleanup" => {
    bump("cleanups");
    json!({ "ok": true })
}

#12. Build, pack and install

Shell
cd extensions/addons
cargo build --release --target wasm32-wasip2 -p pelty-addon-guide
cp target/wasm32-wasip2/release/pelty_addon_guide.wasm ../examples/addon-guide/addon.wasm
pelty-cli addon hash ../examples/addon-guide
pelty-cli addon pack ../examples/addon-guide -o guide.zip

Upload the zip under Admin → Addons, review the capabilities, then install and enable it.