Integration Guide

Marli Channel Partner Integration

Complete technical reference for partners integrating Marli into their product. The integration is a single iframe authenticated by short-lived JWTs — no shared secret, no launch-URL exchange, no third-party cookies.

How the Integration Works

Three things happen each time a user opens Marli:

  1. The <MarliWidget> SDK in your product renders an iframe pointing at our embed URL.
  2. The iframe posts a marli:ready message to your page; your page asks your backend to mint a short-lived signed JWT and posts it back as marli:bootstrap.
  3. The iframe sends every chat request to our gateway with the JWT in the Authorization header. Our gateway verifies the signature, resolves the customer org, and routes to Marli.

JWTs are short-lived (5–15 minutes). When one expires, the iframe automatically asks the SDK for a fresh one — no user action required.

What LyticaLabs Gives You

When your account is ready you receive a "welcome packet":

ItemWhat it isUse
Partner slugKebab-case identifier (e.g. iscream)JWT iss claim + embed URL path
Embed URLhttps://alpha.lyticalabs.ai/embed/cp/{slug}The baseUrl for <MarliWidget>
API key64-char hex string, shown onceServer-to-server org provisioning only
Approved domainsOrigins where iframe loadsCSP frame-ancestors allow-list
Branding display nameLabel shown in the iframe (e.g. "iScream")Connecting/error UX
Sample mint endpointWorking Node/Next.js exampleBackend reference
Warning: The API key cannot be recovered if lost. Save it in your secrets manager (1Password, AWS Secrets Manager, Vercel env vars) immediately.

What You Give LyticaLabs

