KViews

Render JSX inside the Kustomer timeline and insights panel, embed hosted UI with DynamicCard and the Cards SDK, and call external APIs safely with commands.

A KView (Klass View) is a JSX template that Kustomer renders inside the agent desktop. Kustomer hosts and evaluates the template, so a KView needs no server: it reads the record being viewed, formats it with Kustomer's own components, and can call the Kustomer API or your app's commands when the agent clicks something. When you need more than a template can do, a KView can embed a page you host and talk to it through the Cards SDK.

Define a KView

kviews (excerpt)
{
  "kviews": [
    {
      "name": "kustomer.app.acme_orders.recent-orders",
      "resource": "customer",
      "context": "smartbar-card",
      "meta": { "displayName": "Acme orders", "icon": "cart", "state": "open" },
      "template": "<Segment>\n  <BasicRow label=\"Lifetime value\" value={_.get(customer, 'custom.@acmeordLifetimeValueNum')} />\n  <BasicRow label=\"Tier\" value={_.get(customer, 'custom.@acmeordLoyaltyTierStr', 'none')} />\n</Segment>"
    }
  ]
}
KView
namestringrequired
resourcestringrequired
contextstringrequired
templatestringrequired
meta.displayNamestring
meta.iconstring
meta.statestring

Where KViews appear

contextRendersAllowed resource
smartbar-cardA card in the insights panel on the right of the timelineAny
smartbar-detailsThe details panel at the top of the insights panelOnly a Klass your app defines
expanded-timelineThe body of a KObject when an agent expands it in the timelineOnly a Klass your app defines
Where each KView context renders.

Write the template

A template is JSX evaluated in the browser. It can start with JavaScript (constants, helper functions, even React components) and must end with a single JSX expression:

kviews/order-timeline.jsx
const money = (n) => (n == null ? '–' : `$${Number(n).toFixed(2)}`)

const statusColor = {
  shipped: 'var(--text-primary)',
  delayed: 'var(--alert-text)',
  refunded: 'var(--text-secondary)',
}

;<Segment>
  <Grid>
    <Column size="eight">
      <BasicField label="Order" value={object.custom.orderNumberStr} />
      <BasicField label="Placed" value={moment(object.custom.placedAt).format('LL')} />
    </Column>
    <Column size="eight">
      <BasicField label="Total" value={money(object.custom.totalNum)} />
      <div style={{ color: statusColor[object.custom.statusStr] }}>{_.capitalize(object.custom.statusStr)}</div>
    </Column>
  </Grid>
  {object.custom.trackingUrl && (
    <Button size="small" link={object.custom.trackingUrl}>
      Track package
    </Button>
  )}
</Segment>

Remember JSX isn't HTML: use className, style={{ … }} objects, and close every tag. To try a template before you save it, use the KView playground.

What's in scope

NameContains
objectThe record the view renders. For a KObject: id, custom, data, title, description, externalId and so on.
customerThe customer, flattened: id plus its attributes (customer.name, customer.emails, customer.custom). Relationships such as company become plain IDs.
conversationThe conversation's attributes, when one is open
companyThe company's attributes, when there is one
currentUserThe signed-in agent: id, their attributes, and teams
appSettingsYour app's settings for this org. Secret settings aren't readable here.
contextThe context object passed to embedded cards
typeThe record type, such as customer or kobject.order
dataAll of the above as one object, for helpers such as renderHandlebars
_lodash
momentMoment.js for dates
ReactReact, for components with state

Attributes can be missing, so read them with _.get(customer, 'custom.loyaltyTierStr', 'none') rather than chaining property access.

Components

Kustomer provides components that match its UI and respect dark mode and data masking. KView components previews each one and lists every prop.

GroupComponentsUse
LayoutContainer (<Container usePadding> adds standard padding), Segment, Grid, Column (size="one" to "sixteen", adding up to sixteen)Structure and padding
Timeline fieldsBasicField (label and value), Field (label from metadata, typed formatting), Imagesexpanded-timeline views and cards
Detail rowsBasicRow, Row (metadata-aware, inline editable), RowAssignmentUser, RowAssignmentTeam, RowPriority, CreateFormRowsmartbar-details, cards and create forms
Relationship cardsCustomer, Company, Conversation, Kobject, and CustomerField, CompanyField, ConversationField, KobjectField with a labelLink to related records
Buttons and pillsButton, ButtonSecondary, ButtonDanger, ButtonText, ButtonGroup (size: xSmall or small; onClick or link), Pill (type and message)Actions and status
DataWithUser (render prop with a user by ID)Show an assignee's name
EmbeddingCard, DynamicCardYour hosted UI, see below

CollapsedRows, from older generated layouts, is deprecated: it renders its children and no longer collapses them.

Row writes back to the record when the agent edits it inline:

