> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Writing your own plugin

> The real Rust Plugin trait, how built-ins are registered, and the honest state of extensibility.

Pylon's plugin system is a Rust trait registered inside the runtime at boot. It's how the built-ins (`rate_limit`, `tenant_scope`, `owner_stamp`, `csrf`) are wired.

Be clear about the current state before you reach for it:

* **There is no dynamic plugin loading and no `manifest.plugins` array.** Built-ins are registered in the runtime's Rust code. To ship your own, you build a custom runtime that registers it.
* **Most needs don't require a plugin.** App logic belongs in [server functions](/concepts/functions) + [policies](/concepts/policies); schema-level automation is covered by [tenant\_scope / owner\_stamp / behaviors](/plugins/data).
* The SDK exports a `definePlugin({ name, entities, hooks })` helper, but the runtime does **not** consume its output yet — TS-authored plugins aren't wired. Treat it as a placeholder, not a supported path.

## When to write one

| Need                                                     | Plugin?                                  |
| -------------------------------------------------------- | ---------------------------------------- |
| One-off business logic                                   | No — write a function                    |
| Auth-stamped ownership / tenant scoping                  | No — use `.owner()` / a `tenantId` field |
| Timestamp / soft-delete columns                          | No — use `behaviors([...])`              |
| Cross-cutting behavior on **every** write, in the binary | Yes — write a Rust plugin                |
| A new server-side HTTP route in the binary               | Yes — write a Rust plugin                |

## The `Plugin` trait

The real trait (`pylon_plugin::Plugin`). Every method has a default no-op impl — override only what you need:

```rust theme={null}
use pylon_plugin::{Plugin, PluginError, PluginContext, PluginRoute, RequestMeta};
use pylon_auth::AuthContext;
use serde_json::Value;

pub trait Plugin: Send + Sync {
    /// Unique name for this plugin.
    fn name(&self) -> &str;

    /// Called once when the plugin is registered.
    fn on_init(&self, _ctx: &PluginContext) {}

    /// Custom API routes this plugin handles.
    fn routes(&self) -> Vec<PluginRoute> { vec![] }

    /// Before an entity insert. Mutate `data` in place; return Err to reject.
    fn before_insert(&self, _entity: &str, _data: &mut Value, _auth: &AuthContext)
        -> Result<(), PluginError> { Ok(()) }
    fn after_insert(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {}

    /// Before an entity update. Mutate `data`; return Err to reject.
    fn before_update(&self, _entity: &str, _id: &str, _data: &mut Value, _auth: &AuthContext)
        -> Result<(), PluginError> { Ok(()) }
    fn after_update(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {}

    /// Before an entity delete. Return Err to reject.
    fn before_delete(&self, _entity: &str, _id: &str, _auth: &AuthContext)
        -> Result<(), PluginError> { Ok(()) }
    fn after_delete(&self, _entity: &str, _id: &str, _auth: &AuthContext) {}

    /// On every incoming request (middleware). Return Err to short-circuit.
    fn on_request(&self, _method: &str, _path: &str, _auth: &AuthContext)
        -> Result<(), PluginError> { Ok(()) }

    /// Richer variant with per-request metadata (peer IP today).
    /// Defaults to delegating to `on_request`.
    fn on_request_with_meta(&self, method: &str, path: &str, auth: &AuthContext,
        _meta: &RequestMeta<'_>) -> Result<(), PluginError> {
        self.on_request(method, path, auth)
    }

    /// When a new session is created.
    fn on_session_create(&self, _user_id: &str, _token: &str) {}

    /// Additional manifest entities this plugin contributes.
    fn entities(&self) -> Vec<pylon_kernel::ManifestEntity> { vec![] }
}
```

Note there is **no** unified `before_write`/`after_write` with a `WriteOp` enum — insert / update / delete are separate hooks.

### `PluginError`

