npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

samvaad-app-sdk

v1.1.0

Published

CLI and iframe SDK to build, test, and pack Samvaad custom apps independently of the helpdesk server.

Readme

Samvaad App SDK

The Samvaad App SDK is the complete toolkit for building custom apps that extend a Samvaad helpdesk. You work on your own machine. You do not need anything from the helpdesk side beyond a normal agent login for live iframe testing.

A custom app is a private extension of one tenant. It is installed only where an administrator uploads it. It is not published to a shared catalog, and it is not visible to other organisations.

This file ships inside the samvaad-app-sdk npm package. It is the developer source of truth for framework version 1.0. Administrators who only install packed files should read the in-product Help Center article Custom apps. The latest version of this guide is at https://developers.samvaads.com/docs/custom-apps.


What you can build

A custom app can do any combination of the following:

  1. Frontend (iframe) UI — HTML, CSS, and JavaScript under assets/. The helpdesk shows this UI in the Apps panel on ticket details (ticket_sidebar) and/or inbox conversation details (conversation_sidebar). The document runs in a sandboxed iframe. It receives ticket, conversation, contact, and current-user context through SamvaadClient. It cannot read the agent session, cookies, or the parent page DOM.

  2. Serverless HTTP — When a helpdesk event fires (ticket created, message received, and others listed below), Samvaad sends a signed HTTPS POST to a URL you host. You verify the HMAC signature, run your own logic (database, CRM, billing, inventory), and return HTTP 2xx.

  3. Serverless hosted JavaScript — You ship server/server.js in the bundle. The worker runs exported handler functions in a restricted sandbox: no require, no process, no filesystem, about three seconds of CPU, outbound HTTP only through request() to hosts listed in permissions.allowedHosts. Use this for short, reviewed logic that must live next to the install. Use HTTP mode for anything that needs a real runtime, secrets store, or long work.

  4. Hybrid — One manifest declares both locations and events. Agents see the sidebar UI; the same install also receives events.

Typical uses:

  • Show order status, subscription plan, or device warranty next to a ticket.
  • Open a related record in an internal tool (via a link or client.request).
  • Push ticket-created events into an internal queue or data warehouse.
  • Enrich a conversation with CRM fields when a message arrives.
  • Notify an on-call channel when a high-priority ticket is assigned.

Contents

  1. How the pieces fit together
  2. Roles and who does what
  3. Prerequisites
  4. Install the SDK
  5. First app in fifteen minutes
  6. Create an app
  7. Project layout
  8. Manifest reference
  9. Installation parameters (iparams)
  10. OAuth connections to external systems
  11. Frontend (iframe) apps
  12. Ticket and conversation context
  13. SamvaadClient API
  14. Host–app message protocol
  15. Sidebar UI guidelines
  16. Serverless apps
  17. Event catalogue
  18. HTTP mode in depth
  19. Hosted JavaScript in depth
  20. Local development and testing
  21. Pack a bundle
  22. Upload and run on the helpdesk
  23. Update an installed app
  24. Versioning and identity
  25. CLI reference
  26. Templates
  27. REST endpoints developers should know
  28. Limits
  29. Security model
  30. Validation error catalogue
  31. Troubleshooting
  32. TypeScript
  33. Continuous integration
  34. Glossary
  35. FAQ

How the pieces fit together

| Piece | Who uses it | Role | | --- | --- | --- | | samvaad-app-sdk (this package) | Developer | CLI, templates, iframe script (samvaad-apps.js), TypeScript types, validators | | App project (your folder) | Developer | manifest.json, assets/, optional server/, optional README and tests | | Bundle (.samvaad-app.json) | Developer → admin | One UTF-8 JSON file: schema, manifest, and a map of packed files | | Admin → Custom apps | Helpdesk admin | Install, enable, configure parameters, view invocation logs, download this SDK | | Agent workspace | Agents | Sees iframe apps in the Apps panel on tickets and chats they can already open | | Samvaad | Platform | Stores the bundle, serves iframe HTML/JS/CSS, mints short-lived iframe tokens, and invokes HTTP or hosted handlers when events fire |

End-to-end path:

install SDK
    → samvaad-app init
    → edit assets / server / manifest
    → samvaad-app serve          (iframe at :10001; add ?dev=true on a ticket URL)
    → http://127.0.0.1:10001/simulator   (serverless event UI)
    → samvaad-app validate
    → samvaad-app pack           (writes a .zip)
    → admin uploads the zip under Admin → Custom apps
    → iframe appears in Apps; serverless runs on events

You never deploy your own Node process into the helpdesk for an iframe app. The helpdesk stores assets/ with the installation and serves them inside the iframe.

For serverless HTTP mode, the helpdesk calls your HTTPS URL. For serverless hosted mode, you ship server/server.js and the worker runs it.

The validator in this package is the same family of checks the API applies on upload. If samvaad-app validate prints OK, an upload will not fail for manifest or file-shape reasons (plan and permission checks still apply on the tenant).


Roles and who does what

| Person | Needs | Does not need | | --- | --- | --- | | App developer | Node.js 18+, this SDK, a packed output file to hand over | Helpdesk database access, Admin permission (unless they also install) | | Administrator | Plan feature custom_apps, Custom apps module permission | Node.js, the CLI, the app source folder | | Agent | Ability to open the ticket or conversation | Custom apps permission |

Developers and admins often work in different organisations (an internal engineering team building an app for support, or a partner building an app for a customer). The hand-off artefact is only the .samvaad-app.json file plus, for HTTP mode, the webhook secret the admin copies once at first install.


Prerequisites

  • Node.js 18 or newer (node -v). npm ships with Node.
  • A Samvaad tenant whose subscription includes the custom_apps feature (Pro, Enterprise, or Trial).
  • An administrator who can open Admin → Custom apps (Custom apps module on their role).
  • For live iframe testing: an agent login in that tenant, and a browser that can reach http://127.0.0.1:<port>/. The CLI binds loopback only.
  • For HTTP serverless: a public HTTPS endpoint you control. Loopback, RFC1918, and other private addresses are rejected by the platform.

Install the SDK

You only need this package.

Option A — npm (recommended)

The SDK is published to the public npm registry. It is not tied to any helpdesk workspace, so you can install it before you have access to one:

npm install -g samvaad-app-sdk
samvaad-app --version
samvaad-app --help
samvaad-app templates

Update later with npm install -g samvaad-app-sdk@latest.

Option B — Tarball from the developer portal

If you can't reach the npm registry (for example behind a locked-down proxy), install the same package from the developer portal:

npm install -g https://developers.samvaads.com/api/v1/public/custom-apps/sdk.tgz

Or download it first and install from the file:

curl -L -o samvaad-app-sdk.tgz https://developers.samvaads.com/api/v1/public/custom-apps/sdk.tgz
npm install -g ./samvaad-app-sdk.tgz

