# 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

```sh
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](wit.md): `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 guest_

```rust
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](manifest.md) and the [capabilities reference](capabilities.md).

_extensions/examples/addon-guide/addon.json_

```json
{
  "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 listen_

```rust
/// 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](events.md).

## 5. Cancel or modify with hooks

_extensions/examples/addon-guide/src/lib.rs · snippet hooks_

```rust
/// 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](hooks.md).

## 6. Emit your own event

_extensions/examples/addon-guide/src/lib.rs · snippet emit_

```rust
/// 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 mixin_

```rust
/// 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 routes_

```rust
/// 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 settings_

```rust
/// 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 kv_

```rust
/// 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 http_

```rust
/// 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 jobs_

```rust
// `capabilities.jobs` runs this op every `every_secs` seconds.
"cleanup" => {
    bump("cleanups");
    json!({ "ok": true })
}
```

## 12. Build, pack and install

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