JSX
<Row field="statusStr" value={object.custom.statusStr} object="kobject" editable componentAttributes={{ fullWidth: true }} />

Call the Kustomer API

KustomerRequest calls the API as the signed-in agent, with their permissions. Return a string from the optional callback to show it as a toast:

JSX
<Button
  size="small"
  onClick={() =>
    KustomerRequest(
      { url: `/v1/conversations/${conversation.id}`, method: 'PATCH', body: { tags: ['acme-refund'] } },
      (err) => (err ? 'Could not tag the conversation' : 'Tagged for refund'),
    )
  }
>
  Mark for refund
</Button>

Handle masked data

If the org uses data masking and the agent can't see a field, the template receives { isMasked: true, maskedPresentation: "••••@example.com" } instead of the value. Kustomer's field components handle this for you. In your own markup, check before using the value:

JSX
const email = _.get(customer, 'emails[0].email')

;<div>
  {email?.isMasked ? (
    <Button size="small" disabled>
      Email hidden ({email.maskedPresentation})
    </Button>
  ) : (
    <Button size="small" link={`https://admin.acme.example/customers?email=${encodeURIComponent(email)}`}>
      Open in Acme
    </Button>
  )}
</div>

Support dark mode

Agents switch between light and dark themes, and Kustomer's components follow along. For your own markup, use Kustomer's theme variables instead of fixed colors, sizes and fonts:

JSX
<div
  style={{
    background: 'var(--background-secondary)',
    color: 'var(--text-primary)',
    border: '1px solid var(--border-primary)',
    borderRadius: 'var(--border-radius-primary)',
    fontFamily: 'var(--font-family)',
    padding: 8,
  }}
>
  <div style={{ fontWeight: 'var(--font-weight-medium)' }}>Acme loyalty</div>
  <div style={{ color: 'var(--text-secondary)' }}>Gold member since 2024</div>
</div>

Templates get the variables from the agent desktop. Pages in a Card or DynamicCard get them from the Cards SDK, which sets each one, and color-scheme, on the page's root element, and updates them when the agent switches theme (listen with Kustomer.on('theme', …) to re-render). The playground previews both themes.

PurposeVariables
Backgrounds--background-primary, --background-secondary and --background-tertiary, each with a -hover variant; --background-primary-active, --background-primary-inverse
Text--text-primary, --text-secondary, --text-secondary-hover, --text-hint, --text-link, --text-link-hover, --text-info, --text-primary-inverse
Borders and forms--border-primary; --form-border with -hover, -active and -disabled; --form-background-disabled, --form-text-disabled, --form-text-placeholder
Icons--icon-primary, --icon-primary-disabled, --icon-active
Status--info-text, --info-background and --info-border, and the same for success, alert and danger; --info-secondary-text and --info-secondary-background; --neutral-text and --neutral-background, with --neutral-secondary- variants
Buttons--button-primary-background and --button-primary-text; --button-secondary-text and --button-secondary-border; --button-danger-text and --button-danger-border; --button-subtle-text; each with -hover and -disabled variants
Overlays--tooltip-text, --tooltip-background, --modal-overlay-background
AI--background-ai, --background-ai-subtle, --border-ai, --text-ai, --icon-ai. These are gradients: use them as a background.
Corner radius--border-radius-sharp (4px), --border-radius-primary (8px), --border-radius-large (12px)
Typography--font-family (Inter), --font-size (14px), and --font-weight-light, -normal, -medium, -semiBold and -bold (300 to 700)
Control heights--komponent-size-x-small (26px), --komponent-size-small (30px), --komponent-size-medium (36px)

Older variable names

Templates written before these variables existed use older names. Most are still set, so those templates keep working, but they're deprecated and don't always match the theme: --font-secondary-color, for example, is the same gray in light and dark mode. Replace them as you update a template.

Older nameUse insteadStatus
--background-primary-color--background-primaryDeprecated
--background-primary-color-hover--background-primary-hoverDeprecated
--background-primary-color-selected--background-primary-activeDeprecated
--background-primary-color-rgb--background-primary, with color-mix() for transparencyNo longer set
--background-primary-color-inverse-rgb--background-primary-inverseNo longer set
--background-secondary-color--background-secondaryDeprecated
--background-tertiary-color--background-tertiaryDeprecated
--background-tertiary-color-hover--background-tertiary-hoverDeprecated
--background-tertiary-color-rgb--background-tertiary, with color-mix()No longer set
--background-tertiary-color-hover-rgb--background-tertiary-hover, with color-mix()No longer set
--background-nav-primary-colorNone. It's the color of Kustomer's navigation.Deprecated
--background-modal-overlay-color-rgb--modal-overlay-backgroundNo longer set
--background-search-results-has-active-users-colorNoneNo longer set
--background-search-results-has-active-users-color-hoverNoneNo longer set
--font-primary-color--text-primaryDeprecated
--font-primary-color-inverse--text-primary-inverseDeprecated
--font-secondary-color--text-secondaryDeprecated
--font-secondary-hover-color--text-secondary-hoverDeprecated
--icon-primary-color--icon-primaryDeprecated
--icon-primary-color-disabled--icon-primary-disabledDeprecated
--form-border-color--form-borderDeprecated
--form-border-color-hover--form-border-hoverDeprecated
--form-border-color-disabled--form-border-disabledDeprecated
--color-hint--background-secondary. Despite the name, it's a background color.Deprecated