1. Identification

  • Company name
  • Technical contact name + email
  • Production domain(s) (e.g. https://app.example.com)
  • Staging domain(s)

2. A Public JWK (JSON Web Key)

Generate an RS256 (RSA 2048-bit) or ES256 (P-256 ECDSA) keypair. Keep the private half in your secrets manager; send LyticaLabs the public half as a JWK.

This pilot does not support HS256 (shared secret). Asymmetric only.
Generate with jose (run once locally)
import { generateKeyPair, exportJWK } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('RS256', {
  modulusLength: 2048,
  extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const privateJwk = await exportJWK(privateKey);

const kid = `partner-key-${new Date().toISOString().slice(0, 10)}`;

console.log('Send this to LyticaLabs:');
console.log(JSON.stringify({ kid, alg: 'RS256', publicJwk }, null, 2));

console.log('Save these in your secrets manager (NEVER commit):');
console.log('CP_PARTNER_PRIVATE_JWK=' + JSON.stringify(privateJwk));
console.log('CP_PARTNER_KID=' + kid);
Alternative: OpenSSL
openssl genrsa -out partner-private.pem 2048
openssl rsa -in partner-private.pem -pubout -out partner-public.pem
# convert to JWK with jose's importKey + exportJWK

3. Identifier Conventions

  • partnerOrgId: stable customer/account ID in your system (e.g. publisher_12345). Must not change.
  • userId: stable user ID (e.g. user_789). Must not change. Only needs to be unique within one partnerOrgId.

The JWT Format

Your backend mints a JWT for each user session. Mint a fresh one when:

  • The page first loads.
  • The iframe posts marli:expired back to your page (the SDK handles this).

Header

FieldValueNotes
algRS256 or ES256Must match the alg of the JWK you registered
kidYour chosen key IDRequired so LyticaLabs picks the right verification key

Payload (exactly five claims — additional claims are ignored)

ClaimTypeNotes
issstringYour partner slug (e.g. "iscream")
iatnumberIssued-at, Unix seconds
expnumberExpiry, Unix seconds. Recommended TTL: 5–15 minutes.
partnerOrgIdstringStable customer ID. Must match a provisioned customer-org row.
userIdstringStable user ID. Unique within one partnerOrgId.
Example decoded JWT
// Header
{ "alg": "RS256", "kid": "partner-key-2026-05-06" }

// Payload
{
  "iss": "iscream",
  "iat": 1778101494,
  "exp": 1778101794,
  "partnerOrgId": "publisher_12345",
  "userId": "user_789"
}

We deliberately do not ask you to put email, name, role, permissions, or PII in the JWT. The smaller the surface, the smaller the leak risk.

Identity-Bearing Claim Format — No Colons

partnerOrgId and userId must not contain the colon character (:). Internally we namespace end users as cp:<slug>:<partnerOrgId>:<userId>. Colons in either claim would create ambiguous internal IDs.

A JWT with a colon in either claim is rejected at the gateway with HTTP 400 PARTNER_JWT_MISSING_CLAIMS.

If your IDs use colons, two clean encodings:

  • Replace : with _ — keep it deterministic.
  • URL-encode team%3A1%3Amember%3A2. Reversible.

Backend: The Mint Endpoint

Your backend exposes one endpoint (e.g. POST /api/marli-token) that the SDK calls from the partner-side page. The recommended path uses our createMarliMintHandler helper:

app/api/marli-token/route.ts
import { createMarliMintHandler } from '@lyticalabs/marli-sdk/server';

export const POST = createMarliMintHandler({
  partnerSlug: process.env.CP_PARTNER_SLUG!,
  privateJwkJson: process.env.CP_PARTNER_PRIVATE_JWK!,
  kid: process.env.CP_PARTNER_KID!,
  alg: 'RS256',
  resolveContext: async (request) => {
    const session = await getYourSession(request);
    if (!session) return null;
    return {
      partnerOrgId: session.activeOrg.id,
      userId: session.user.id,
    };
  },
});

export const runtime = 'nodejs';

The helper handles JWT composition, key import + caching, claim-format validation, TTL clamping, and sanitized error envelopes.

StatusBodyMeaning
200{ "jwt": "eyJ…" }Signed JWT ready for the SDK
401{ "error": "unauthenticated" }resolveContext returned null
500{ "error": "mint_failed" }Inspect server logs for [marli-mint] line

Required env vars:

CP_PARTNER_SLUG=iscream
CP_PARTNER_KID=partner-key-2026-05-06
CP_PARTNER_PRIVATE_JWK={"kty":"RSA","d":"…","n":"…",…}

Frontend: Mounting MarliWidget

Install the SDK (during pilot, LyticaLabs provides a tarball; npm publish is on the Phase 2 roadmap):

npm install @lyticalabs/marli-sdk

Mount it in any client component:

components/MarliPanel.tsx
'use client';
import { MarliWidget } from '@lyticalabs/marli-sdk/react';

export function MarliPanel() {
  return (
    <MarliWidget
      baseUrl="https://alpha.lyticalabs.ai"
      partnerSlug="iscream"
      onMintJwt={async () => {
        const res = await fetch('/api/marli-token', { method: 'POST' });
        if (!res.ok) throw new Error('mint failed');
        const { jwt } = await res.json();
        return jwt;
      }}
      iframeAttrs={{
        title: 'Marli',
        allow: 'clipboard-read; clipboard-write',
        style: { width: '100%', height: '600px', border: 'none' },
      }}
      onError={(err) => {
        console.error('[marli]', err.code, err.message);
      }}
    />
  );
}

The SDK enforces three security invariants automatically: it rejects invalid baseUrls at construction, locks the iframe sandbox to a known-safe value, and only sends JWTs to the canonical origin.

Provisioning Customer Orgs

Before any user from a given customer can use Marli, register that customer. This is a one-time server-to-server call (typically during your customer-creation flow):

curl -X POST https://alpha.lyticalabs.ai/api/v1/cp/orgs \
  -H "Authorization: Bearer ${CP_PARTNER_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "partnerExternalOrgId": "publisher_12345",
    "name": "Acme Media"
  }'

Idempotent — calling twice returns 409 PARTNER_ORG_PROVISION_DUPLICATE (row already exists).

If you mint a JWT for an unprovisioned partnerOrgId, the first chat request returns 404 PARTNER_CUSTOMER_ORG_NOT_FOUNDand the iframe shows a branded "Account not found" message.

Error Codes

SDK-Level (Parent Page) — Your onError Handler

CodeWhenRecovery
BASE_URL_INVALIDConstruction; bare hostname or non-https URLFix the prop
CONTAINER_INVALIDVanilla path; not a real DOM elementPass a real element
PARTNER_SLUG_INVALIDSlug isn't kebab-caseUse the slug from your welcome packet
JWT_MINT_FAILEDYour /api/marli-token returned non-200Check your mint endpoint
IFRAME_DESTROYEDbootstrap() called after destroy()Construct a new instance

Gateway-Level (Inside the Iframe)

CodeHTTPMeaningAction
PARTNER_JWT_EXPIRED401JWT past its expNone — SDK auto-refreshes
PARTNER_JWT_INVALID_FORMAT401JWT not a valid JWS stringCheck mint endpoint output
PARTNER_JWT_KID_NOT_FOUND401kid doesn't match any active JWKVerify CP_PARTNER_KID matches registration
PARTNER_JWT_SIGNATURE_INVALID401Signature doesn't verifyVerify private/public JWK pair matches
PARTNER_JWT_MISSING_CLAIMS400Required claim missing or contains :Check payload — all five claims required, no colons
PARTNER_SUSPENDED403Partner account suspendedContact LyticaLabs
PARTNER_CUSTOMER_ORG_NOT_FOUND404partnerOrgId not provisionedRun provisioning call for this org

CSP on Your Side

If your product uses a Content Security Policy, allow-list our domain as a frame source:

Content-Security-Policy: frame-src https://alpha.lyticalabs.ai

We enforce the reciprocal allow-list: the iframe only loads if your domain is in the allowedDomains we registered. Iframes from unlisted origins are blocked at the browser before any content paints.

Known Limitations During Pilot

Chat History Is Session-Only

The iframe holds in-memory conversation context via the AI SDK's useChat hook. It is not persisted server-side. Page reload or tab close clears the conversation. Cross-session history is on the Phase 2 roadmap.

Single Embed Host (No Separate Staging Surface)

The same embed URL serves staging and production. To separate usage, use different partnerOrgId values per environment.

Tool-Call Rendering Is Text-Only

If Marli uses a tool that produces structured output (charts, citations), the iframe currently renders only the text portion. Rich rendering is queued for a near-term release.

What NOT to Put in the JWT

  • Passwords, hashes, secrets, API keys
  • Personally-identifying data without consent
  • Customer business data (revenue, account values, customer lists)
  • Long-lived tokens of any kind (TTL >1 hour)

The JWT is a session pass — keep it minimal. Usage is attributed by (partnerOrgId, userId).

Advanced: Minting JWTs Without the SDK Helper

Use this path if you're on a non-Node/Next.js stack, want custom claim mapping, or need to inspect every JWT before signing. For everyone else, the helper-based default is shorter, safer, and stays in sync with verifier changes automatically.

app/api/marli-token/route.ts (manual)
import { importJWK, type JWK, SignJWT } from 'jose';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function POST(request: Request) {
  const session = await getYourSession(request);
  if (!session) {
    return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
  }

  const { id: userId } = session.user;
  const { id: partnerOrgId } = session.activeOrg;

  // Validate no colons in identity-bearing claims
  if (
    typeof partnerOrgId !== 'string' || partnerOrgId.length === 0 ||
    partnerOrgId.includes(':') ||
    typeof userId !== 'string' || userId.length === 0 ||
    userId.includes(':')
  ) {
    console.error('[marli-token] invalid claims');
    return NextResponse.json({ error: 'mint_failed' }, { status: 500 });
  }

  try {
    const privateJwk = JSON.parse(process.env.CP_PARTNER_PRIVATE_JWK!) as JWK;
    const kid = process.env.CP_PARTNER_KID!;
    const alg = process.env.CP_PARTNER_ALG ?? 'RS256';
    const slug = process.env.CP_PARTNER_SLUG!;

    const privateKey = await importJWK(privateJwk, alg);
    const now = Math.floor(Date.now() / 1000);

    const jwt = await new SignJWT({ partnerOrgId, userId })
      .setProtectedHeader({ alg, kid })
      .setIssuer(slug)
      .setIssuedAt(now)
      .setExpirationTime(now + 300)
      .sign(privateKey);

    return NextResponse.json({ jwt });
  } catch (err) {
    console.error('[marli-token] mint failed:', err);
    return NextResponse.json({ error: 'mint_failed' }, { status: 500 });
  }
}

What you give up by writing this by hand:

  • Forward compatibility — the helper updates with the SDK when new claim-format invariants are added.
  • Key caching — the helper imports the JWK once and caches it.
  • TTL clamping — the helper clamps to [60, 900] seconds.
  • Sanitized error envelope — guaranteed no leakage of error details to the browser.

Support

  • Technical questions during integration: your assigned LyticaLabs technical contact.
  • Production incidents: the emergency contact in your welcome packet.
  • Code-level reference: the apps/cp-test directory contains a complete worked-example partner integration.