Hopp til innholdet

DokumentasjonAdmin i egen appMarkdown

Admin UI in your own app

Put the full Snakkbar inbox inside a customer's existing admin/back office. Their staff stay in their own system, logged in with their own accounts; Snakkbar never shows a login.

Browser (your admin page)          Your backend                         Snakkbar
──────────────────────────         ─────────────                         ────────
mountAdmin(el, { token })  ──►  GET /snakkbar/operator-token
                                 (checks your own session)
                                 POST /api/v1/server/operator-tokens ──►  upserts operator,
                                   Authorization: Bearer sk_snakkbar_…     returns JWT (1 h)
                           ◄──   { token }
admin UI calls /api/v1/operator/* with the token, refreshes it before expiry

1. Create a secret API key

Admin → Innstillinger → API-nøkler → Ny nøkkel (or POST /api/v1/operator/api-keys as an admin). The key (sk_snakkbar_…) is shown once. Store it as a server-side secret (e.g. SNAKKBAR_SECRET_KEY). Never send it to a browser.

2. Add a token endpoint to your backend

It must only answer for your logged-in staff, and map each of your users to a stable externalId.

ts
// Example: Next.js route handler — app/api/snakkbar/operator-token/route.ts
export async function POST() {
  const user = await requireStaffUser(); // your own auth; throw 401 if not staff
  const res = await fetch(`${process.env.SNAKKBAR_HOST}/api/v1/server/operator-tokens`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.SNAKKBAR_SECRET_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      externalId: user.id,              // stable id in your system
      name: user.displayName,           // shown to visitors
      email: user.email,                // optional
      avatarUrl: user.avatarUrl,        // optional, https URL
      role: user.isAdmin ? "admin" : "agent",
      ttlSeconds: 3600,                 // optional, max 86400
    }),
  });
  if (!res.ok) return new Response("Snakkbar token failed", { status: 502 });
  const { token, expiresAt } = await res.json();
  return Response.json({ token, expiresAt });
}

The first exchange for an externalId creates the operator; later exchanges update name, email, avatar and role. Operators can still change their own greeting, language and avatar in the UI. A token request for a removed operator is refused with 403 (see below).

Token exchange alone creates staff the first time they open the inbox, but never removes anyone. Let your user system be the source of truth and push changes to Snakkbar:

When, in your systemCall
A user becomes staff (created, added to the staff group, re-enabled)PUT /api/v1/server/operators/:externalId { name, email?, avatarUrl?, role?, invite? } (invite: true mails a sign-in link to the hosted panel)
Name, e-mail, photo or role changesthe same PUT (idempotent)
A user stops being staff (deleted, removed from the group, suspended)DELETE /api/v1/server/operators/:externalId
Nightly, to repair missed eventsGET /api/v1/server/operators, then PUT / DELETE the differences

PUT provisions staff before they ever log in, so colleagues can @-mention them and the assistant can hand conversations to them. DELETE deactivates rather than erases: their tokens stop working immediately, they leave the team and every open conversation (ownership passes to the next person or back to the assistant), and their e-mail is cleared. Their name and photo stay on messages they wrote. A later PUT with the same externalId brings them back with their history. Both calls are safe to repeat; DELETE answers 204 for unknown ids.

The hosted panel too. Staff you provision can also answer in Snakkbar's hosted panel (<host>/panel) and the mobile app, with no separate Snakkbar account to manage. Send their work e-mail and invite: true on the PUT (or call POST /api/v1/server/operators/:externalId/invite): they get a one-time sign-in link, no password. They can also ask for a link or a code at <host>/panel/login. The first sign-in with that verified address links the panel login to the same operator your tokens use: same id, history, departments and notification settings, and the button in alert e-mails works for them. Your system stays in charge: DELETE ends panel access at once, and changing the e-mail or reactivating someone unlinks the login until the address signs in again. role is always yours; it cannot be changed in the panel. Details: api.md.

Integration accounts. If your backend needs an operator of its own (for example to manage departments with an operator token), mark it system: true on the PUT: it is hidden from the team, @-mentions, round-robin, alerts and the dashboard. Departments can also be managed directly with the secret key: /api/v1/server/departments.

Example: Craft CMS module

A module that treats members of the user group kundeservice (and Craft admins) as staff. Calls go through Craft's queue, so a Snakkbar outage is retried instead of blocking the save.

php
<?php
// modules/snakkbar/Module.php — register in config/app.php: 'modules' => ['snakkbar' => modules\snakkbar\Module::class], 'bootstrap' => ['snakkbar']
namespace modules\snakkbar;

use Craft;
use craft\elements\User;
use craft\events\ElementEvent;
use craft\events\UserEvent;
use craft\events\UserGroupsAssignEvent;
use craft\services\Elements;
use craft\services\Users;
use yii\base\Event;

class Module extends \yii\base\Module
{
    public function init(): void
    {
        parent::init();
        Craft::setAlias('@modules/snakkbar', __DIR__);
        $this->controllerNamespace = Craft::$app->request->isConsoleRequest ? 'modules\snakkbar\console' : 'modules\snakkbar\controllers';

        $sync = fn(?User $user) => $user && Craft::$app->queue->push(new SyncOperator(['userId' => $user->id, 'uid' => $user->uid]));
        foreach ([Elements::EVENT_AFTER_SAVE_ELEMENT, Elements::EVENT_AFTER_DELETE_ELEMENT, Elements::EVENT_AFTER_RESTORE_ELEMENT] as $name) {
            Event::on(Elements::class, $name, fn(ElementEvent $e) => $e->element instanceof User && $sync($e->element));
        }
        foreach ([Users::EVENT_AFTER_SUSPEND_USER, Users::EVENT_AFTER_UNSUSPEND_USER] as $name) {
            Event::on(Users::class, $name, fn(UserEvent $e) => $sync($e->user));
        }
        Event::on(Users::class, Users::EVENT_AFTER_ASSIGN_USER_TO_GROUPS, fn(UserGroupsAssignEvent $e) => $sync(User::find()->id($e->userId)->status(null)->trashed(null)->one()));
    }

    public static function isStaff(?User $user): bool
    {
        return $user && !$user->trashed && $user->getStatus() === User::STATUS_ACTIVE && ($user->admin || $user->isInGroup('kundeservice'));
    }

    public static function client(): \GuzzleHttp\Client
    {
        return Craft::createGuzzleClient([
            'base_uri' => rtrim(getenv('SNAKKBAR_HOST'), '/') . '/api/v1/server/',
            'headers' => ['Authorization' => 'Bearer ' . getenv('SNAKKBAR_SECRET_KEY')],
        ]);
    }

    public static function remove(string $uid): void
    {
        self::client()->delete('operators/' . rawurlencode($uid));
    }

    /** PUT for staff, DELETE for everyone else. */
    public static function push(User $user): void
    {
        if (!self::isStaff($user)) {
            self::remove($user->uid);
            return;
        }
        $body = ['name' => $user->getFriendlyName(), 'email' => $user->email, 'role' => $user->admin ? 'admin' : 'agent'];
        $photo = $user->getPhoto()?->getUrl();
        if ($photo && str_starts_with($photo, 'https://')) {
            $body['avatarUrl'] = $photo; // left out otherwise, so a photo set in Snakkbar survives
        }
        self::client()->put('operators/' . rawurlencode($user->uid), ['json' => $body]);
    }
}
php
<?php
// modules/snakkbar/SyncOperator.php
namespace modules\snakkbar;