Call APIs with commands

A command is a named HTTP request defined in your app and executed by Kustomer's servers. Your template or iframe runs it by name. Kustomer fills in secret settings or the app's OAuth token, so credentials never reach the agent's browser.

commands (excerpt)
{
  "commands": [
    {
      "name": "acme_orders.app.lookup-order",
      "displayName": "Look up an Acme order",
      "url": "https://{{{storeDomain}}}/api/orders/{{{orderId}}}",
      "httpMethod": "get",
      "permittedArgs": ["storeDomain", "orderId"],
      "appSettings": { "apiKey": { "key": "acme_orders.default.apiKey" } },
      "defaultHeaders": { "Authorization": "Bearer {{{apiKey}}}" },
      "cacheSeconds": 60,
      "outputSchema": {
        "type": "object",
        "properties": { "status": { "type": "string" }, "items": { "type": "array" } },
        "additionalProperties": true
      }
    }
  ]
}
Command
namestringrequired
urlstringrequired
httpMethodstringrequired
permittedArgsstring[]
permittedUrlArgsstring[]
appSettingsobject
defaultHeadersobject
defaultBodyobject
appAccountNamestring
inputSchemaJSON Schema
outputSchemaJSON Schema
cacheSecondsnumber

The app definition is rejected if a command has any other field.

Run a command

From a template, call runCommand. From an iframe, call Kustomer.command.run. Both go through the same endpoint, which you can also call directly:

post/v1/commands/{name}/run
Request body
{
  "args": { "storeDomain": "shop.acme.example", "orderId": "4417" }
}
Cards SDK
const order = await Kustomer.command.run('acme_orders.app.lookup-order', {
  args: { storeDomain: 'shop.acme.example', orderId: '4417' },
})

The request body can also have headers and body. Both are templates too, so a caller can send { "Authorization": "Bearer {{{apiKey}}}" } and the browser only ever sends the placeholder; {{{apiKey}}} is filled in on the server from the appSettings mapping.

How templates are filled in:

  • Use triple braces. {{apiKey}} HTML-escapes the value, so = becomes &#x3D; and & becomes &amp;. {{{apiKey}}} inserts it as is.
  • A string that is exactly "/#name" inserts the value with its type, such as a number or boolean, instead of text. A falsy value (0, false, "") becomes null.
  • Args win over settings. An arg with the same name as a setting replaces it.
  • inputSchema casts the body before templates are filled in. A property typed number that holds "/#amount" becomes NaN, sent as null. Leave templated properties untyped.

What your API receives:

  • A JSON request (Content-Type: application/json) with the rendered URL, headers and body.
  • x-kustomer-org-id and x-kustomer-user-id headers identifying the org and the agent who ran the command. Kustomer sets them after your headers, so a caller can't change them.
  • With appAccountName, the OAuth token in Authorization: Bearer …, or in the auth config's customHeader.

Kustomer returns your response, filtered through outputSchema, as the command's result. A run fails with 400 before your API is called if it passes an arg that isn't in permittedArgs (cannot run a command with unpermitted args provided in request body), or if the body misses an inputSchema required property (missing required params for inputSchema) or has one it forbids (unsupported property provided for inputSchema).

InteractiveBuild and run a command
Commandone entry in your app definition’s commands array
Run requestPOST /v1/commands/{name}/run

The agent’s browser sends this as is. Settings are filled in on Kustomer’s servers.

Org settings
storeDomain← acme_orders.default.storeDomain
apiKey← acme_orders.default.apiKey
What your API receivesArg from the requestSettingSecret, filled in on the serverNo value
GET https://shop.acme.example/api/orders/4417
Content-Type: application/json
Authorization: Bearer sk_live_51Hx9aQ2==
x-kustomer-org-id: 5f8481c6a22ab5b9d5f2dd8a
x-kustomer-user-id: 5fe109880e432183796e9218
Edit either side and the request updates. The checks run in Kustomer’s order: the app schema when the app is installed, then the run request, permittedArgs, inputSchema and the templates. Try passing an arg that isn’t in permittedArgs.

Caching and cache fallback