Helpdesk admins can also get this file from Admin → Custom apps → Download SDK. It is the same package.

Install details as JSON (no login):

GET https://developers.samvaads.com/api/v1/public/custom-apps/sdk
{
  "name": "samvaad-app-sdk",
  "version": "1.0.0",
  "install": "npm install -g samvaad-app-sdk",
  "tarball": "https://developers.samvaads.com/api/v1/public/custom-apps/sdk.tgz",
  "install_from_tarball": "npm install -g https://developers.samvaads.com/api/v1/public/custom-apps/sdk.tgz",
  "docs": "https://developers.samvaads.com/docs/custom-apps",
  "commands": [
    "samvaad-app init my-app --template ticket-sidebar",
    "samvaad-app serve",
    "samvaad-app pack"
  ]
}

Option C — One-off without a global install

npx --package samvaad-app-sdk samvaad-app --help

After a global install, samvaad-app is on your PATH. If the shell says the command is not found, npm’s global bin directory is not on PATH (common on Windows). Use npm prefix -g to locate it, or call npx samvaad-app.


First app in fifteen minutes

This walkthrough produces a ticket sidebar and installs it.

samvaad-app init order-lookup --template ticket-sidebar --name "Order lookup"
cd order-lookup
samvaad-app validate
samvaad-app serve --port 10001

Leave serve running. In the agent workspace, open any ticket and add ?dev=true to the URL (or &dev=true if the URL already has a query). Reload. Expand Apps (it opens automatically). You should see Local app with the template UI.

Optional: ?dev=true&dev_port=10002 if you passed --port 10002.

  1. Stop serve with Ctrl+C. Pack and hand off:
samvaad-app pack

This writes {slug}.zip containing manifest.json, assets/, and server/ as real files.

  1. An admin opens Admin → Custom apps → Install app, uploads the .zip, and leaves Enabled on.

  2. Any agent who opens a ticket sees Order lookup in Apps. Drop ?dev=true from the URL when you are done testing locally.


Create an app

List templates, then scaffold a directory. The folder name becomes the default slug (lowercase, hyphens). The directory must be empty or not exist yet.

samvaad-app templates
samvaad-app init my-app --template ticket-sidebar
samvaad-app init ticket-hooks --template serverless-http --name "Ticket hooks"

| Flag | Meaning | | --- | --- | | --template | ticket-sidebar or serverless-http (see samvaad-app templates) | | --name | Display title written into manifest.json (name). Agents and admins see this. |

Then:

cd my-app
samvaad-app validate

validate must print a line starting with OK before you pack.

init also writes a .gitignore that ignores node_modules and packed *.samvaad-app.json files so you do not commit secrets accidentally into a bundle sitting in git. You can still keep the packed file out of band for the admin.


Project layout

A valid app is a folder with manifest.json at the root (the filename samvaad-app.json is also accepted as the manifest). Every file that will be uploaded must live under assets/ or server/. README files, tests, CI config, and .gitignore stay in the project and are ignored by validate / pack.

Frontend example (ticket-sidebar template)

my-app/
  manifest.json
  README.md                 ignored by pack
  .gitignore                ignored by pack
  assets/
    index.html              required if locations[] is non-empty
    app.js
    app.css

index.html is the iframe document. Relative src / href to other files under assets/ are rewritten when the helpdesk serves the app so they include the iframe token.

Serverless hosted example (serverless-http template)

ticket-hooks/
  manifest.json
  server/
    server.js               required when serverless.mode is "hosted"

Export one function per name in manifest.events.

Hybrid

Include both locations and events, plus assets/index.html and either server/server.js or HTTP serverless.endpoint.

hybrid-app/
  manifest.json
  assets/index.html
  assets/app.js
  server/server.js

Do not put secrets in the project. Use installation parameters (iparams) that the admin fills in after upload. Frontend JavaScript is visible to anyone who can open the iframe.


Manifest reference

manifest.json describes what the app is, where it appears, and which events it handles. frameworkVersion must be "1.0".

{
  "frameworkVersion": "1.0",
  "name": "Order lookup",
  "slug": "order-lookup",
  "version": "1.0.0",
  "description": "Shows order status next to the ticket",
  "author": "Acme Support Engineering",
  "locations": ["ticket_sidebar", "conversation_sidebar"],
  "events": ["onTicketCreate", "onTicketUpdate"],
  "serverless": {
    "mode": "http",
    "endpoint": "https://hooks.example.com/samvaad"
  },
  "iparams": [
    {
      "name": "greeting",
      "type": "text",
      "label": "Greeting shown in the sidebar",
      "default": "Hello"
    }
  ],
  "requests": [
    {
      "name": "getOrder",
      "method": "GET",
      "url": "https://api.example.com/orders/{{args.order_id}}",
      "headers": { "Authorization": "Bearer {{iparams.api_key}}" }
    }
  ],
  "uiHandlers": ["onLookupOrder"],
  "permissions": {
    "allowedHosts": ["api.example.com", "hooks.example.com"],
    "helpdesk": ["ticket.read", "ticket.note.create"]
  }
}

| Field | Required | Description | | --- | --- | --- | | frameworkVersion | Yes | Must be "1.0". Any other value is rejected. | | name | Yes | Display name, at least 2 characters. Shown in Admin and in the Apps panel heading. | | slug | Yes | Unique per tenant. 2–63 characters: must match ^[a-z0-9][a-z0-9-]{1,62}$. This is the install identity. Re-uploading the same slug replaces the app when the admin enables replace. id is accepted as an alias for slug. | | version | Yes | Your version string (for example 1.0.1). Defaults to 1.0.0 if omitted during parse, but you should always set it. Stored on the installation; bump it when you pack a new bundle. | | description | No | Shown in Admin. | | author | No | A string, or { "name": "…" }. Normalised to a string on install. | | locations | For UI apps | Where the iframe appears. Unknown values fail validation. | | events | For serverless | Handler names the worker will call. Unknown names fail validation. Duplicates are de-duplicated. | | serverless.mode | For serverless | "http" or "hosted". If you list events and omit serverless, mode defaults to "hosted". | | serverless.endpoint | If mode is http | HTTPS URL that receives signed POSTs. Alias: serverless.url. Admins can override this after install. | | iparams | No | Installation parameters. Alias: installation_parameters. | | requests | No | Named server-side HTTP templates used by client.request.invoke; may interpolate args.*, iparams.* and oauth.*. | | oauth | No | Connect to a third-party system with OAuth 2.0 instead of an API key. See OAuth connections. | | uiHandlers | No | Serverless handler names the iframe may call with client.invoke. Event handlers are also allowed. | | permissions.allowedHosts | For client.request / hosted request() | Hostnames (no scheme, no path). Compared case-insensitively. Subdomains of a listed host are allowed. | | permissions.helpdesk | For client.api, or contact values in requests | Explicit grants: ticket.read, ticket.note.create, conversation.read, contact.read (for client.api), and contact.email / contact.phone (for contact values in request templates). |