use craft\elements\User;
use craft\queue\BaseJob;

class SyncOperator extends BaseJob
{
    public int $userId;
    public string $uid;

    public function execute($queue): void
    {
        $user = User::find()->id($this->userId)->status(null)->trashed(null)->one();
        // Hard-deleted users are gone from Craft by now; remove them by uid.
        $user ? Module::push($user) : Module::remove($this->uid);
    }

    protected function defaultDescription(): string
    {
        return 'Snakkbar: synkroniser bruker';
    }
}
php
<?php
// modules/snakkbar/console/SyncController.php — run nightly: php craft snakkbar/sync
namespace modules\snakkbar\console;

use craft\console\Controller;
use craft\elements\User;
use modules\snakkbar\Module;

class SyncController extends Controller
{
    public function actionIndex(): int
    {
        $staff = [];
        foreach (User::find()->status(null)->each() as $user) {
            if (Module::isStaff($user)) {
                Module::push($user);
                $staff[$user->uid] = true;
            }
        }
        $list = json_decode((string) Module::client()->get('operators')->getBody(), true);
        foreach ($list['operators'] as $operator) {
            if (!$operator['deactivatedAt'] && !isset($staff[$operator['externalId']])) {
                Module::remove($operator['externalId']);
            }
        }
        return 0;
    }
}

