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:
- The
<MarliWidget>SDK in your product renders an iframe pointing at our embed URL. - The iframe posts a
marli:readymessage to your page; your page asks your backend to mint a short-lived signed JWT and posts it back asmarli:bootstrap. - The iframe sends every chat request to our gateway with the JWT in the
Authorizationheader. 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":
| Item | What it is | Use |
|---|---|---|
| Partner slug | Kebab-case identifier (e.g. iscream) | JWT iss claim + embed URL path |
| Embed URL | https://alpha.lyticalabs.ai/embed/cp/{slug} | The baseUrl for <MarliWidget> |
| API key | 64-char hex string, shown once | Server-to-server org provisioning only |
| Approved domains | Origins where iframe loads | CSP frame-ancestors allow-list |
| Branding display name | Label shown in the iframe (e.g. "iScream") | Connecting/error UX |
| Sample mint endpoint | Working Node/Next.js example | Backend reference |
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.
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);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 + exportJWK3. 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:expiredback to your page (the SDK handles this).
Header
| Field | Value | Notes |
|---|---|---|
| alg | RS256 or ES256 | Must match the alg of the JWK you registered |
| kid | Your chosen key ID | Required so LyticaLabs picks the right verification key |
Payload (exactly five claims — additional claims are ignored)
| Claim | Type | Notes |
|---|---|---|
| iss | string | Your partner slug (e.g. "iscream") |
| iat | number | Issued-at, Unix seconds |
| exp | number | Expiry, Unix seconds. Recommended TTL: 5–15 minutes. |
| partnerOrgId | string | Stable customer ID. Must match a provisioned customer-org row. |
| userId | string | Stable user ID. Unique within one partnerOrgId. |
// 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:
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.
| Status | Body | Meaning |
|---|---|---|
| 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-sdkMount it in any client component:
'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).
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
| Code | When | Recovery |
|---|---|---|
| BASE_URL_INVALID | Construction; bare hostname or non-https URL | Fix the prop |
| CONTAINER_INVALID | Vanilla path; not a real DOM element | Pass a real element |
| PARTNER_SLUG_INVALID | Slug isn't kebab-case | Use the slug from your welcome packet |
| JWT_MINT_FAILED | Your /api/marli-token returned non-200 | Check your mint endpoint |
| IFRAME_DESTROYED | bootstrap() called after destroy() | Construct a new instance |
Gateway-Level (Inside the Iframe)
| Code | HTTP | Meaning | Action |
|---|---|---|---|
| PARTNER_JWT_EXPIRED | 401 | JWT past its exp | None — SDK auto-refreshes |
| PARTNER_JWT_INVALID_FORMAT | 401 | JWT not a valid JWS string | Check mint endpoint output |
| PARTNER_JWT_KID_NOT_FOUND | 401 | kid doesn't match any active JWK | Verify CP_PARTNER_KID matches registration |
| PARTNER_JWT_SIGNATURE_INVALID | 401 | Signature doesn't verify | Verify private/public JWK pair matches |
| PARTNER_JWT_MISSING_CLAIMS | 400 | Required claim missing or contains : | Check payload — all five claims required, no colons |
| PARTNER_SUSPENDED | 403 | Partner account suspended | Contact LyticaLabs |
| PARTNER_CUSTOMER_ORG_NOT_FOUND | 404 | partnerOrgId not provisioned | Run 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.aiWe 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.
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-testdirectory contains a complete worked-example partner integration.