Nordic Engine
a CRM that calls and listens

Multi-tenant CRM where the database itself keeps companies apart. Browser softphone, two-channel transcripts, AI call review

Built Nordic Engine for the sales team, then split it so a second company could sell from the same system without ever seeing our data.

Software developer, AI engineerTypeScript · Next.js · ReactAug 2025 to May 2026

Try it

One click opens the engine on invented Danish leads: open the pipeline and replay a staged sales call with its transcript and AI review. No real call is placed, and the demo runs on its own database with no link to production.

Architecture

Seller's browser: CRM and softphone
Next.js app: pages and server actions
PostgreSQL with row-level security per company
Twilio: phone network
Recording webhook
Transcription, per channel
AI call review
Web forms, email and booking intake

Connections

  • Seller's browser: CRM and softphone → Next.js app: pages and server actions: pipeline, dialer, inbox
  • Next.js app: pages and server actions → PostgreSQL with row-level security per company: queries as the signed-in user
  • Seller's browser: CRM and softphone → Twilio: phone network: call from the browser
  • Twilio: phone network → Recording webhook: two-channel recording
  • Recording webhook → Transcription, per channel: lead and seller transcribed apart
  • Transcription, per channel → AI call review: merged transcript
  • AI call review → PostgreSQL with row-level security per company: feedback on the call
  • Web forms, email and booking intake → PostgreSQL with row-level security per company: verified, then written

The app is Next.js with server components for reading and server actions for writing, validated on every input. There is no separate API layer and no ORM: pages query PostgreSQL directly as the signed-in user, so the database’s row-level security applies to every read. Writes that touch several tables go through database functions, so they commit as one transaction or not at all.

Webhooks and intake (forms, inbound email, the phone network, calendar bookings) run without a user session. They verify a signature or a shared secret first and only then write, using derived idempotency keys so a retried webhook lands once.

The phone runs in the browser. Calls are recorded with the seller on one channel and the lead on the other. When a recording is ready, the webhook answers at once and finishes the work after the response: download, transcribe each channel separately, merge them into one conversation, then ask a language model for feedback. A failed transcription costs the transcript and nothing else; the logged outcome is already safe.

An earlier version also had an AI voice agent that placed calls through a small bridge I wrote, streaming phone audio to a realtime voice model. The company retired the voice agents in August 2026 and kept the AI behind the seller instead, as transcripts and feedback.

Several companies in one system, isolated in the database

Constraint. A second company moved into the system with its own sellers. The app already had around 200 queries and about 30 access policies. Neither company could be allowed to see the other’s leads or even its people.

Options. Add a company filter to every query in the app, run a separate database per company, or let the database decide which company a session is looking at.

Decision. Every table that holds company work got a company column and one restrictive policy, added on top of whatever it already granted. A single database function answers “which company is this session in”, from the user’s active membership. Rows created without a session (a webhook, an import) follow the lead they belong to through a trigger.

Result. No existing query or policy was rewritten. Removing someone from one company ends that membership only, and switching company is a database call that checks membership first.

Proving the isolation instead of believing it

Constraint. “Default deny” is the kind of claim that sits in a comment for a year while being wrong. A test script can’t prove it: the service key that scripts use bypasses row-level security entirely and would report all clear against a schema with no policies at all.

Decision. The proof is a database function that runs as an ordinary signed-in role with a forged identity, set exactly where the auth layer reads it. It asserts that an outsider reads nothing, that a seller sees only what a seller should, and, just as important, that the owners can still read their own CRM. A policy of false would pass the first two checks and lock the company out.

Result. Access rules can be changed and re-proved in one call, against the real schema.

Turning a phone call into a readable conversation

Constraint. Automatic speaker separation on Danish phone audio is not reliable, the transcription takes far longer than the phone network’s webhook timeout, and a timeout makes the network retry, which would pay to transcribe the same call again.

Decision. Record on two channels for the same price as one, so speaker separation is free. Answer the webhook immediately and do the work after the response. Merge the two channels by timestamp, joining consecutive lines from the same speaker, and break ties toward the lead, because a simultaneous line is almost always the customer answering.

Result. Every recorded call becomes a transcript that reads like a conversation, and a slow or failed model call never costs the call record itself.

Code

One restrictive policy per table, added in a loop, so no existing query changes. Lightly edited for display: one comment shortened.