The token endpoint from step 2 must use the same rule (Module::isStaff(Craft::$app->user->identity)) and the same externalId ($user->uid).

3. Mount the inbox

html
<div id="snakkbar-admin" style="height: calc(100vh - 64px)"></div>
<script src="https://<host>/admin.js"></script>
<script>
  let cached = null;
  async function token() {
    if (cached && Date.parse(cached.expiresAt) - Date.now() > 60_000) return cached.token;
    cached = await (await fetch("/api/snakkbar/operator-token", { method: "POST" })).json();
    return cached.token;
  }
  const admin = Snakkbar.mountAdmin(document.getElementById("snakkbar-admin"), {
    host: "https://<host>",
    token,               // string or (async) function; called before every request
    language: "no",      // "en" for English UI
    theme: "auto",       // "light" | "dark" | "auto": until the person picks one themselves
    // onThemeChange: (theme) => …, // optional: told when they pick (e.g. to match your page)
    // look: "snakkbar", // optional: Snakkbar's own look (as in the hosted panel) instead of the
    //                   // neutral default in the tenant's brand colour
  });
  // admin.unmount() when leaving the page
</script>

The admin renders in a shadow root and fills its container; give the container a height. It needs the page origin in the tenant's allowed origins, like the widget.

By default it is neutral and uses the tenant's brand colour, so it sits quietly in your own system. look: "snakkbar" switches to Snakkbar's own look (sand and ink, Snakkbar pink, artwork from <host>/brand/). That look uses the fonts in the CSS variables --font-display and --font-body when your page defines them on or above the container (e.g. Bricolage Grotesque and Figtree), and system fonts otherwise. In that look the navigation rail sits on the left and opens to icons with labels from the Snakkbar logo at its top (remembered in the browser).

Staff can pick Lys / Mørk / Automatisk themselves (the profile menu at the bottom of the rail, or Settings); the pick is kept in their browser's localStorage and wins over theme from then on.

What staff get

Inbox with filters (Alle / Åpne / Mine), several conversations side by side (drag a conversation or tab to the edge of a pane to split; up to 6 panes with 6 tabs each), join / take over / give back / leave / close, typing indicators and read receipts, alerts (title blink, sound, desktop notifications), «Spør etter deg» when a visitor asks for them, visitor details and move visitor to page, translation in both directions, «Legg i kunnskap» on replies, knowledge base and suggestion review, callbacks, own profile (name, photo, greeting, language) and — for admins — tenant settings, assistant portrait and API keys.

