Plugins
Real Example — CRM Plugin
A complete CRM plugin with hooks, custom routes, database queries, and event emission.
This walkthrough builds a real-world CRM plugin: sales pipeline management, contact timeline, and custom API endpoints.
What We'll Build
CRM Plugin
├── Hooks: react to content creation, emit custom events
├── Routes: pipeline API, deal detail, stage update
├── Database: query deals, contacts, companies
└── Events: emit crm.lead_created when a contact is added1. Manifest
Create extensions/plugins/crm/manifest.toml:
[plugin]
id = "com.raisfast.crm"
name = "CRM API"
version = "0.1.0"
description = "Sales pipeline and contact management"
author = "RaisFast"
license = "MIT"
runtime = "js"
entry = "main.js"
[permissions]
max_memory_mb = 16
timeout_ms = 5000
database = ["crm_contacts", "crm_companies", "crm_deals", "crm_activities"]
http = ["api.example.com"]
config = ["app.*"]
filesystem = ["read-write"]
[hooks.on-content-created]
priority = 50
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"
auth = "public"
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline/:dealId"
handler = "getDealDetail"
auth = "member"
[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals/:dealId/stage"
handler = "updateDealStage"
auth = "admin"
description = "Move a deal to a new pipeline stage"
[[cron]]
label = "Daily Stats"
job_type = "crm_daily_stats"
cron_expr = "0 0 * * *"
payload = """{"type": "full"}"""2. Plugin Code
Create extensions/plugins/crm/main.js:
import {
dbQuery, dbExec, ok, fail,
extractJson, logInfo, eventEmit, newId,
vfsWrite, vfsRead, vfsExists
} from 'sdk';
const STAGES = [
"prospecting",
"qualification",
"proposal",
"negotiation",
"closed_won",
"closed_lost"
];
// ── Routes ──────────────────────────────────────
export function getPipeline() {
const pipeline = [];
for (const stage of STAGES) {
const rows = dbQuery(
`SELECT id, title, CAST(amount AS TEXT) as amount
FROM crm_deals WHERE stage = ?
ORDER BY amount DESC`,
[stage]
);
pipeline.push({ stage, deals: rows || [] });
}
return ok({ stages: pipeline });
}
export function getDealDetail(input) {
const dealId = extractJson(input, "params.dealId");
if (!dealId) return fail(400, "deal id required");
const deals = dbQuery(
`SELECT id, title, CAST(amount AS TEXT) as amount, stage
FROM crm_deals WHERE id = ?`,
[dealId]
);
if (!deals || deals.length === 0) return fail(404, "deal not found");
const activities = dbQuery(
`SELECT id, type, note, created_at
FROM crm_activities WHERE deal_id = ?
ORDER BY created_at DESC LIMIT 20`,
[dealId]
);
return ok({ deal: deals[0], activities: activities || [] });
}
export function updateDealStage(input) {
const dealId = extractJson(input, "params.dealId");
const body = extractJson(input, "body");
if (!dealId) return fail(400, "deal id required");
if (!body?.stage) return fail(400, "stage required");
if (!STAGES.includes(body.stage)) return fail(400, "invalid stage");
const result = dbExec(
"UPDATE crm_deals SET stage = ? WHERE id = ?",
[body.stage, dealId]
);
if (result.rows_affected === 0) return fail(404, "deal not found");
const activityId = newId();
dbExec(
"INSERT INTO crm_activities (id, deal_id, type, note) VALUES (?, ?, ?, ?)",
[activityId, dealId, "stage_change", `Moved to ${body.stage}`]
);
eventEmit("crm.deal_stage_changed", JSON.stringify({
deal_id: dealId,
new_stage: body.stage
}));
logInfo(`[crm] deal ${dealId} moved to ${body.stage}`);
return ok({ updated: true });
}
// ── Hooks ───────────────────────────────────────
export function on_content_created(input) {
const data = extractJson(input, "body");
if (!data) return ok(input);
if (data.content_type === "contact") {
logInfo(`[crm] new contact: ${data.id}`);
eventEmit("crm.lead_created", JSON.stringify({
contact_id: data.id
}));
}
return ok(input);
}
// ── Cron ────────────────────────────────────────
export function on_cron_tick(input) {
const data = extractJson(input, "body");
if (!data || data.job_type !== "crm_daily_stats") return;
const won = dbQuery(
`SELECT CAST(COUNT(*) AS TEXT) as cnt,
CAST(COALESCE(SUM(CAST(amount AS REAL)), 0) AS TEXT) as total
FROM crm_deals WHERE stage = 'closed_won'`
);
const active = dbQuery(
`SELECT CAST(COUNT(*) AS TEXT) as cnt
FROM crm_deals WHERE stage NOT IN ('closed_won', 'closed_lost')`
);
const stats = {
date: new Date().toISOString().slice(0, 10),
won_count: parseInt(won?.[0]?.cnt || "0"),
won_total: parseFloat(won?.[0]?.total || "0"),
active_count: parseInt(active?.[0]?.cnt || "0")
};
vfsWrite("cache/daily_stats.json", JSON.stringify(stats));
logInfo(`[crm] daily stats: ${JSON.stringify(stats)}`);
}3. Result
Start the server. The plugin loads automatically.
Get the full pipeline
curl http://localhost:9898/api/v1/plugins/crm/pipeline{
"code": 0,
"data": {
"stages": [
{ "stage": "prospecting", "deals": [...] },
{ "stage": "qualification", "deals": [...] },
{ "stage": "closed_won", "deals": [...] }
]
}
}Get deal detail with activities
curl http://localhost:9898/api/v1/plugins/crm/pipeline/deal123 \
-H "Authorization: Bearer TOKEN"Move a deal to a new stage
curl -X POST \
http://localhost:9898/api/v1/plugins/crm/deals/deal123/stage \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"stage": "negotiation"}'Custom event fired
Other plugins can listen to crm.deal_stage_changed and crm.lead_created by registering hooks with matching names.
What You Got From This
| Feature | Source |
|---|---|
| 3 custom API endpoints | [[routes]] in manifest |
| Auth-protected routes | auth = "member" / "admin" |
| URL parameter extraction | :dealId path param |
| Content lifecycle hook | [hooks.on-content-created] |
| Cron daily stats | [[cron]] with on_cron_tick |
| Database queries | dbQuery with parameterized SQL |
| Database writes | dbExec for updates and inserts |
| ID generation | newId() for UUID v7 |
| Event emission | eventEmit for custom events |
| File caching | vfsWrite / vfsRead |
| Permission gating | [permissions] whitelist |
Zero lines of Rust — pure JavaScript plugin.