With cacheSeconds set, Kustomer stores every response for 24 hours:

  • A cached response younger than cacheSeconds is returned without calling your API.
  • If your API returns a 4xx or 5xx, cache fallback mode turns on for that combination of org, command, URL, headers and body. Kustomer returns the cached response even when it's stale, and retries your API on every fifth run.
  • The first successful (< 400) response turns fallback mode off.

This keeps cards working through a rate limit or an outage on your side.

Embed your own UI

When a template isn't enough (you need your own frontend, a build step, or server-side code) render an iframe with DynamicCard. The page you host loads the Cards SDK to receive context and talk to Kustomer.

template
<DynamicCard src={`https://kustomer.acme.example/card?customer=${customer.id}`} context={context} />
ComponentSize
DynamicCardResizes to fit as your page calls Kustomer.resize()
CardFixed: set height (for example "320px")

Pass a custom context object to send your own data instead of the default:

JSX
<DynamicCard src="https://kustomer.acme.example/card" context={{ type: 'acmeOrder', customContext: { orderId: object.externalId } }} />

The Cards SDK

Load the SDK in the page you host:

HTML
<script src="https://cdn.kustomerapp.com/card-js/latest/kustomer-card.min.js"></script>

Kustomer loads your page with parentUrl and id query parameters, and the SDK uses them to exchange postMessage events only with the Kustomer window that opened it. Your page must be served over HTTPS and allow framing by Kustomer.

card.js
Kustomer.initialize(async (context, { userRoles }) => {
  render(context)
  Kustomer.resize()
})

Kustomer.on('context', (context) => {
  // The agent moved to another conversation or timeline item.
  render(context)
  Kustomer.resize()
})

async function refund(orderId) {
  try {
    await Kustomer.command.run('acme_orders.app.refund-order', { args: { orderId } })
    Kustomer.handleTriggerToast({ type: 'success', message: `Refunded order ${orderId}` })
  } catch ([err]) {
    Kustomer.updateStatus({ status: 'danger', description: 'Acme is unreachable. Refunds are paused.' })
  }
}

context.type is customer, conversation, company, kobject.<klass>, widget, app or your custom type. It carries the full JSON:API resources: context.customer, plus context.conversation or the KObject when one is selected.

MethodDoes
Kustomer.initialize(options?, callback)Handshake. Calls back once with (context, { userRoles }); also returns a promise. options.debug: true logs every message.
Kustomer.on(event, callback)Subscribe to context, theme, flags, number-dialed, routing-status-change or kustomer-typing-status-changed.
Kustomer.request({ url, method, body }, callback?)Call the Kustomer API as the agent. Rejects with [error, data].
Kustomer.command.run(name, { args, headers, body })Run one of your app's commands. Resolves with the response's attributes.
Kustomer.resize({ height?, width? })Fit the iframe. With no height, measures your page.
Kustomer.updateStatus({ status, description }) / Kustomer.clearStatus()Show or clear a banner on the card: info, warning or danger.
Kustomer.handleTriggerToast({ type, message })Show a toast: info, warning, error or success.
Kustomer.openCustomer(id) / Kustomer.openCustomerEvent(customerId, eventId)Navigate the agent.
Kustomer.expandInsightsCard({ appName, appLogo, title, height, url, context }) / Kustomer.contractInsightsCard()Open a full-height view of the insights panel that stays open while the agent moves around the same timeline.
Kustomer.open() / Kustomer.close()Open a widget, or remove the iframe.

Modals

A card can open up to three modals, each its own page that also loads the SDK:

JavaScript
await Kustomer.modal.init({ id: 'refund', url: 'https://kustomer.acme.example/refund', title: 'Refund order', additionalData: { orderId: '4417' } })
Kustomer.modal.show('refund')
// Later, from the modal or the card:
Kustomer.modal.hide('refund', 'Refund issued')
Kustomer.modal.destroy('refund')

init registers a modal without showing it, and fails once three are registered; destroy or destroyAll frees the slots. show hides any other open modal. hide can show a success toast. resize({ height, width }) resizes the visible modal.

Troubleshooting

SymptomCheck
The card is blankA JavaScript error in the template. Check the browser console, and make sure the template ends with exactly one JSX expression.
Cannot read properties of undefinedRead optional data with _.get, and guard on conversation in customer-level views.
Text is invisible in dark modeReplace fixed colors with the theme variables, and the older --font-*-color names with --text-*.
The iframe never initializesIt must load the Cards SDK and be framed by Kustomer (with parentUrl and id in its URL). Turn on Kustomer.initialize({ debug: true }, …).
A command returns 400"cannot run a command with unpermitted args": an args key isn't in permittedArgs (or a urlArgs key isn't in permittedUrlArgs). Otherwise the body breaks inputSchema, or the command is disabled.