supabase/migrations/0092_to_virksomheder_og_roller.sql

do $$
declare
  t text;
begin
  -- Tables whose company comes from the session (or the default company).
  foreach t in array array[
    'companies', 'call_campaigns', 'dialer_state', 'overblik_targets',
    'email_templates', 'mail_sendouts', 'scrape_jobs', 'scrape_finds',
    'contacts', 'activities', 'quotes', 'emails', 'email_drafts',
    'mail_sendout_recipients'
  ] loop
    execute format(
      'alter table public.%I add column workspace_id uuid not null
         default public.default_workspace() references public.workspaces (id)',
      t);
    execute format(
      'create policy %I on public.%I as restrictive for all to authenticated
         using (workspace_id = (select public.current_workspace()))
         with check (workspace_id = (select public.current_workspace()))',
      t || '_this_workspace', t);
  end loop;

Two separately transcribed channels back into one conversation. Lightly edited for display: a person’s name removed from the comment.

lib/transcribe/merge.ts

/*
 * Ties break toward the LEAD, not toward the seller. When both sides start within
 * the same instant it is almost always the customer answering something.
 */
export function mergeChannels(
  ours: TranscriptSegment[],
  theirs: TranscriptSegment[],
  labels: { ours: string; theirs: string },
): string {
  const lines = [
    ...ours.map((segment) => ({ segment, who: labels.ours, rank: 1 })),
    ...theirs.map((segment) => ({ segment, who: labels.theirs, rank: 0 })),
  ].sort((a, b) => a.segment.start - b.segment.start || a.rank - b.rank);

  const out: string[] = [];
  for (const line of lines) {
    const previous = out.length > 0 ? out[out.length - 1] : null;

    /* Consecutive lines from the same speaker are joined rather than repeating
     * the name every few seconds - whisper segments on breath, not on turns. */
    if (previous?.startsWith(`${line.who}:`)) {
      out[out.length - 1] = `${previous} ${line.segment.text}`;
      continue;
    }
    out.push(`${line.who}: ${line.segment.text}`);
  }

  return out.join("\n");
}

The dialer’s order: never called first, then longest since a call, then a fixed per-lead slot. Ordering by lead score bunched leads by industry, and the queue is fetched in pages, so a fresh shuffle per page would repeat some leads and skip others. Lightly edited for display: comment shortened.

lib/dialer/queue.ts

export function compareLeadsForQueue(a: DialerCompany, b: DialerCompany): number {
  if (a.last_called_at === null && b.last_called_at !== null) return -1;
  if (a.last_called_at !== null && b.last_called_at === null) return 1;
  if (a.last_called_at !== null && b.last_called_at !== null) {
    const diff = new Date(a.last_called_at).getTime() - new Date(b.last_called_at).getTime();
    if (diff !== 0) return diff;
  }

  if (a.lead_slot !== b.lead_slot) return a.lead_slot - b.lead_slot;

  return a.id.localeCompare(b.id);
}

Background

A sales team that calls Danish businesses all day needs leads, calls, mail and follow-ups in one place, and a manager needs to know how calls actually go without sitting next to the seller. When a second company started selling with the same tools, its leads, people and mail also had to stay completely separate from the first.

Nordic Engine is a CRM and calling platform that runs several companies from one system. A seller opens the dialer, the next lead is already on screen with its key people and company facts, and the call goes out from the browser. When the call ends, the seller logs the outcome and the next lead rings by itself. Afterwards the call is on the timeline as a two-sided transcript with written feedback for the seller.

Leads arrive from web forms, email and booking links, are matched against existing companies and scored. Each company has its own leads, campaigns, templates, inbox and people, and the database refuses to show one company’s rows to someone working in another. I designed the system and was its only engineer.

What I’d do differently

  • Design for more than one company from the first table. Retrofitting a company column onto every table worked, but it took a migration that had to run in one transaction and a second pass to close what the first left shared, such as the team roster and the suppression list.
  • Test the idea before building the infrastructure. I built a realtime voice bridge for AI phone agents, and about a month later the agents were retired in favour of AI behind the seller. A handful of staged calls would have answered that question for less.
  • Look at every chart before shipping it. Some dashboard charts went out tested in code but never viewed on a real screen. For anything a person reads, I now check the rendered page.

Deep dives

In the order the system was hard to build.