You must declare at least one of locations or events. An app with only locations is kind frontend. Only events (or serverless) is kind serverless. Both is kind hybrid.

Locations

| Value | Where it loads | Context you get | | --- | --- | --- | | ticket_sidebar | Ticket details, right rail, Apps panel | ticket, contact | | conversation_sidebar | Inbox conversation details, Apps panel | conversation, contact | | contact_sidebar | Contact details, right rail | contact | | deal_sidebar | Deal details, right rail | deal | | ticket_background | Hidden. Runs while a ticket is open | ticket, contact | | conversation_background | Hidden. Runs while a conversation is open | conversation, contact | | new_ticket_sidebar | New ticket page, side column | draft (the ticket being created) | | new_contact_sidebar | New contact page, side column | draft (the contact being created) | | new_deal_sidebar | New deal page, side column | draft (the deal being created) |

An app can list several locations. The same assets/index.html loads in each. Branch the UI on client.get('metadata') (field location), or on which records are present.

Background locations load assets/index.html in a hidden iframe with no panel, and no resizing. Use them for apps that react to what the agent does, such as logging a call when a reply is sent or warning when a VIP ticket is opened. A background app has the full SDK: it can listen to UI events, call client.request, client.invoke and client.api, show a toast with client.interface.notify, and open a slider. The page runs only while that ticket or conversation is open. At most 5 background apps load on one page.

Create-page locations (new_*) sit next to the form. The app reads what the agent has entered so far with client.get('draft'), is told about every edit through draft.changed, and can fill in fields with client.set. Nothing is saved until the agent submits the form.

For a larger tool of your own design, open a slider from any location, or link to your own site (allow-popups is set).

Kind inference

| locations | events / serverless | kind stored on install | | --- | --- | --- | | yes | no | frontend | | no | yes | serverless | | yes | yes | hybrid |


Installation parameters (iparams)

Iparams are values the administrator sets at install or in Configure. The Admin page renders manifest-defined fields, using password inputs for secure values; older apps without a schema retain a JSON fallback. Iparams are stored with the installation and sent to serverless handlers as args.iparams / the HTTP JSON field iparams. They are never injected into the iframe or returned by the iframe SDK. If the sidebar needs a non-secret label (for example a greeting), either hard-code it or have your HTTP backend return it — do not put API keys in assets/.

Each entry:

| Field | Meaning | | --- | --- | | name | Key in the JSON object the admin pastes. Use snake_case. | | type | Informational for the admin UI (text is the usual value in 1.0). | | label | Human caption. | | default | Optional default the developer suggests; the admin still confirms. |

Redaction: if name matches /secret|token|password|key|webhook/i, Admin list/get responses show •••• instead of the stored value. Saving Configure with •••• leaves the previous secret unchanged.

Example admin JSON at install:

{
  "greeting": "Hello from Acme",
  "api_key": "live_…"
}

After install, api_key is redacted in GET responses. The worker still receives the real value in serverless invocations.

If the external system supports OAuth, prefer an oauth block over an API-key iparam: the admin approves access on the provider’s own consent screen and Samvaad keeps the token fresh.


OAuth connections to external systems

When the system your app talks to supports OAuth 2.0 (most CRMs, ERPs, calendars and marketing tools do), declare an oauth block instead of asking the admin to paste an API key. Samvaad runs the authorization-code flow with PKCE, stores the tokens encrypted for that workspace, refreshes them before they expire, and puts the current access token into your request templates. Your code never handles the consent redirect or the refresh token.

{
  "iparams": [
    { "name": "client_id", "label": "HubSpot client ID" },
    { "name": "client_secret", "type": "password", "label": "HubSpot client secret" }
  ],
  "oauth": {
    "label": "HubSpot",
    "authorize_url": "https://app.hubspot.com/oauth/authorize",
    "token_url": "https://api.hubapi.com/oauth/v1/token",
    "client_id": "{{iparams.client_id}}",
    "client_secret": "{{iparams.client_secret}}",
    "scopes": ["crm.objects.contacts.read"]
  },
  "requests": [
    {
      "name": "listContacts",
      "method": "GET",
      "url": "https://api.hubapi.com/crm/v3/objects/contacts?limit=10",
      "headers": { "Authorization": "Bearer {{oauth.access_token}}" }
    }
  ],
  "permissions": { "allowedHosts": ["hubapi.com"] }
}

| Field | Required | Description | | --- | --- | --- | | label | No | Name shown to the admin (“Connect to HubSpot”). Defaults to the authorize host. | | authorize_url | Yes | Provider consent page. https only. May contain {{iparams.*}} for per-customer hosts. | | token_url | Yes | Provider token endpoint. https only, and its host must be in permissions.allowedHosts. May contain {{iparams.*}}. | | revoke_url | No | RFC 7009 revocation endpoint, called when the admin disconnects. Host must be in allowedHosts. | | client_id | Yes | A literal id, exactly {{iparams.name}} so each workspace enters its own, or {{developer.client_id}} for the client you saved in Studio (marketplace apps). | | client_secret | No | Exactly {{iparams.name}} pointing at a secure ("type": "password") iparam, or {{developer.client_secret}} together with {{developer.client_id}}. A literal secret is rejected: it would ship inside the bundle. Omit it for public clients that rely on PKCE. | | allow_admin_override | No | With {{developer.*}} credentials: true lets a workspace admin use their own OAuth client instead. Default false. | | scopes | No | Array (or space-separated string) of scopes to request. | | scope_separator | No | " " (default), "," or "+" — how scopes are joined on the consent URL. | | pkce | No | true by default (S256). Set false only for providers that reject PKCE. | | token_auth | No | "body" (default) sends the client credentials as form fields; "basic" sends them as HTTP Basic auth. | | authorize_params | No | Extra consent-URL parameters, e.g. { "access_type": "offline", "prompt": "consent" } for providers that only issue a refresh token when asked. | | token_params | No | Extra token-request parameters (e.g. audience). |

Samvaad sets client_id, redirect_uri, state, scope, response_type, the PKCE fields, grant_type, code and refresh_token itself; authorize_params / token_params cannot override them.

Per-customer provider URLs

Some providers give every customer their own host — a Shopify store, a Salesforce My Domain, a self-hosted Jira or an Okta org. Put {{iparams.name}} in the URL and let the admin enter their value under Installation parameters before connecting:

{
  "iparams": [
    { "name": "shop", "label": "Shop name (the part before .myshopify.com)" }
  ],
  "oauth": {
    "label": "Shopify",
    "authorize_url": "https://{{iparams.shop}}.myshopify.com/admin/oauth/authorize",
    "token_url": "https://{{iparams.shop}}.myshopify.com/admin/oauth/access_token",
    "client_id": "{{developer.client_id}}",
    "client_secret": "{{developer.client_secret}}",
    "scopes": ["read_orders"]
  },
  "requests": [
    {
      "name": "getOrder",
      "method": "GET",
      "url": "https://{{iparams.shop}}.myshopify.com/admin/api/2026-07/orders/{{args.id}}.json",
      "headers": { "X-Shopify-Access-Token": "{{oauth.access_token}}" }
    }
  ],
  "permissions": { "allowedHosts": ["myshopify.com"] }
}

A placeholder may sit in exactly one of three places:

| Position | Example | allowedHosts needs | Admin enters | | --- | --- | --- | --- | | First host label | https://{{iparams.shop}}.myshopify.com/… | the fixed suffix: myshopify.com | acme (or acme.myshopify.com) | | Whole host | https://{{iparams.jira_host}}/rest/oauth2/latest/token | the same placeholder: "{{iparams.jira_host}}" | jira.acme.com | | Whole path segment | https://acme.okta.com/oauth2/{{iparams.auth_server}}/v1/token | the literal host | aus1abc |

  • Placeholders can't appear in the query string, fragment or port, or in the middle of a host (auth.{{iparams.x}}.com is rejected).
  • The iparams they use must not be secure — they end up in URLs.
  • Values are checked when connecting: a host label is a single name, a whole host is a real host name (IP addresses, ports and private addresses are refused), a path segment is letters, digits, ., -, _ or ~. A pasted URL such as https://acme.myshopify.com/ is trimmed to what's needed.
  • A "{{iparams.x}}" entry in allowedHosts lets that install's request templates call the entered host too.
  • Samvaad remembers which account the tokens came from. If the admin changes the shop or instance afterwards, the connection shows Expired and nothing is sent to the new host with the old token until they connect again.

How the admin connects

  1. Register an OAuth app with the provider (skip this and step 2 when the app uses the publisher’s credentials). Use the redirect URI shown in Admin → Custom apps → Configure — https://<your helpdesk host>/auth/custom-apps/callback. It is the same for every workspace and every app, so a single provider registration works everywhere.
  2. Enter the client ID and secret under Installation parameters.
  3. Click Connect to …, approve the consent screen, and you land back on Custom apps with the connection marked Connected.

Reconnect runs the flow again (for example after changing scopes); Disconnect deletes the stored tokens and calls revoke_url if you declared one. The Custom apps list shows each app’s connection status. If the provider rejects a refresh (invalid_grant, revoked access, a changed secret), the connection is marked Expired and the admin reconnects.

Using the token

  • Request templates: {{oauth.access_token}} and {{oauth.token_type}} can appear in the URL, headers or body of any requests[] entry. The token is refreshed first if it expires within a minute. If the provider answers 401, Samvaad refreshes once and retries.
  • Iframe: call the template with client.request.invoke('listContacts'). The token never reaches the iframe.
  • Hosted JavaScript: request({ template: 'listContacts' }). args.oauth is { connected: true } or { connected: false, error }, so a handler can skip work when nothing is connected. Freeform request() still cannot send Authorization headers — use a template.
  • HTTP mode: the signed body gains "oauth": { "connected": true, "access_token": "…", "token_type": "Bearer" } (refreshed before each call), or { "connected": false, "error": "…" }. Treat the token like any other secret on your side; do not store it — you get a fresh one on every call.

A template that uses {{oauth.*}} while the app is not connected fails with HTTP 424 and a message telling the agent that an admin must connect it.

There is one connection per install, shared by everyone in the workspace. With {{iparams.*}} credentials each workspace uses the client its own admin enters; with {{developer.*}} every install uses yours (see below). If your provider supports public clients, you can also put a literal client_id in the manifest and omit client_secret; PKCE protects the exchange.

Marketplace apps: one OAuth client for every workspace

For a listing other workspaces install, you usually don't want every admin registering their own OAuth app with the provider. Register one OAuth app yourself and let every install use it:

  1. Register an OAuth app with the provider, using the redirect URI https://<your helpdesk host>/auth/custom-apps/callback (the same for every workspace, and shown in Studio).
  2. In the developer portal, open Studio → your app → External OAuth and save its client ID and secret. The secret is stored encrypted and never shown again.
  3. In the manifest, reference those credentials instead of iparams:
"oauth": {
  "label": "HubSpot",
  "authorize_url": "https://app.hubspot.com/oauth/authorize",
  "token_url": "https://api.hubapi.com/oauth/v1/token",
  "client_id": "{{developer.client_id}}",
  "client_secret": "{{developer.client_secret}}",
  "scopes": ["crm.objects.contacts.read"],
  "allow_admin_override": true
}

Workspaces that install the listing just click Connect — no credentials to fill in.

  • Availability follows the listing. While a public app is unpublished or in review, only your own workspace can connect with these credentials, so you can test the flow end to end. Once it is published, every installing workspace can. If the listing is suspended, nobody can connect or refresh.
  • Submitting a version that uses {{developer.client_id}} is refused until the credentials are saved in Studio.
  • Rotating the secret in Studio takes effect on the next token refresh. Changing the client ID means workspaces that already connected must connect again. Removing the credentials stops connecting and refreshing for every install that relies on them.
  • allow_admin_override: true (optional, off by default) lets a workspace admin use their own OAuth client instead — useful for providers where each customer must register their own app, such as sandboxes or self-hosted instances. The admin enters it under Use your own OAuth client in Configure; it is stored encrypted for that workspace only and keeps working even if your listing's credentials change. Switching either way disconnects the current connection.
  • {{developer.*}} only works for installs from the App marketplace. A bundle uploaded directly under Admin → Custom apps must use {{iparams.*}} (or allow_admin_override, so the admin can enter a client).

Frontend (iframe) apps

The helpdesk loads assets/index.html in an iframe with:

sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"

allow-same-origin is not set. Consequences:

  • The document cannot read agent cookies or localStorage of the parent origin.
  • The document cannot inspect the parent DOM.
  • Same-origin APIs inside the iframe are limited; treat the iframe as a unique opaque origin.
  • All helpdesk data arrives through postMessage via SamvaadClient.

When the helpdesk serves HTML, it injects the iframe SDK (samvaad-apps.js) so you do not host it. Local samvaad-app serve injects the same script into HTML that does not already reference samvaad-apps.js or SamvaadClient.

Relative URLs in index.html (app.js, app.css, images) must stay under assets/. Other HTML pages in assets/ (for example assets/slider.html, loaded by client.interface.openSlider) are served the same way, and get the SDK injected too. The host rewrites them so they load as:

/api/v1/user/app/custom-apps/{id}/file?path=assets/app.js&token=…

server/ files are never served to the iframe (HTTP 403).

The iframe token is a short-lived JWT (about 15 minutes) minted when the agent opens the Apps panel. It is scoped to tenant, app, user, and location. Frame and file routes accept ?token= without a session cookie so the unique-origin iframe can load assets.

Keep the UI compact. Call client.invoke('resize', { height }) after render. The host clamps height to roughly 80–640px.

Minimal assets/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Order lookup</title>
    <link rel="stylesheet" href="app.css" />
  </head>
  <body>
    <h1 id="title">Loading…</h1>
    <script src="app.js"></script>
  </body>
</html>

You do not add a <script src="samvaad-apps.js"> tag for production installs. The platform injects it. For local serve, injection happens automatically.

What the iframe cannot do

  • Call the helpdesk REST API with the agent’s cookie.
  • Read other apps’ iframes.
  • Run after the agent navigates away. The iframe is destroyed, including background and slider iframes.
  • Persist data in the parent origin. Use your own backend, or serverless events, for persistence.

Ticket and conversation context

The host sends a slim snapshot, not the full database document. Fields that are copied when present:

| Field | Ticket | Conversation | Contact | Deal | Current user | | --- | --- | --- | --- | --- | --- | | _id / id | yes | yes | yes | yes | _id | | display_id | yes | — | — | — | — | | subject / title / name | yes | title | — | name | name | | status | yes | yes | yes | — | — | | priority | yes | yes | — | — | — | | source | yes | yes | yes | — | — | | stage | — | — | yes | yes | — | | amount / currency | — | — | — | yes | — | | contact_id / account_id | yes | yes | — | yes | — | | assigned_agent_id / assigned_group_id / group_id | yes | yes | — | — | — | | created_at / updated_at / createdAt / updatedAt | yes | yes | yes | yes | — | | email | — | — | — | — | yes |

Treat any extra field as optional. Only the records that belong to the location are set: ticket on ticket locations, conversation in the inbox, contact on the contact page, deal on the deal page. The others are null.

metadata holds location, locations (every location the app declared), events, surface (frame, or slider inside a slider page) and background (true for background locations).

On create-page locations, draft is the form as the agent has filled it in so far:

| Location | draft fields | | --- | --- | | new_ticket_sidebar | subject, description, status, priority, source, issue_type, agent_id, group_id, tags[], form_id, custom_fields (by field name), contact (_id, name, email of the chosen requester) | | new_contact_sidebar | mobile, work_phone, email, firstname, lastname, gender, birthday, status, stage, source, owner_agent, language, country, timezone, company, src_user_name, contact_groups[], contact_lists[], tags[], waba_subscription, marketing_subscription, sms_subscription, custom_fields (by field name) | | new_deal_sidebar | name, stage, amount, currency, probability, expected_close_date, contact_id, account_id, ticket_id, conversation_id, description, lost_reason |

When the record changes, the host posts a new context and then fires the matching event, such as ticket.updated. Re-read with client.get inside your handler rather than caching values.


SamvaadClient API

Always start with:

const client = await SamvaadClient.init();

init() posts samvaad:ready to the parent. The parent then posts context. init() resolves with a client once that first context has arrived, so client.get('ticket') right after await SamvaadClient.init() returns data. If no host answers within 2 seconds, for example when the page is opened directly in a browser tab, it resolves anyway and get returns null. app.registered fires every time the host refreshes the context.

client.get(path)

| Path | Returns | | --- | --- | | 'ticket' | Slim ticket object, or null. | | 'conversation' | Slim conversation, or null. | | 'contact' | Slim contact when the host has one. | | 'deal' | Slim deal on deal_sidebar, or null. | | 'draft' | The form being filled in on new_* locations, or null. | | 'slider' | Inside a slider page: { payload, size, title }, where payload is what the opener passed. null elsewhere. | | 'currentUser' | Logged-in agent: _id, email, name. | | 'metadata' / 'app' | { location, locations, events, surface, background }. |

const client = await SamvaadClient.init();
const ticket = await client.get('ticket');
const conversation = await client.get('conversation');
const user = await client.get('currentUser');
const meta = await client.get('metadata');

if (ticket && ticket._id) {
  // ticket sidebar
} else if (conversation && (conversation._id || conversation.id)) {
  // conversation sidebar
}

Unknown paths go through samvaad:call get and return whatever the host puts in context for that key.

client.on(event, handler)

UI events describe what the agent just did in the helpdesk. They are sent to every sidebar, background and slider frame that has context for that record. client.on returns a function that unsubscribes. client.off(event, handler) does the same, and client.off(event) removes every handler for that event.

| Event | Fires when | Payload | | --- | --- | --- | | app.registered | Context was posted (first load and every refresh) | The full context | | ticket.opened | The app loaded on a ticket (ticket_sidebar, ticket_background) | { ticket } | | ticket.updated | Any ticket field in the context changed, for example after the agent saves | { ticket, changes: [field names] } | | ticket.status_changed | Ticket status changed | { ticket, previous, status } | | ticket.assignee_changed | Assigned agent or group changed | { ticket, previous: { assigned_agent_id, assigned_group_id }, assigned_agent_id, assigned_group_id } | | ticket.reply_sent | The agent sent a public reply | { ticket_id, message, is_private: false, attachments } | | ticket.note_added | The agent added a note | { ticket_id, message, is_private } | | ticket.forwarded | The agent forwarded the ticket to other agents | { ticket_id, message, is_private: true } | | conversation.opened | The app loaded on a conversation | { conversation } | | conversation.updated | Conversation fields changed | { conversation, changes } | | conversation.status_changed / conversation.assignee_changed | Same shapes as the ticket events | | | conversation.message_sent | The agent sent a message in the conversation | { conversation_id, message, type } | | conversation.note_added | The agent added a note to the conversation | { conversation_id, message } | | contact.opened / contact.updated | On contact_sidebar | { contact } / { contact, changes } | | deal.opened / deal.updated / deal.stage_changed | On deal_sidebar, after the deal is saved | { deal } / { deal, changes } / { deal, previous, stage } | | draft.changed | The agent edited the form on a new_* page | { draft } | | ticket.created / contact.created / deal.created | The agent submitted the form on a new_* page | { ticket_id \| contact_id \| deal_id, draft } |

*.created fires just before the helpdesk moves to the new record, so the new_* iframe is destroyed right after it. Do quick work only, such as a client.invoke. For follow-up work, use a serverless handler such as onTicketCreate, or a background location on the record page.

UI events are separate from serverless events. They run in the agent's browser, only while the page is open, and are not retried.

