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
All of this is on your Channel Partner page in Lytica (Account → Channel Partner) as soon as your account is active. There is no welcome packet to wait for.
| 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> |
| Signing keypair | Generated on the page; private half shown once | Sign the JWTs your backend mints |
| API key (optional) | 64-char hex string, shown once | Server-to-server org provisioning only |
| Approved domains | Origins where iframe loads — ask us to add yours | 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)
Domains are the one thing we still add for you. Until yours are approved the embed is served with frame-ancestors 'self', so browsers block the iframe on every host but the Lytica test page.
2. Nothing — Your Keypair Is Generated For You
You do not send us a key. Click Generate Keypair on your Channel Partner page: we create an RS256 keypair server-side, keep the public half against your partner record, and show you the private half once. Save it to your secrets manager as CP_PARTNER_PRIVATE_JWK along with the kid shown beside it.
To rotate, generate a second keypair, deploy the new private half, then revoke the old one from the keypair table. Revoking takes effect immediately, so ship the replacement first.
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:expiredback to your page (the SDK handles this).
Header
| Field | Value | Notes |
|---|---|---|
| alg | RS256 | The self-serve flow generates an RS256 keypair and there is no upload path, so this is the only algorithm you can hold a key for |
| 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
Optional. A customer org is created the first time you mint a JWT carrying its partnerOrgId, so nothing has to be registered ahead of the first chat. Call this when you want the row to exist earlier — with a display name of your choosing — typically from your own 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 we have never seen creates the org and proceeds — a typo in that claim silently opens a fresh, empty account rather than failing. The only ceiling is your maxCustomerOrgs limit, past which chat returns 429 PARTNER_CUSTOMER_ORG_LIMIT_EXCEEDED.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 Channel Partner page |
| 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_LIMIT_EXCEEDED | 429 | A new partnerOrgId would exceed your maxCustomerOrgs limit | Check for typo’d partnerOrgId claims, then contact LyticaLabs to raise the limit |
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 partner agreement.
- Code-level reference: the
apps/cp-testdirectory contains a complete worked-example partner integration.