Rejections carry a code, message, and HTTP status (it's a struct, not an enum):

```rust theme={null}
return Err(PluginError {
    code: "BLOCKED".into(),
    message: "inserts to this entity are not allowed".into(),
    status: 403,
});
```

## Example: a before-insert plugin

The pattern the built-ins use — mutate `data` before it lands:

```rust theme={null}
use pylon_plugin::{Plugin, PluginError};
use pylon_auth::AuthContext;
use serde_json::Value;

pub struct Timestamps;

impl Plugin for Timestamps {
    fn name(&self) -> &str { "timestamps" }

    fn before_insert(
        &self,
        _entity: &str,
        data: &mut Value,
        _auth: &AuthContext,
    ) -> Result<(), PluginError> {
        if let Some(obj) = data.as_object_mut() {
            let now = chrono::Utc::now().to_rfc3339();
            if !obj.contains_key("createdAt") {
                obj.insert("createdAt".into(), Value::String(now.clone()));
            }
            obj.insert("updatedAt".into(), Value::String(now));
        }
        Ok(())
    }
}
```

## Example: a per-request gate

`on_request_with_meta` runs before dispatch and receives the peer IP — this is how `rate_limit` buckets anonymous callers:

```rust theme={null}
use pylon_plugin::{Plugin, PluginError, RequestMeta};
use pylon_auth::AuthContext;

impl Plugin for MyLimiter {
    fn name(&self) -> &str { "my_limiter" }

    fn on_request_with_meta(
        &self,
        _method: &str,
        _path: &str,
        auth: &AuthContext,
        meta: &RequestMeta<'_>,
    ) -> Result<(), PluginError> {
        let key = match auth.user_id.as_ref() {
            Some(uid) => format!("user:{uid}"),
            None => format!("ip:{}", meta.peer_ip),
        };
        self.check(&key) // Err(PluginError { status: 429, .. }) if over budget
    }
}
```

## Example: adding an HTTP route

`routes()` returns `PluginRoute`s; the router matches them after its built-in routes by method + path prefix. The handler receives `(body, path, auth)` and returns `(status, body)`:

```rust theme={null}
use pylon_plugin::{Plugin, PluginRoute};

impl Plugin for MyWebhookReceiver {
    fn name(&self) -> &str { "my_webhook" }

    fn routes(&self) -> Vec<PluginRoute> {
        vec![PluginRoute {
            method: "POST".into(),
            path: "/api/webhooks/my-service".into(),
            handler: Box::new(|body, _path, _auth| {
                // verify signature, parse, dispatch...
                (200, "{}".into())
            }),
        }]
    }
}
```

## Registering with the runtime

Plugins are added to a `PluginRegistry` at boot — the same place the built-ins are registered:

```rust theme={null}
use std::sync::Arc;
use pylon_plugin::PluginRegistry;

let mut reg = PluginRegistry::new(runtime.manifest().clone());
reg.register(Arc::new(Timestamps));
// The default runtime also registers rate_limit, tenant_scope, owner_stamp here.
```

There is no name-to-constructor lookup from a manifest, and no runtime-loaded plugin bundle. **To ship your own today you fork the runtime** (`crates/runtime`) and add your `reg.register(...)` call, then build your own `pylon` binary from it. That's the honest state — a dynamic/loadable plugin path and a wired `definePlugin` are on the roadmap.

## Testing

The trait is plain Rust — no harness needed:

```rust theme={null}
#[test]
fn timestamps_stamps_on_insert() {
    let plugin = Timestamps;
    let mut data = serde_json::json!({ "title": "x" });
    plugin
        .before_insert("Post", &mut data, &AuthContext::anonymous())
        .unwrap();
    assert!(data["createdAt"].is_string());
    assert!(data["updatedAt"].is_string());
}
```

## Reference: the built-ins

Read the real built-ins in `crates/plugin/src/builtin/` as templates:

* **Before-write data mutation** — `tenant_scope.rs`, `owner_stamp.rs`
* **Per-request gate** — `rate_limit.rs`, `csrf.rs`
* **External-service integration** — `ai_proxy.rs`

Pick the closest match and adapt.
