Build a Plugin (TypeScript & AssemblyScript)
This guide walks you through building a custom Salamandr WebAssembly plugin from scratch using TypeScript (AssemblyScript).
Plugins compile directly into freestanding .wasm binaries and run in Salamandr’s secure, sandboxed WebAssembly runtime. They allow you to pull customer context from internal databases, CRM systems, ERPs, or APIs directly into the agent’s Context Rail on demand without modifying the core server.
What an AssemblyScript Plugin Can Do
Section titled “What an AssemblyScript Plugin Can Do”Salamandr plugins run in a zero-trust sandbox. Every capability must be declared in your manifest.toml and approved by the tenant administrator:
| Capability | AssemblyScript SDK Function | Required Manifest Scope |
|---|---|---|
| Outbound HTTP calls | httpFetch(req) |
scopes.http_hosts |
| Encrypted tenant secrets | getConfig("api_key") |
scopes.config_keys |
| Key/Value persistent store | kvGet / kvSet / kvDelete |
scopes.kv = true |
| Read core helpdesk records | coreCall("tickets", ...) |
scopes.core = ["tickets:read"] |
| Rich formatting | richtextToMarkdown(html) |
None |
| Server logging | log(msg) |
None |
1. Project Initialization
Section titled “1. Project Initialization”Instant Scaffolding (Recommended)
Section titled “Instant Scaffolding (Recommended)”You can scaffold a complete TypeScript / AssemblyScript plugin with testing fixtures in seconds using the official CLI:
npx salamandrsdk new -name my-ts-plugin -lang ts # Single-file layoutnpx salamandrsdk new -name my-ts-plugin -lang ts -split # Modular layout (models, client, views, actions)For a deep dive into structuring production plugins with clean separation of logic and presentation, see the CRM Plugin Architecture Guide.
Manual Setup
Section titled “Manual Setup”Alternatively, create a new directory for your plugin from scratch:
mkdir my-ts-plugin && cd my-ts-pluginnpm init -ynpm install --save-dev assemblyscriptnpm install @salamandr/plugin-sdkCreate asconfig.json in the root directory:
{ "targets": { "release": { "outFile": "dist/plugin.wasm", "optimizeLevel": 3, "shrinkLevel": 2, "converge": true, "noExportRuntime": false } }}Add build scripts to package.json:
{ "name": "my-ts-plugin", "version": "0.1.0", "scripts": { "build": "asc assembly/index.ts --target release", "test": "asc --runTests" }, "devDependencies": { "assemblyscript": "^0.27.0" }, "dependencies": { "@salamandr/plugin-sdk": "^0.3.0" }}2. Define the Manifest (manifest.toml)
Section titled “2. Define the Manifest (manifest.toml)”Create manifest.toml in your project root:
name = "acme-crm-ts"version = "0.1.0"kind = "crm"author = "Your Engineering Team"description = "Displays requester CRM profile and company tier in TypeScript."
entrypoints = ["fetch_context", "log_note"]
[scopes]# Allowed outbound HTTP hostshttp_hosts = ["api.acme-crm.com"]
# Encrypted configuration keysconfig_keys = ["api_key"]
# Read tickets and requester informationcore = ["users:read", "tickets:read"]3. Writing the Plugin (assembly/index.ts)
Section titled “3. Writing the Plugin (assembly/index.ts)”Create assembly/index.ts with the complete, strongly-typed plugin implementation:
import { Action, ActionOutcome, ActionRequest, ActionResult, ContextBlock, ContextRequest, ContextResult, HttpRequest, HttpResponse, JValue, OpError, getConfig, httpFetch, plugin} from "@salamandr/plugin-sdk";
const API_BASE = "https://api.acme-crm.com/v1";
/// 1. Context Entrypoint: Called whenever an agent opens a ticketexport function fetchContext(req: ContextRequest): ContextResult { // Read tenant API key const keyLookup = getConfig("api_key"); if (keyLookup.isMissing || keyLookup.isErr) { return ContextResult.err(OpError.notConfigured()); } const apiKey = keyLookup.unwrapOr("");
// Iterate over candidate emails associated with the requester const emails = req.emails(); for (let i = 0; i < emails.length; i++) { const email = emails[i]; const contactBlock = findContact(apiKey, email, req); if (contactBlock !== null) { return ContextResult.ok([contactBlock]); } }
return ContextResult.ok([]);}
/// Query the third-party API via sandboxed HTTPfunction findContact(apiKey: string, email: string, req: ContextRequest): ContextBlock | null { const url = API_BASE + "/contacts?email=" + encodeURIComponent(email);
const httpReq = HttpRequest.get(url) .header("Authorization", "Bearer " + apiKey) .header("Accept", "application/json");
const fetchRes = httpFetch(httpReq); if (fetchRes.isErr) { return null; }
const res = fetchRes.response!; if (res.status === 200) { const parsed = JValue.parse(res.body); if (!parsed.isObject()) return null;
const root = parsed.asObject(); if (!root.has("results")) return null;
const results = root.getArray("results"); if (results.length === 0) return null;
const contact = results[0].asObject(); const contactId = contact.getString("id", ""); const fullName = contact.getString("full_name", email); const company = contact.getString("company", "Independent"); const plan = contact.getString("plan", "Standard"); const ltv = contact.getString("lifetime_value", "$0.00");
// Build the Context Rail Block return new ContextBlock("Acme CRM (TypeScript)") .field("Name", fullName) .field("Company", company) .field("Subscription Plan", plan) .field("Lifetime Value", ltv) .field("Ticket Reference", "Ticket #" + req.ticket_number.toString()) .action("Open in CRM", "https://app.acme-crm.com/contacts/" + contactId) .run( Action.run("Log Account Note", "log_note") .param("contact_id", contactId) .textarea("body", "Note Content", true) .placeholder("What should the account manager know about this ticket?") ); }
return null;}
/// 2. Action Entrypoint: Triggered when agent submits a noteexport function logNote(req: ActionRequest): ActionResult { const keyLookup = getConfig("api_key"); if (keyLookup.isMissing || keyLookup.isErr) { return ActionResult.err(OpError.notConfigured()); } const apiKey = keyLookup.unwrapOr("");
const contactId = req.param("contact_id"); const noteBody = req.input("body");
const payload = "{\"contact_id\":\"" + contactId + "\",\"body\":\"" + noteBody + "\\n\\n— Signed: " + req.signature() + "\"}";
const httpReq = HttpRequest.post(API_BASE + "/notes", payload) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json");
const fetchRes = httpFetch(httpReq); if (fetchRes.isErr) { return ActionResult.err(fetchRes.error!); }
const res = fetchRes.response!; if (res.status === 200 || res.status === 201) { return ActionResult.ok( new ActionOutcome("Note successfully logged in Acme CRM") .url("https://app.acme-crm.com/contacts/" + contactId) ); }
return ActionResult.err(OpError.other("Failed to log note: HTTP " + res.status.toString()));}
/// Register WebAssembly Exportsplugin({ contextOps: { "fetch_context": fetchContext }, actionOps: { "log_note": logNote }});4. Building the WebAssembly Binary
Section titled “4. Building the WebAssembly Binary”Run the build script:
npm run buildThis compiles your TypeScript source code into a compact, optimized WebAssembly binary at dist/plugin.wasm (typically < 100 KB).
5. Installing the Plugin in Salamandr
Section titled “5. Installing the Plugin in Salamandr”Using the Admin Web UI
Section titled “Using the Admin Web UI”- Navigate to Admin Panel → Extensions → Install Extension.
- Upload
manifest.tomlanddist/plugin.wasm. - The Admin Panel displays all declared permission scopes (
api.acme-crm.com) for verification. - Enter your
api_keyunder Settings. - Toggle Enable Extension.
Using the REST API
Section titled “Using the REST API”curl -X POST https://helpdesk.yourcompany.com/api/v1/extensions \ -H "Authorization: Bearer YOUR_ADMIN_API_KEY" \ -F manifest=@manifest.toml \ -F wasm=@dist/plugin.wasmWhen an agent opens a ticket, Salamandr executes fetchContext inside the WebAssembly sandbox, instantly rendering the live customer data in the Context Rail.