Building an app for Effektivflyt

An app is a small web page from another system, shown inside Effektivflyt in one or both of two places:

  • In each case: a box in the case's side panel. It knows the open case and, if the company allows it, can add notes and tags (a customer card from an ERP, tracking for a shipment).
  • In the left sidebar: a row under Views that opens the app as a full page, with no case (a customer list, a dashboard, a stock lookup).
┌ sidebar ────────┐┌ main area ──────────────────────────┐┌ case panel ─────────┐
│ Team inbox      ││ the case's conversation             ││ Requester, …        │
│ …               ││                                     ││ ┌ Customer card ──┐ │
│ Views           ││   (or, from the sidebar row,        ││ │ your page       │ │
│  Customer card ←││    your page, full size)            ││ └─────────────────┘ │
└─────────────────┘└─────────────────────────────────────┘└─────────────────────┘

There is no review and no SDK to install: you put two files on any web server, and a company admin installs your app by its address. To build and try it, get a free developer company at /developers (sign in, one click): a company of your own on invented customers, orders and PDFs, where you are the admin. It can't connect real mailboxes.

Quick start on your computer

In a terminal (with Effektivflyt running at http://localhost:5173; the /developers page shows these lines with its own address):

mkdir my-app && cd my-app
curl -fO http://localhost:5173/developers/starter/index.html -fO http://localhost:5173/developers/starter/app.js -fO http://localhost:5173/developers/starter/effektivflyt-app.json
python3 -m http.server 3000

The downloaded index.html already loads the kit and styles from that Effektivflyt. Open http://localhost:3000 for a preview: opened on its own, the kit gives the page a sample case and answers every call with sample data (nothing is saved; a blue bar says so; add ?location=sidebar for the sidebar page). Edit app.js (inCase draws the box in a case, inSidebar the sidebar page) and reload. To see it in real cases, install http://localhost:3000/ in your developer company (below).

1. Two files on your server

effektivflyt-app.json, the description admins see before they install:

{
  "name": "Customer card",
  "description": "The requester's account from our ERP: balance, terms and last orders.",
  "url": "./index.html",
  "locations": ["case", "sidebar"],
  "icon": "contact",
  "permissions": ["case.read", "case.notes.write"],
  "height": 300,
  "author": { "name": "Acme Integrations" }
}
Field
name required at most 60 characters, shown as the box's heading
url optional the page to show, relative to the file (default ./)
locations optional case (a box in each case, the default), sidebar (its own page from the left sidebar), or both
icon optional the sidebar row's icon: boxes (default), briefcase, building, calendar-days, chart-column, chart-line, clipboard-list, contact, file-spreadsheet, gauge, kanban, layout-dashboard, list, receipt, shopping-cart, store, workflow
permissions optional what the app may do, see below. Ask for as little as you need.
height optional starting height in pixels, 80–800 (the kit adjusts it to your content)
description, author optional shown to the admin when installing

The page must be on https:// (http://localhost works while you try it out).

Your page loads the kit (and, to look at home, the styles) from the Effektivflyt address the company uses:

<link rel="stylesheet" href="https://app.effektivflyt.no/apps/ui.css">
<script src="https://app.effektivflyt.no/apps/sdk.js"></script>
<script>
  Effektivflyt.ready().then(context => {
    document.body.textContent = `Case from ${context.requester.name} <${context.requester.email}>`;
  });
</script>

2. Install it

Settings → Admin → Apps → paste the address of the folder (or of the .json file) → Look at it. The admin sees your app's name, maker, address and what it asks for, then clicks Install. Admins can switch an app off or remove it at any time. There is a ready example, Customer card, under Install an app: its source is in static/apps/examples/customer-card/.

Publishing it in the app directory

Settings → Apps has an app directory: apps the owner has looked at, which any company's admin installs in one click (they still see what it may do first). To get yours in, open /developers/publish, give the address of your app (where its effektivflyt-app.json is) and a category, and Submit for review. You see the answer there, with any note.

  • The directory lists apps on https:// only (on a --test server, localhost is allowed too).
  • Changed your app? Read it again. New text keeps it listed; asking for a new permission or a new place sends it back to review. An admin installing it is warned if it asks for more than what was reviewed.
  • Take it out removes it from the directory; companies that installed it keep it.

3. The kit (/apps/sdk.js)

Call Permission What it does
await Effektivflyt.ready() none the context (below); call it first. Outside Effektivflyt: a sample (context.preview is true)
Effektivflyt.on('case.changed', fn) none fn(context) when the case changes (reply, note, tag, status)
await Effektivflyt.case.get() case.read { status, assignee, urgent, tags, notes } (notes is a count)
await Effektivflyt.tags.list() case.read the company's tag names (also on a sidebar page)
await Effektivflyt.notes.add(text) case.notes.write an internal note; the history shows Anna Berg via Customer card
await Effektivflyt.tags.add(name), .remove(name) case.tags.write by tag name
await Effektivflyt.token() none a signed token for your own server (below; also on a sidebar page)
Effektivflyt.openLink(url) none opens a web address in a new tab
Effektivflyt.resize(px?) none sets the box's height; done for you as your page grows

A refused or failed call rejects with an Error whose code is e.g. permission_denied, tag_unknown, note_invalid, app_disabled or timeout.

Calls about the case work only in a case; on a sidebar page they reject with case_missing.

The context:

{
  "location": "case",
  "case": { "id": "AAMk…", "mailbox": "post@company.dk", "subject": "Order 4471", "conversationId": "…", "receivedAt": "…", "emailIds": ["…"] },
  "requester": { "name": "Lars Mikkelsen", "email": "lars.mikkelsen@nordiskbyg.dk" },
  "user": { "name": "Mette Jensen" },
  "company": { "name": "Nordisk Grossist A/S" },
  "permissions": ["case.read", "case.notes.write"],
  "locale": "da",
  "theme": { "mode": "dark", "colors": { "accent": "#7B93F5", "text": "#ECEEF2", "…": "…" } },
  "revision": 3
}

On a sidebar page location is "sidebar" and case and requester are null. Each case opens your page fresh; case.changed is for changes while it is open.

Design and code: making it fit, and making it yours

Start from the starter (/apps/starter/ on any Effektivflyt address, source in web/static/apps/starter/): a description file, a page and a script that handle both places. Customer card (/apps/examples/customer-card/) is a fuller example.

Look. /apps/ui.css gives you Effektivflyt's own look: text (ef-title, ef-heading, ef-muted), layout (ef-stack, ef-row, ef-spread, ef-card), facts (ef-facts), badges (ef-badge accent|success|danger), buttons (ef-button primary|danger|link), fields (ef-label, ef-input) and tables (ef-table, ef-end). Open /apps/ui.html to see every piece. The kit fills the --ef-* colours with the ones the person is looking at (light, dark, or another theme), so your app changes with them. Outside Effektivflyt the page follows the computer's dark mode, so you can build it in a normal browser tab.

Make it yours. Everything in ui.css is a default: set any colour in your own CSS (they win over Effektivflyt's), e.g. :root { --ef-accent: #0f766e; } for your brand colour, or skip ui.css and style it all yourself. Keep it readable in dark mode ([data-ef-theme="dark"]) and narrow (the case panel is about 320 px wide); a sidebar page has the whole main area ([data-ef-location="sidebar"]).

Good manners.

  • Show something useful at once; load the rest after. Say plainly when there is nothing to show.
  • Put buttons at the bottom of the box, in the person's words ("Note the account"), and say what happened afterwards (ef-status).
  • Don't repeat what Effektivflyt already shows (subject, requester, status).
  • Your page opens again for every case; keep it light (no big frameworks needed).
  • Never ask the person to sign in inside the box; use Effektivflyt.token() and your own server.

4. Trusting requests on your server

What your page tells your server could be made up by anyone. To know a request really comes from someone looking at a case in Effektivflyt, send the token along and check it:

const token = await Effektivflyt.token();
const answer = await fetch('/customer', { headers: { Authorization: `Bearer ${token}` } });

The token is a JWT (HS256) signed with the app's signing secret, which the admin sees once after installing and gives you. It lasts five minutes. Claims: iss (effektivflyt), aud (the app's id in that company), sub (the person's email), name, company, companyName, mailbox, caseId, iat, exp.

import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(token, secret) {
  const [header, body, signature] = token.split('.');
  const expected = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
  if (!signature || !timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) return null;
  const claims = JSON.parse(Buffer.from(body, 'base64url').toString());
  return claims.exp * 1000 > Date.now() ? claims : null;
}

Any JWT library works too (jsonwebtoken, jose, PyJWT with algorithms=["HS256"]).

What an app can't do

  • Read the conversation's text, attachments or other cases; send mail; change status or assignee. (The Cases API, api guide, is for systems that need more.)
  • Reach Effektivflyt directly: its frame has no access to the page or its cookies, and every request goes through the kit and is checked on the server against both the app's approved permissions and the person's own access to the mailbox.
  • Show itself where it wasn't installed: the page's frame-src lists only switched-on apps.
  • Put rows anywhere but the sidebar's Views, or pick an icon outside the list above.

Inside

src/lib/server/apps/ (the installed_apps table, reading descriptions, the actions and their permissions, tokens), routes/api/apps/ (install, switch, remove, actions) and src/lib/cases/plugins/apps/ (Settings → Apps, the case-panel box, the sidebar page AppScreen.svelte through the plugin's dynamicViews, and the message bridge bridge.ts). The kit and styles are web/static/apps/sdk.js and ui.css. hooks/securityHeaders.ts adds switched-on apps' origins to frame-src.