client.on('ticket.updated', async () => {
  const ticket = await client.get('ticket');
  render(ticket);
});

client.on('app.registered', (ctx) => {
  console.log('location', ctx && ctx.metadata && ctx.metadata.location);
});

// Background app: react to replies on the open ticket.
const stop = client.on('ticket.reply_sent', async ({ ticket_id, message }) => {
  await client.invoke('onReplyLogged', { ticket_id, length: message.length });
});
// later: stop();

client.invoke('resize', { height })

Ask the parent to set iframe height in pixels. Values below ~80 are ignored; values above ~640 are clamped. Background and slider frames ignore resize: a background frame is hidden, and a slider fills the drawer.

client.invoke('resize', { height: document.body.scrollHeight + 8 });

Other invoke names call an installed app’s hosted or HTTP serverless handler. Declare the name in uiHandlers (or events); the platform adds the current ticket, conversation, and contact context.

const result = await client.invoke('onLookupOrder', { order_id: '123' });

client.request({ url, method, headers, body })

Ask the helpdesk to call an external HTTP URL on the app’s behalf. The browser does not make the request from the iframe origin (avoids CORS and keeps secrets off the client if you only use serverless — but client.request still runs in the agent’s session as a proxy, so do not put secrets in frontend JS).

Rules:

  • url hostname must be listed in manifest.permissions.allowedHosts.
  • Private, loopback, and link-local addresses are blocked (SSRF protection).
  • Timeout is 8 seconds.
  • Redirects: at most 2.
  • Local samvaad-app serve does not implement a real proxy; request from a Local app iframe is not forwarded. Test client.request against an installed app.
const { status, body } = await client.request({
  url: 'https://api.example.com/orders/123',
  method: 'GET',
  headers: { Accept: 'application/json' },
});

Prefer serverless HTTP or hosted handlers for anything privileged (API keys, writes to your systems).

client.request.invoke(name, args)

Call a named manifest requests template. Template interpolation runs only on the server, so secure iparams can be placed in URLs, headers, or bodies without entering iframe memory.

const { status, body } = await client.request.invoke('getOrder', {
  order_id: '123',
});

Use {{args.order_id}} for caller values and {{iparams.api_key}} for admin-configured secrets. The template URL must still match permissions.allowedHosts. Freeform client.request rejects credential headers such as Authorization and X-API-Key; use a named template for authenticated calls.

Contact email and phone in request templates

Workspaces mask a contact's email and phone from agents until they choose to unmask them, and every unmask is audited. Apps follow the same rule: they never receive these values in the iframe. Instead, a request template can name them and the server fills them in when it makes the outbound request:

{
  "permissions": {
    "allowedHosts": ["api.stripe.com"],
    "helpdesk": ["contact.email"]
  },
  "requests": [
    {
      "name": "findCustomer",
      "method": "GET",
      "url": "https://api.stripe.com/v1/customers/search?query=email%3A%22{{contact.email}}%22",
      "headers": { "Authorization": "Bearer {{iparams.secret_key}}" }
    }
  ]
}
// No email is passed, or visible: the server uses the contact of the open ticket, chat or contact page.
const { body } = await client.request.invoke('findCustomer');
  • {{contact.email}} and {{contact.phone}} (the mobile number, else the work phone) are the only contact values available.
  • The manifest must list contact.email and/or contact.phone under permissions.helpdesk, or samvaad-app validate fails and the request is refused at run time.
  • The values come from the record the agent has open (ticket, conversation or contact page), and only if the agent can open it. Create pages (new_*) have no contact yet.
  • In a URL the value is percent-encoded for you. In headers and bodies it is inserted as is. It cannot be used in the host part of the URL.
  • Every request that uses one is written to the workspace's unmask audit trail (source custom app), with the app, the agent and the record, even if the workspace has turned off unmask auditing. If the audit entry cannot be written, the request is not sent.
  • If the contact has no email or phone, the request fails with a clear message. Have your UI fall back to asking the agent.
  • Hosted event handlers (onTicketCreate and the rest) cannot use these values; they are available to sidebar requests only.
  • Whatever the third-party service returns is yours to show or not. Don't display more contact detail than the agent needs.

client.api(resource, action, params)

Call a small, current-agent-scoped helpdesk API. Every action must be declared in permissions.helpdesk, and reads/writes are pinned to the ticket, conversation or contact currently open in the host. On contact_sidebar, contact.read returns the open contact.

const ticket = await client.api('ticket', 'read', {});
await client.api({
  resource: 'ticket',
  action: 'note.create',
  params: { message: 'Checked by the order app' },
});

Supported client.api grants are ticket.read, ticket.note.create, conversation.read, and contact.read. (contact.email and contact.phone are separate: they apply to request templates, not client.api.)

client.metadata()

Same as client.get('metadata').

client.set(path, value)

Only on new_ticket_sidebar, new_contact_sidebar and new_deal_sidebar. Fills in a field on the form the agent is completing. The agent sees the change right away and can still edit it. Nothing is saved until they submit. Resolves with { ok: true }, or rejects with a message such as status must be one of: ….

const client = await SamvaadClient.init();
client.on('draft.changed', async ({ draft }) => {
  if (draft.subject && /refund/i.test(draft.subject) && draft.priority !== 'high') {
    await client.set('priority', 'high');
  }
});
await client.set('custom_fields.order_id', '10045');
await client.set('tags', ['refund', 'web']);

| Location | Paths you can set | | --- | --- | | new_ticket_sidebar | subject, description (plain text), status, priority, source, issue_type, agent_id, group_id, tags (array or comma string), custom_fields.<name> for fields on the selected form | | new_contact_sidebar | mobile, work_phone, email, firstname, lastname, gender, birthday, status, stage, source, owner_agent, language, country, timezone, company, src_user_name, contact_groups, contact_lists, tags, waba_subscription, marketing_subscription, sms_subscription, custom_fields.<name> | | new_deal_sidebar | name, stage (also resets probability to the stage default), amount, currency, probability (0–100), expected_close_date (ISO date), description, lost_reason |

A value must be valid in that workspace: status, priority, stage and source must be one of its configured options, and agent_id, group_id and owner_agent must exist. Paths have no draft. prefix: write client.set('subject', …).

client.interface.openSlider(options)

Opens a slide-over drawer from the right edge of the helpdesk, showing another page of your app that you design yourself. You can call it from any location, including background apps.

const closed = await client.interface.openSlider({
  page: 'order.html',     // assets/order.html in your bundle
  title: 'Order #10045',
  size: 'large',          // small | medium | large | xl
  payload: { order_id: '10045' },
});
// closed = { closed: true, result } once the slider closes
if (closed.result) render(closed.result);

