RaisFastRaisFast
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 added

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

FeatureSource
3 custom API endpoints[[routes]] in manifest
Auth-protected routesauth = "member" / "admin"
URL parameter extraction:dealId path param
Content lifecycle hook[hooks.on-content-created]
Cron daily stats[[cron]] with on_cron_tick
Database queriesdbQuery with parameterized SQL
Database writesdbExec for updates and inserts
ID generationnewId() for UUID v7
Event emissioneventEmit for custom events
File cachingvfsWrite / vfsRead
Permission gating[permissions] whitelist

Zero lines of Rust — pure JavaScript plugin.

On this page