Also «Oversikt» (dashboard: visitors and conversations now and over a period, response time to requests for a person, busy hours, top pages) and «Besøkende» (live visitors: who is on the site now, their page, device, location and navigation history, and «Se live» to follow one visitor's cursor, clicks and scroll over the page they are on).

For «Se live» to show the real page, the tenant's public site must allow being framed by your admin page's origin; see Framing the customer's site below. Otherwise it shows a schematic view.

Only want part of it?

Use the SDK (createOperatorClient) and build your own screens; see sdk.md. A common split: stock widget for visitors, own inbox for staff, or the other way round.

Build your own live view

A headless admin can show the same live view as «Se live». The parts:

  1. The feed. staff.visitors.watch(visitorId) (renews every 10 s) makes the visitor's browser send pointer events on staff.subscribe(...): url, viewport, scroll, documentHeight, and pointer samples / clicks as [t, x, y] in the visitor's viewport coordinates. See realtime.md.
  2. The page. An iframe named snakkbar-mirror loading the visitor's current URL (currentPage.url from visitors.get(), then the url of each frame). Snakkbar's widget (or startTracking()) on that page sees the name, renders nothing, tracks nothing, and runs the mirror: it answers your postMessages and scrolls the page where you tell it.
  3. The overlay. Your own cursor and click marks drawn on top of the iframe.
html
<div class="live" style="position: relative; overflow: hidden">
  <iframe
    name="snakkbar-mirror"
    sandbox="allow-scripts allow-same-origin"
    referrerpolicy="no-referrer"
    style="position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none"
  ></iframe>
  <div class="cursor" style="position: absolute; pointer-events: none"></div>
</div>
ts
import { isMirrorMessage, type MirrorMessage } from "@snakkbar/sdk";
// `staff` is your createOperatorClient(...) from sdk.md; `visitorId` the visitor to watch.

const frame = document.querySelector<HTMLIFrameElement>("iframe[name=snakkbar-mirror]")!;
const box = frame.parentElement!;
const cursor = box.querySelector<HTMLElement>(".cursor")!;
let mirrorReady = false;
let fallback: ReturnType<typeof setTimeout> | undefined;

// No `ready` from the mirror within a few seconds: the site refused framing (or has no widget).
function load(url: string) {
  mirrorReady = false;
  frame.src = url;
  clearTimeout(fallback);
  fallback = setTimeout(() => !mirrorReady && showSchematicView(), 6000);
}

window.addEventListener("message", (event) => {
  if (event.source !== frame.contentWindow || !isMirrorMessage(event.data)) return;
  if (event.data.type === "ready") { mirrorReady = true; clearTimeout(fallback); hideSchematicView(); }
});

const visitor = await staff.visitors.get(visitorId);
if (visitor.currentPage) load(visitor.currentPage.url);

const send = (message: MirrorMessage, url: string) =>
  frame.contentWindow?.postMessage(message, new URL(url).origin);

const stopWatching = staff.visitors.watch(visitorId);
const sub = staff.subscribe((event) => {
  if (event.type !== "pointer" || event.visitorId !== visitorId || !event.visible) return;
  if (event.url !== frame.src) load(event.url); // the visitor moved on
  if (event.viewport) {
    // Render the page at the visitor's viewport size, scaled to fit the box.
    const scale = box.clientWidth / event.viewport.width;
    frame.style.width = `${event.viewport.width}px`;
    frame.style.height = `${event.viewport.height}px`;
    frame.style.transform = `scale(${scale})`;
    box.style.height = `${event.viewport.height * scale}px`;
    const last = event.samples.at(-1);
    if (last) cursor.style.transform = `translate(${last[1] * scale}px, ${last[2] * scale}px)`;
    // event.clicks: draw a short ripple at each [t, x, y] (scaled the same way)
  }
  if (event.scroll) send({ snakkbar: "mirror", type: "scroll", x: event.scroll.x, y: event.scroll.y }, event.url);
});
// leaving: stopWatching(); sub.close();

For smooth motion, replay samples over the batch using their t offsets instead of jumping to the last one.

The MirrorMessage protocol

All messages are objects with snakkbar: "mirror" (check with isMirrorMessage):

DirectiontypeFields
Live view → pagehelloAsks the mirror to send ready again (e.g. when your view mounts after the page loaded).
Live view → pagescrollx, yScroll the page to the visitor's position (instant).
Page → live viewreadyurl, width, height, documentHeightThe mirror runs. Sent on load, on hello, and after the page's URL changes.

The mirror only accepts messages from its parent window, and only sends layout numbers and the page's own URL. It runs only when the tenant has live visitors on and the page has the widget or calls startTracking().

Framing the customer's site

The iframe loads the public page from the customer's site, so that site decides whether it may be framed:

  • It must allow the origin of the page that contains the iframe in its CSP, e.g. Content-Security-Policy: frame-ancestors 'self' https://admin.example.no. That is https://<host> for the hosted panel, and the origin of your own admin page for mountAdmin or your own live view. List both if staff use both.
  • It must not send X-Frame-Options: DENY, or SAMEORIGIN when the admin is on another origin. Browsers that support frame-ancestors ignore X-Frame-Options when both are present, but older ones don't; drop it once frame-ancestors is in place.
  • Cookies set with SameSite=Lax/Strict (the default in most browsers) are not sent to a cross-site iframe, so pages that need a login show their logged-out version.

Sandbox. allow-scripts is required (the mirror is a script) and allow-same-origin keeps the page working (storage, its own requests) as it does for visitors. Leave out allow-forms, allow-popups and allow-top-navigation*, so nothing in the page can submit, open windows or navigate your admin, and set pointer-events: none so the operator can't click in the copy by accident. If your admin is served from the same origin as the customer's site, the framed page has full access to the admin page either way; that is the customer's own code, but keep it in mind.

Fallback. When the site refuses framing (or has no widget), the browser shows an error page in the iframe and you can't tell from the outside. Treat "no ready within a few seconds" as refused and switch to a schematic view: a page outline sized viewport × documentHeight with the scroll position, cursor and clicks from the pointer events, and the URL and title.

The operator's browser loads the page with its own session, never the visitor's; see security.md.

Hosted alternative

Staff can always use <host>/panel with Snakkbar accounts instead (created by Snakkbar), or with the sign-in your backend invites them to (section 2b). Hosted and embedded operators can work side by side in the same tenant, and one person can use both with the same operator.