| Option | Required | Description | | --- | --- | --- | | page | Yes | An .html file inside assets/, e.g. slider.html or views/order.html. | | title | No | Drawer heading. Defaults to the app name. | | size | No | small (600px), medium (800px, default), large (900px), xl (1200px). These are the widths the helpdesk uses for its own sliders. On narrow screens the drawer is full width. | | payload | No | Any JSON value. The slider page reads it with client.get('slider'). |

The slider page is a normal app page. The same SDK is injected, it gets the same context as the frame that opened it, and it can call client.request, client.request.invoke, client.invoke, client.api and client.interface.notify, and receive UI events. Inside a slider, client.get('metadata').surface is 'slider'.

The promise resolves when the slider closes:

  • The slider page calls client.interface.closeSlider(result): you get { closed: true, result }.
  • The agent closes the drawer (×, Esc or the backdrop): you get { closed: true, result: null }.

Only one slider can be open at a time, and a slider cannot open another one. Both calls reject with an error message instead.

client.interface.closeSlider(result)

Call from inside the slider page to close it. result (any JSON value, default null) becomes the result of the opener's openSlider() promise.

// assets/order.html → order.js
const client = await SamvaadClient.init();
const { payload } = await client.get('slider');
document.getElementById('save').onclick = async () => {
  const { body } = await client.request.invoke('updateOrder', { id: payload.order_id });
  client.interface.closeSlider({ updated: true, order: body });
};

client.interface.notify(options)

Shows a toast in the helpdesk, labelled with your app's name, for about 5 seconds.

client.interface.notify({ type: 'success', message: 'Order linked to ticket' });
client.interface.notify('error', 'Could not reach the order system');

type is success, info (default), warning or error. message is plain text, up to 300 characters.


Host–app message protocol

You normally use SamvaadClient only. This section is for debugging and for authors who inspect postMessage in DevTools.

Messages from the app to the host set source: 'samvaad-app'.

| type | Meaning | | --- | --- | | samvaad:ready | App finished loading; host should send context. | | samvaad:resize | { payload: { height } } | | samvaad:call | { id, method, payload } — method is get, set, request, invoke, api, interface.openSlider, interface.closeSlider or interface.notify. |

Messages from the host to the app set source: 'samvaad-host'.

| type | Meaning | | --- | --- | | samvaad:context | { payload: { ticket, conversation, contact, deal, draft, slider, currentUser, metadata } } | | samvaad:event | { event, payload }, for example ticket.reply_sent | | samvaad:response | { id, payload, error } — reply to samvaad:call |

The iframe SDK posts to * (unique origin). The host matches event.source to the iframe contentWindow so other frames cannot spoof calls.


Sidebar UI guidelines

  • Default height is about 180px until the first resize.
  • Prefer a short heading, a definition list or compact table, and one primary link or button.
  • Do not ship large SPA bundles; the Apps panel is a rail, not a full workspace.
  • Match helpdesk density: small type, clear labels, no horizontal scroll.
  • Handle missing context: show “No ticket selected” rather than throwing.
  • Re-render on ticket.updated / conversation.updated.
  • External tools: use target="_blank" links (popups are allowed) rather than trying to navigate the parent.
  • Anything that needs room, such as a form, a table or a record view, goes in a slider. Pick the smallest size that fits: small for a short form, medium for most views, large or xl only for wide tables.
  • Slider pages fill the drawer's full height. The drawer already shows your title, so skip a page heading and put actions at the bottom.
  • Background apps have no UI of their own. Use notify sparingly, only for things the agent must act on, and never on every event.

Serverless apps

Samvaad maps helpdesk events to handler names in manifest.events. Only enabled apps that list that handler are invoked.

Each invocation is stored as a run (Invocations in Admin): ok, error, or skipped, with duration_ms, optional error text, and a small result object.

The payload passed to your handler is:

{
  tenant_id,      // tenant that owns the event
  event_id,       // platform event id
  event_type,     // e.g. ticket.created
  actor,          // user or system that caused the event, when present
  // plus fields from the domain-event payload, commonly:
  ticket_id,
  conversation_id,
  message_id,
  status,
  priority,
  entity_id,
}

Treat extra fields as optional. Large payloads are clipped at 256 KB (you may receive { truncated: true, preview }).


Event catalogue

| Platform event.type | Handler you list in events | Typical trigger | | --- | --- | --- | | ticket.created | onTicketCreate | New ticket | | ticket.updated | onTicketUpdate | Ticket fields saved | | ticket.replied | onTicketReply | Public reply | | ticket.note.added | onTicketNoteAdd | Internal note | | ticket.closed | onTicketClose | Ticket closed | | ticket.resolved | onTicketResolve | Ticket resolved | | ticket.assigned | onTicketAssign | Assignee set | | conversation.message.received | onMessageReceived | Inbound chat/channel message | | conversation.message.sent | onMessageSent | Outbound message | | conversation.updated | onConversationUpdate | Conversation record changed |

Also accepted in the manifest:

| Handler | Behaviour in 1.0 | | --- | --- | | onConversationCreate | Invoked when a domain event is not in the table above and payload.conversation_is_new is set. List it if you need “new conversation” specifically. | | onExternalWebhook | Inbound POST to this tenant’s app URL {API}/custom-apps/webhook/{tenantId}/{appId}. HMAC or Bearer with the install secret. | | onAppInstall | Fires once, right after a fresh install of your app into a tenant (not on a version replace/upgrade). Payload is { app_id, slug, name, version, kind } — no ticket/conversation fields. Use it for one-time setup, e.g. registering with an external system. |

If you list a handler that never fires, Invocations stay empty for that name. Use Send test event in Admin to invoke the first listed handler with a synthetic payload { test: true, actor }.


HTTP mode in depth

Set:

"serverless": {
  "mode": "http",
  "endpoint": "https://hooks.example.com/samvaad"
}

The admin may override the URL at install or in Configure. The endpoint must be public HTTPS.

Request

POST Content-Type: application/json

| Header | Meaning | | --- | --- | | X-Samvaad-Event | Handler name, e.g. onTicketCreate | | X-Samvaad-App-Id | Installation id | | X-Samvaad-Timestamp | Unix milliseconds as a string | | X-Samvaad-Signature | sha256= + hex HMAC-SHA256 of "{timestamp}.{rawBody}" using the webhook secret |

Webhook secret: generated on first install of that slug, shown once in the create response / Admin UI. Copy it into your secrets store. Replacing a version keeps the same secret so your receiver does not break. Admin GET later redacts it.

Body:

{
  "event": "onTicketCreate",
  "app_id": "64f1…",
  "slug": "order-lookup",
  "iparams": { "greeting": "Hello" },
  "oauth": { "connected": true, "access_token": "…", "token_type": "Bearer" },
  "data": {
    "tenant_id": "…",
    "event_id": "…",
    "event_type": "ticket.created",
    "actor": "…",
    "ticket_id": "…"
  }
}

Verify every request

  1. Read X-Samvaad-Timestamp and reject if older than five minutes (or from the future by the same skew).
  2. Compute HMAC-SHA256 over timestamp + '.' + rawBody (the exact bytes, not re-serialised JSON).
  3. Prefix with sha256= and compare to X-Samvaad-Signature with a constant-time compare.
  4. Respond 2xx. Non-2xx is recorded as an error run. Timeout is 8 seconds.

Node.js example:

const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/samvaad', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.SAMVAAD_WEBHOOK_SECRET;
  const timestamp = String(req.get('x-samvaad-timestamp') || '');
  const header = String(req.get('x-samvaad-signature') || '');
  const rawBody = req.body; // Buffer
  const age = Math.abs(Date.now() - Number(timestamp));
  if (!Number.isFinite(age) || age > 5 * 60 * 1000) {
    return res.status(401).send('stale');
  }
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }
  const payload = JSON.parse(rawBody.toString('utf8'));
  // enqueue payload.event / payload.data — do not block past a few hundred ms
  res.status(202).json({ ok: true });
});

Use express.raw (or equivalent) so the HMAC covers the exact body. Parsing JSON first and JSON.stringify later will fail verification.

Idempotency: retries can happen if your endpoint is slow. Key off data.event_id when present.


Hosted JavaScript in depth

Set "serverless": { "mode": "hosted" } (or omit serverless when events is set) and ship server/server.js.

exports.onTicketCreate = async function onTicketCreate(args) {
  // args.event  — handler name
  // args.iparams — admin-configured object
  // args.data — domain payload (ticket_id, …)
  const url = args.iparams && args.iparams.notify_url;
  if (url) {
    await request({
      url,
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: { ticket_id: args.data && args.data.ticket_id },
    });
  }
  // Prefer a named manifest template so secrets stay in iparams:
  // await request({ template: 'getOrder', args: { order_id: '123' } });
  return { ok: true };
};

You may assign module.exports = { onTicketCreate, onTicketUpdate }. The runner also looks for onTicketCreateCallback and a generic handler.

Sandbox:

| Available | Not available | | --- | --- | | exports, module.exports | require | | console.log / warn / error (worker logs, prefix [app:slug]) | process, Buffer, global | | request({ url, method, headers, body }) or request({ template, args }) → { status, data } | fs, net, child processes | | setTimeout / clearTimeout | Arbitrary npm packages |

Rules for request():

  • Hostname must be in permissions.allowedHosts.
  • Same SSRF blocks as HTTP mode.
  • 8 second timeout.

Run time for loading the script and calling the function is about 3 seconds (vm.Script timeout). If you need more time, use HTTP mode and enqueue work on your side.

Return values are stored on the run record. Keep them small. Thrown errors become status: error with error set to the message.

Only upload hosted JavaScript that your organisation reviewed. This is a private-tenant feature, not a multi-tenant public marketplace sandbox.

Locally, samvaad-app test-event and POST /web/event run the same export style, but request() throws (stub). Test real outbound HTTP after install.


Local development and testing

Validate

cd my-app
samvaad-app validate

Example success:

OK  Order lookup ([email protected]) kind=frontend files=3

This is the same family of checks as upload: unknown locations/events, missing assets/index.html or server/server.js, path traversal, file count and size.

Serve a frontend locally

samvaad-app serve --port 10001
  • Listens on 127.0.0.1 only (not 0.0.0.0).
  • GET / serves assets/index.html with SDK injection.
  • GET /simulator is the event simulator UI for hosted server/server.js.
  • POST /web/event is the JSON API the simulator uses.

In the agent workspace, open a ticket or conversation and add ?dev=true to the address bar, then reload. Apps loads http://127.0.0.1:10001/ as Local app. This is only that browser tab.

| Query | Meaning | | --- | --- | | ?dev=true | Local iframe at http://127.0.0.1:10001/ | | ?dev=true&dev_port=10002 | Same, different port | | ?dev=http://127.0.0.1:10001/ | Explicit loopback URL (localhost / 127.0.0.1 only) |

localStorage.samvaad_app_dev_url still works as a fallback if you prefer not to change the URL.

Event simulator (serverless)

With serve running, open:

http://127.0.0.1:10001/simulator

Pick a handler (onTicketCreate, …), edit the JSON payload (this becomes args.data), optionally edit args.iparams, and click Send event. The page shows the return value, duration, and console lines from server/server.js. This does not call the helpdesk; it runs the same export names the worker will call after install.

CLI equivalent:

samvaad-app test-event . onTicketCreate --payload '{"ticket_id":"t1","status":"open"}'

Pack a bundle

samvaad-app pack

Writes {slug}.zip in the current directory. The zip is a normal folder layout:

manifest.json
assets/index.html
assets/app.js
assets/app.css
server/server.js          (if present)

That is what an administrator uploads under Admin → Custom apps. README, tests, and .gitignore are not included.

samvaad-app pack -o order-lookup.zip
samvaad-app pack --json -o order-lookup.samvaad-app.json

--json writes the older inlined JSON bundle. Prefer zip so HTML, JS, and CSS stay as files.

Give only the zip to the admin. They do not need the SDK to install it.


Upload and run on the helpdesk

  1. Sign in as an admin with Custom apps permission, on a plan that includes custom_apps.
  2. Open Admin → Custom apps.
  3. Install app.
  4. Upload packed zip — choose {slug}.zip (manifest.json + assets/ + optional server/). JSON bundles still work.
  5. Optionally set installation parameters (JSON object keyed by iparams[].name) and a serverless HTTP endpoint.
  6. Leave Enabled on unless you want a dry install.
  7. If this slug already exists, check Replace if this slug is already installed.
  8. Install. For a brand-new HTTP app, copy the webhook secret immediately.

Then:

  • Frontend: open a ticket or conversation. Expand Apps in the right rail.
  • Serverless: create a ticket or send a message. Open Invocations (eye icon) on the app row. Use the bolt icon to send a test event.

The worker must be running for serverless. If invocations stay empty, the API is up but the domain-events worker is not.

Starter Use template buttons on the admin page install sample apps for demos. Production apps should come from a packed file so you have a versioned, validated bundle.


Update an installed app

  1. Change code and bump manifest.version.
  2. samvaad-app validate then samvaad-app pack -o my-app.samvaad-app.json.
  3. Either:
    • Install app again with the same slug and Replace if this slug is already installed, or
    • Configure (pencil) on the row → Upload new version → Save.

Replace keeps the webhook secret and, unless you send new iparams, existing parameter values. Configure merges iparams; a value of •••• is ignored so secrets are not overwritten by the redacted placeholder.

Enable / disable from the