Network Tab
Integration Guide
A complete, self-contained implementation guide for adding a Network tab to any self-hosted DiviDen open-core instance — enabling federation registration with dividen.ai, automated heartbeating, Bubble Store marketplace participation, and agent sync.
Audience: AI agents or human developers implementing federation on any single self-hosted DiviDen instance. Each instance (e.g., Pickleclaw, Inyo, Klaw Voice) is fully independent — it has its own database, its own platformToken, its own agents. This guide applies to one instance at a time. If you manage multiple instances, repeat this process separately for each one.
Table of Contents
1. Architecture Overview
DiviDen federation uses a central node architecture. The managed instance at dividen.ai serves as the central coordination node — the single source of truth for the network registry, the Bubble Store marketplace, and cross-instance relay routing. Self-hosted open-core instances connect to the central node to join the network. Each connected instance maintains full autonomy over its own data, agents, and operations while gaining access to shared network services.
┌─────────────────────────────────────┐
│ dividen.ai (Central Node) │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Network │ │ Bubble Store │ │
│ │ Registry │ │ Marketplace │ │
│ └──────────┘ └─────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Federation APIs /api/v2/fed/* │ │
│ │ Relay · Discovery · Sync │ │
│ └──────────────────────────────┘ │
└───────┬──────────┬──────────┬────────┘
│ │ │
┌────────┴┐ ┌────┴─────┐ ┌─┴────────┐
│ Instance │ │ Instance │ │ Instance │
│ Pickle- │ │ Inyo │ │ Klaw │
│ claw │ │ │ │ Voice │
└──────────┘ └──────────┘ └──────────┘
Each instance is FULLY AUTONOMOUS:
• Own database, own .env, own platformToken
• No shared state between instances
• Each registers independently with the
central node, even if run by the same dev
Per-instance lifecycle:
1. Connects → registers with central node
2. Heartbeats every 5–15 min
3. Enables marketplace → lists its agents
4. Syncs agents → appear in Bubble Storehttps://dividen.ai/api/v2/federation/*. Do NOT call your own instance's federation endpoints — that triggers the self-registration guard and returns HTTP 422.What the Network Tab Provides
2. Prerequisites
Prisma Schema Verification
Your schema should include these models. If missing, add them and run npx prisma db push.
model FederationConfig {
id String @id @default(cuid())
instanceName String @default("DiviDen Instance")
instanceUrl String? // Your public URL, e.g. https://cc.fractionalventure.partners
federationMode String @default("closed") // "closed" | "allowlist" | "open"
allowInbound Boolean @default(false)
allowOutbound Boolean @default(false)
requireApproval Boolean @default(true)
instanceApiKey String? // Your federation API key (for inbound auth)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model InstanceRegistry {
id String @id @default(cuid())
name String
baseUrl String @unique
apiKey String
isActive Boolean @default(false)
isTrusted Boolean @default(false)
lastSeenAt DateTime?
platformLinked Boolean @default(false)
platformToken String? // Token returned by dividen.ai on registration
marketplaceEnabled Boolean @default(false)
discoveryEnabled Boolean @default(false)
updatesEnabled Boolean @default(false)
version String?
userCount Int?
agentCount Int?
lastSyncAt DateTime?
metadata String? // JSON blob for capabilities, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}3. Environment Configuration
Add these variables to your .env file. The DIVIDEN_PLATFORM_TOKEN will be populated automatically after registration, but the other values must be set beforehand.
# ── Federation Configuration ─────────────────────────────────
# Your instance's public name (displayed on dividen.ai)
DIVIDEN_INSTANCE_NAME="Fractional Venture Partners"
# Your instance's public URL (must be HTTPS, must be reachable from dividen.ai)
DIVIDEN_INSTANCE_URL="https://cc.fractionalventure.partners"
# The central node URL (do not change unless using a different central node)
DIVIDEN_HUB_URL="https://dividen.ai"
# Platform token — populated after successful registration
# This is the Bearer token for all central node API calls
DIVIDEN_PLATFORM_TOKEN=""
# Instance ID — populated after successful registration
DIVIDEN_INSTANCE_ID=""
# Heartbeat interval in milliseconds (default: 5 minutes)
DIVIDEN_HEARTBEAT_INTERVAL="300000"
# Auto-sync agents to marketplace on startup (true/false)
DIVIDEN_AUTO_SYNC_AGENTS="false"DIVIDEN_PLATFORM_TOKEN is a long-lived Bearer token that authenticates your instance with dividen.ai. Treat it like a database password — never commit it to version control, never expose it in client-side code, never log it.DIVIDEN_PLATFORM_TOKEN and DIVIDEN_INSTANCE_ID during registration. Do NOT share tokens between instances. If you operate Pickleclaw, Inyo, and Klaw Voice, each one has its own .env with its own token. There is no cross-instance token reuse.4. Backend API Routes
Your instance needs three local API routes that the Network tab UI calls. These routes act as a proxy/orchestrator — they call the dividen.ai federation APIs on your behalf so that theDIVIDEN_PLATFORM_TOKEN never reaches the browser.
GET /api/network/status
Returns the current federation status of this instance. The Network tab polls this on mount.
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await prisma.federationConfig.findFirst();
const platformToken = process.env.DIVIDEN_PLATFORM_TOKEN;
const instanceId = process.env.DIVIDEN_INSTANCE_ID;
// Check if we have a valid registration
const isRegistered = !!(platformToken && instanceId);
// If registered, try to verify by checking our instance record on the central node
let hubStatus: 'connected' | 'pending' | 'disconnected' = 'disconnected';
let hubFeatures = { marketplace: false, discovery: false, relay: false, updates: false };
let lastSeen: string | null = null;
if (isRegistered) {
try {
// Use heartbeat as a status check
const res = await fetch(
`${process.env.DIVIDEN_HUB_URL || 'https://dividen.ai'}/api/v2/federation/heartbeat`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${platformToken}`,
},
body: JSON.stringify({
version: process.env.npm_package_version || '2.1.0',
}),
}
);
if (res.ok) {
const data = await res.json();
hubStatus = data.instance?.isActive ? 'connected' : 'pending';
hubFeatures = {
marketplace: data.instance?.marketplaceEnabled || false,
discovery: data.instance?.discoveryEnabled || false,
relay: true,
updates: data.instance?.updatesEnabled || false,
};
lastSeen = data.instance?.lastSeenAt || null;
} else if (res.status === 403) {
hubStatus = 'pending'; // Token valid but instance not approved
}
} catch {
// Central node unreachable — still registered but disconnected
hubStatus = isRegistered ? 'pending' : 'disconnected';
}
}
return NextResponse.json({
isRegistered,
hubStatus,
instanceId: instanceId || null,
instanceName: config?.instanceName || process.env.DIVIDEN_INSTANCE_NAME || null,
instanceUrl: config?.instanceUrl || process.env.DIVIDEN_INSTANCE_URL || null,
hubUrl: process.env.DIVIDEN_HUB_URL || 'https://dividen.ai',
features: hubFeatures,
lastSeen,
federationMode: config?.federationMode || 'closed',
allowInbound: config?.allowInbound || false,
allowOutbound: config?.allowOutbound || false,
});
}POST /api/network/register
Initiates registration with dividen.ai. Called once by the Network tab wizard.
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || (session.user as any).role !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}
const body = await req.json();
const {
instanceName,
instanceUrl,
enableMarketplace = true,
enableDiscovery = true,
enableUpdates = true,
} = body;
if (!instanceName || !instanceUrl) {
return NextResponse.json(
{ error: 'instanceName and instanceUrl are required' },
{ status: 400 }
);
}
const hubUrl = process.env.DIVIDEN_HUB_URL || 'https://dividen.ai';
// Ensure we have a federation config
let config = await prisma.federationConfig.findFirst();
if (!config) {
config = await prisma.federationConfig.create({
data: {
instanceName,
instanceUrl: instanceUrl.replace(//$/, ''),
federationMode: 'allowlist',
allowInbound: true,
allowOutbound: true,
requireApproval: true,
instanceApiKey: crypto.randomBytes(32).toString('hex'),
},
});
} else {
config = await prisma.federationConfig.update({
where: { id: config.id },
data: {
instanceName,
instanceUrl: instanceUrl.replace(//$/, ''),
allowInbound: true,
allowOutbound: true,
},
});
}
// ── Call dividen.ai /api/v2/federation/register ──
const registerRes = await fetch(`${hubUrl}/api/v2/federation/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: instanceName,
baseUrl: instanceUrl.replace(//$/, ''),
apiKey: config.instanceApiKey || 'self-register',
version: process.env.npm_package_version || '2.1.0',
capabilities: {
relay: true,
marketplace: enableMarketplace,
discovery: enableDiscovery,
updates: enableUpdates,
},
}),
});
if (!registerRes.ok) {
const err = await registerRes.json().catch(() => ({ error: 'Registration failed' }));
return NextResponse.json(
{ error: err.error || `HTTP ${registerRes.status}` },
{ status: registerRes.status }
);
}
const result = await registerRes.json();
// ── Store the platformToken in .env ──
// In production, you may want to store this in a secrets manager instead
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try { envContent = fs.readFileSync(envPath, 'utf-8'); } catch { /* file may not exist */ }
const setEnv = (key: string, value: string) => {
const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(envContent)) {
envContent = envContent.replace(regex, `${key}="${value}"`);
} else {
envContent += `\n${key}="${value}"`;
}
};
setEnv('DIVIDEN_PLATFORM_TOKEN', result.platformToken);
setEnv('DIVIDEN_INSTANCE_ID', result.instanceId);
fs.writeFileSync(envPath, envContent);
// Also set in process.env for immediate use
process.env.DIVIDEN_PLATFORM_TOKEN = result.platformToken;
process.env.DIVIDEN_INSTANCE_ID = result.instanceId;
} catch (envErr) {
console.error('[Network Register] Failed to write .env:', envErr);
// Non-fatal — return the token so the user can set it manually
}
// ── If marketplace enabled, activate it ──
if (enableMarketplace && result.platformToken) {
try {
await fetch(`${hubUrl}/api/v2/federation/marketplace-link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${result.platformToken}`,
},
body: JSON.stringify({ action: 'enable' }),
});
} catch { /* non-blocking */ }
}
return NextResponse.json({
success: true,
instanceId: result.instanceId,
platformToken: result.platformToken,
status: result.status, // 'pending_approval' or 'active'
endpoints: result.endpoints,
features: result.features,
message: result.message,
});
}POST /api/network/sync-agents
Pushes local agents to dividen.ai's Bubble Store. Called by the Agent Sync panel.
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || (session.user as any).role !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}
const platformToken = process.env.DIVIDEN_PLATFORM_TOKEN;
if (!platformToken) {
return NextResponse.json(
{ error: 'Not registered. Complete registration first.' },
{ status: 400 }
);
}
const hubUrl = process.env.DIVIDEN_HUB_URL || 'https://dividen.ai';
const body = await req.json();
const { agentIds } = body; // Optional: specific agent IDs to sync
// Fetch local agents (adapt this query to your schema)
const where: any = { isPublished: true };
if (agentIds?.length) where.id = { in: agentIds };
const localAgents = await prisma.marketplaceAgent.findMany({
where,
include: { developer: { select: { name: true, email: true } } },
});
if (localAgents.length === 0) {
return NextResponse.json({ error: 'No published agents found to sync' }, { status: 400 });
}
// Transform to the federation agent sync format
const agents = localAgents.map((a: any) => ({
id: a.id,
name: a.name,
description: a.description,
longDescription: a.longDescription || null,
category: a.category || 'general',
tags: a.tags || '',
endpointUrl: a.endpointUrl || `${process.env.DIVIDEN_INSTANCE_URL}/api/a2a`,
developerName: a.developer?.name || a.developerName || 'Unknown',
developerUrl: a.developerUrl || process.env.DIVIDEN_INSTANCE_URL || '',
inputFormat: a.inputFormat || 'text',
outputFormat: a.outputFormat || 'text',
pricingModel: a.pricingModel || 'free',
pricePerTask: a.pricePerTask || null,
version: a.version || '1.0.0',
supportsA2A: a.supportsA2A || false,
supportsMCP: a.supportsMCP || false,
agentCardUrl: a.agentCardUrl || null,
// Include capability metadata if available
capabilities: {
taskTypes: a.taskTypes || null,
contextInstructions: a.contextInstructions || null,
},
}));
// Push to dividen.ai
const syncRes = await fetch(`${hubUrl}/api/v2/federation/agents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${platformToken}`,
},
body: JSON.stringify({ agents }),
});
if (!syncRes.ok) {
const err = await syncRes.json().catch(() => ({ error: 'Sync failed' }));
return NextResponse.json(
{ error: err.error || `HTTP ${syncRes.status}`, code: err.code },
{ status: syncRes.status }
);
}
const syncResult = await syncRes.json();
return NextResponse.json(syncResult);
}
// GET — check sync status
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const platformToken = process.env.DIVIDEN_PLATFORM_TOKEN;
if (!platformToken) {
return NextResponse.json({ synced: false, agents: [] });
}
const hubUrl = process.env.DIVIDEN_HUB_URL || 'https://dividen.ai';
try {
const res = await fetch(`${hubUrl}/api/v2/federation/agents`, {
headers: { 'Authorization': `Bearer ${platformToken}` },
});
if (res.ok) {
const data = await res.json();
return NextResponse.json({ synced: true, ...data });
}
} catch { /* ignore */ }
return NextResponse.json({ synced: false, agents: [] });
}5. Network Tab UI Component
This is the core React component. It provides the full Network tab experience — status display, registration wizard, marketplace toggle, and agent sync panel. Drop this into your components directory.
--text-primary, --bg-primary, --border-color, --brand-primary, etc.) and Tailwind utility classes. If your fork uses a different theme system, adapt the class names accordingly.'use client';
import { useState, useEffect, useCallback } from 'react';
// ── Types ──────────────────────────────────────────────────────
interface NetworkStatus {
isRegistered: boolean;
hubStatus: 'connected' | 'pending' | 'disconnected';
instanceId: string | null;
instanceName: string | null;
instanceUrl: string | null;
hubUrl: string;
features: {
marketplace: boolean;
discovery: boolean;
relay: boolean;
updates: boolean;
};
lastSeen: string | null;
federationMode: string;
allowInbound: boolean;
allowOutbound: boolean;
}
interface RegisterResult {
success: boolean;
instanceId: string;
platformToken: string;
status: string;
endpoints: Record<string, string>;
features: Record<string, boolean>;
message: string;
}
interface SyncedAgent {
remoteId: string;
name: string;
status: string;
marketplaceUrl?: string;
}
type WizardStep = 'idle' | 'form' | 'registering' | 'success' | 'error';
// ── Component ──────────────────────────────────────────────────
export function NetworkTab() {
// Status
const [status, setStatus] = useState<NetworkStatus | null>(null);
const [loading, setLoading] = useState(true);
// Registration wizard
const [wizardStep, setWizardStep] = useState<WizardStep>('idle');
const [wizardError, setWizardError] = useState('');
const [registerResult, setRegisterResult] = useState<RegisterResult | null>(null);
const [regForm, setRegForm] = useState({
instanceName: '',
instanceUrl: '',
enableMarketplace: true,
enableDiscovery: true,
enableUpdates: true,
});
// Agent sync
const [syncedAgents, setSyncedAgents] = useState<SyncedAgent[]>([]);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<any>(null);
// ── Fetch status on mount ──
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/network/status');
if (res.ok) {
const data = await res.json();
setStatus(data);
// Pre-fill registration form from config
if (data.instanceName) setRegForm(f => ({ ...f, instanceName: data.instanceName }));
if (data.instanceUrl) setRegForm(f => ({ ...f, instanceUrl: data.instanceUrl }));
}
} catch (e) { console.error('Failed to fetch network status:', e); }
finally { setLoading(false); }
}, []);
const fetchSyncStatus = useCallback(async () => {
try {
const res = await fetch('/api/network/sync-agents');
if (res.ok) {
const data = await res.json();
if (data.agents) setSyncedAgents(data.agents);
}
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchStatus();
fetchSyncStatus();
}, [fetchStatus, fetchSyncStatus]);
// ── Registration handler ──
const handleRegister = async () => {
if (!regForm.instanceName || !regForm.instanceUrl) {
setWizardError('Instance name and URL are required.');
setWizardStep('error');
return;
}
setWizardStep('registering');
setWizardError('');
try {
const res = await fetch('/api/network/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regForm),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Registration failed' }));
throw new Error(err.error || `HTTP ${res.status}`);
}
const result = await res.json();
setRegisterResult(result);
setWizardStep('success');
// Refresh status
setTimeout(fetchStatus, 2000);
} catch (err: any) {
setWizardError(err.message || 'Registration failed');
setWizardStep('error');
}
};
// ── Agent sync handler ──
const handleSyncAgents = async () => {
setSyncing(true);
setSyncResult(null);
try {
const res = await fetch('/api/network/sync-agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // Sync all published agents
});
const data = await res.json();
setSyncResult(data);
if (res.ok) fetchSyncStatus();
} catch (err: any) {
setSyncResult({ error: err.message });
} finally { setSyncing(false); }
};
// ── Loading state ──
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-sm text-[var(--text-muted)] animate-pulse">
Loading network status...
</div>
</div>
);
}
const isConnected = status?.hubStatus === 'connected';
const isPending = status?.hubStatus === 'pending';
const isRegistered = status?.isRegistered;
return (
<div className="space-y-6 max-w-3xl">
{/* ── Status Banner ── */}
<div className={`p-5 rounded-xl border ${
isConnected
? 'bg-emerald-500/5 border-emerald-500/20'
: isPending
? 'bg-amber-500/5 border-amber-500/20'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)]'
}`}>
<div className="flex items-center gap-3 mb-2">
<div className={`w-3 h-3 rounded-full ${
isConnected ? 'bg-emerald-400 animate-pulse'
: isPending ? 'bg-amber-400 animate-pulse'
: 'bg-white/20'
}`} />
<span className="text-base font-semibold text-[var(--text-primary)]">
{isConnected ? 'Connected to DiviDen Network'
: isPending ? '⏳ Registration Pending Approval'
: 'Not Connected'}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
{isConnected
? `Connected to ${status?.hubUrl}. Your agents can participate in the Bubble Store marketplace.`
: isPending
? 'Your registration is pending admin approval on dividen.ai. Heartbeat is active — you will be notified when approved.'
: 'Register your instance with dividen.ai to join the network and access the Bubble Store, discovery feed, and relay network.'}
</p>
{isConnected && status?.features && (
<div className="flex gap-2 mt-3">
{status.features.marketplace && (
<span className="text-[10px] px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
Bubble Store
</span>
)}
{status.features.discovery && (
<span className="text-[10px] px-2 py-0.5 rounded bg-blue-500/10 text-blue-400">
Discovery
</span>
)}
{status.features.relay && (
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/10 text-purple-400">
Relay
</span>
)}
</div>
)}
</div>
{/* ── Registration Wizard (shown when not registered) ── */}
{!isRegistered && (
<div className="p-5 rounded-xl bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-1">
Join the DiviDen Network
</h3>
<p className="text-xs text-[var(--text-muted)] mb-4">
Register this instance with dividen.ai to access the agent marketplace,
network discovery, and cross-instance relay.
</p>
{wizardStep === 'idle' && (
<button
onClick={() => setWizardStep('form')}
className="w-full py-2.5 text-sm font-medium rounded-lg
bg-[var(--brand-primary)] text-white
hover:bg-[var(--brand-primary)]/80 transition-colors"
>
Start Registration →
</button>
)}
{wizardStep === 'form' && (
<div className="space-y-3">
<div>
<label className="text-[11px] text-[var(--text-muted)] block mb-1">
Instance Name
</label>
<input
value={regForm.instanceName}
onChange={e => setRegForm(f => ({ ...f, instanceName: e.target.value }))}
placeholder="e.g. Fractional Venture Partners"
className="w-full px-3 py-2 text-sm bg-[var(--bg-primary)]
border border-[var(--border-color)] rounded-lg
text-[var(--text-primary)]
placeholder:text-[var(--text-muted)]
focus:outline-none focus:border-[var(--brand-primary)]/50"
/>
</div>
<div>
<label className="text-[11px] text-[var(--text-muted)] block mb-1">
Public URL (must be reachable from the internet)
</label>
<input
value={regForm.instanceUrl}
onChange={e => setRegForm(f => ({ ...f, instanceUrl: e.target.value }))}
placeholder="https://cc.yourcompany.com"
className="w-full px-3 py-2 text-sm bg-[var(--bg-primary)]
border border-[var(--border-color)] rounded-lg
text-[var(--text-primary)] font-mono
placeholder:text-[var(--text-muted)]
focus:outline-none focus:border-[var(--brand-primary)]/50"
/>
</div>
{/* Feature toggles */}
<div className="space-y-2 pt-2">
<span className="text-[11px] text-[var(--text-muted)]">Enable features:</span>
{[
{ key: 'enableMarketplace' as const, label: 'Bubble Store', desc: 'List your agents on the marketplace' },
{ key: 'enableDiscovery' as const, label: 'Discovery', desc: 'Browse the network discovery feed' },
{ key: 'enableUpdates' as const, label: 'Updates', desc: 'Receive platform updates' },
].map(f => (
<label key={f.key} className="flex items-start gap-3 cursor-pointer p-2 rounded-lg hover:bg-[var(--bg-tertiary)]">
<input
type="checkbox"
checked={regForm[f.key]}
onChange={e => setRegForm(prev => ({ ...prev, [f.key]: e.target.checked }))}
className="mt-0.5 rounded"
/>
<div>
<span className="text-xs text-[var(--text-primary)]">{f.label}</span>
<p className="text-[10px] text-[var(--text-muted)]">{f.desc}</p>
</div>
</label>
))}
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setWizardStep('idle')}
className="flex-1 py-2 text-xs rounded-lg bg-[var(--bg-surface)]
border border-[var(--border-color)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={handleRegister}
disabled={!regForm.instanceName || !regForm.instanceUrl}
className="flex-1 py-2 text-xs font-medium rounded-lg
bg-[var(--brand-primary)] text-white
hover:bg-[var(--brand-primary)]/80 transition-colors
disabled:opacity-50"
>
Register with dividen.ai
</button>
</div>
</div>
)}
{wizardStep === 'registering' && (
<div className="text-center py-8">
<div className="text-3xl mb-3 animate-pulse"></div>
<p className="text-sm text-[var(--text-primary)]">Registering with dividen.ai...</p>
<p className="text-[11px] text-[var(--text-muted)] mt-1">
Exchanging keys and configuring federation
</p>
</div>
)}
{wizardStep === 'success' && registerResult && (
<div className="space-y-4">
<div className="text-center">
<div className="text-3xl mb-2"></div>
<h4 className="text-sm font-semibold text-emerald-400">
Registration {registerResult.status === 'pending_approval' ? 'Submitted' : 'Complete'}
</h4>
<p className="text-xs text-[var(--text-muted)] mt-1">
{registerResult.message}
</p>
</div>
{registerResult.status === 'pending_approval' && (
<div className="p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
<p className="text-xs text-amber-400">
⏳ Your registration is pending admin approval on dividen.ai.
The heartbeat service will start automatically. You'll
transition to "connected" once approved.
</p>
</div>
)}
<div className="p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-color)]">
<div className="text-[10px] text-[var(--text-muted)] font-semibold mb-2">
PLATFORM TOKEN (saved to .env automatically)
</div>
<div className="px-3 py-2 bg-black/30 rounded text-[10px] font-mono
text-[var(--text-secondary)] break-all select-all">
{registerResult.platformToken}
</div>
<p className="text-[10px] text-amber-400/80 mt-2">
This token was saved to your .env file as DIVIDEN_PLATFORM_TOKEN.
If your deployment system doesn't read .env at runtime, add it
to your secrets manager.
</p>
</div>
<div className="p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-color)]">
<div className="text-[10px] text-[var(--text-muted)] font-semibold mb-2">
AVAILABLE ENDPOINTS
</div>
<div className="space-y-1">
{Object.entries(registerResult.endpoints || {}).map(([key, url]) => (
<div key={key} className="flex items-center gap-2 text-[11px]">
<span className="text-emerald-400">→</span>
<span className="text-[var(--text-muted)] w-28">{key}:</span>
<span className="font-mono text-[var(--text-secondary)] text-[10px]">
{url}
</span>
</div>
))}
</div>
</div>
<button
onClick={() => { setWizardStep('idle'); fetchStatus(); }}
className="w-full py-2 text-xs rounded-lg bg-[var(--bg-surface)]
border border-[var(--border-color)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] transition-colors"
>
Done
</button>
</div>
)}
{wizardStep === 'error' && (
<div className="space-y-3">
<div className="text-center">
<div className="text-3xl mb-2"></div>
<h4 className="text-sm font-semibold text-red-400">Registration Failed</h4>
<p className="text-xs text-[var(--text-muted)] mt-1">{wizardError}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setWizardStep('idle')}
className="flex-1 py-2 text-xs rounded-lg bg-[var(--bg-surface)]
border border-[var(--border-color)] text-[var(--text-secondary)]"
>
Cancel
</button>
<button
onClick={() => setWizardStep('form')}
className="flex-1 py-2 text-xs font-medium rounded-lg
bg-[var(--brand-primary)] text-white"
>
Try Again
</button>
</div>
</div>
)}
</div>
)}
{/* ── Agent Sync Panel (shown when registered) ── */}
{isRegistered && (
<div className="p-5 rounded-xl bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
Agent Sync
</h3>
<p className="text-xs text-[var(--text-muted)]">
Push your published agents to the Bubble Store on dividen.ai
</p>
</div>
<button
onClick={handleSyncAgents}
disabled={syncing || !isConnected}
className="px-4 py-2 text-xs font-medium rounded-lg
bg-[var(--brand-primary)] text-white
hover:bg-[var(--brand-primary)]/80 transition-colors
disabled:opacity-50"
>
{syncing ? 'Syncing...' : '↑ Sync Agents'}
</button>
</div>
{!isConnected && (
<p className="text-xs text-amber-400 mb-3">
Agent sync requires an active connection. Wait for admin approval
on dividen.ai before syncing.
</p>
)}
{syncResult && (
<div className={`p-3 rounded-lg mb-3 ${
syncResult.error
? 'bg-red-500/5 border border-red-500/20'
: 'bg-emerald-500/5 border border-emerald-500/20'
}`}>
{syncResult.error ? (
<p className="text-xs text-red-400">{syncResult.error}</p>
) : (
<div>
<p className="text-xs text-emerald-400 mb-1">Sync complete</p>
<p className="text-[10px] text-[var(--text-muted)]">
{syncResult.created || 0} created · {syncResult.updated || 0} updated · {syncResult.skipped || 0} skipped
</p>
<p className="text-[10px] text-amber-400/80 mt-1">
All synced agents enter "pending_review" on dividen.ai.
A platform admin must approve each agent before it appears
in the Bubble Store.
</p>
</div>
)}
</div>
)}
{syncedAgents.length > 0 && (
<div className="space-y-1.5">
<div className="text-[10px] text-[var(--text-muted)] font-semibold">SYNCED AGENTS</div>
{syncedAgents.map((a, i) => (
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--bg-secondary)]">
<span className="text-xs text-[var(--text-primary)]">{a.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded ${
a.status === 'approved' ? 'bg-emerald-500/10 text-emerald-400'
: a.status === 'pending_review' ? 'bg-amber-500/10 text-amber-400'
: 'bg-[var(--bg-surface)] text-[var(--text-muted)]'
}`}>
{a.status}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* ── Connection Details (shown when registered) ── */}
{isRegistered && status && (
<div className="p-5 rounded-xl bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">
Connection Details
</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">Instance ID</span>
<span className="font-mono text-[var(--text-secondary)]">
{status.instanceId || '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">Central Node</span>
<span className="font-mono text-[var(--text-secondary)]">
{status.hubUrl}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">Status</span>
<span className={`font-medium ${
isConnected ? 'text-emerald-400'
: isPending ? 'text-amber-400'
: 'text-red-400'
}`}>
{status.hubStatus}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">Last Heartbeat</span>
<span className="text-[var(--text-secondary)]">
{status.lastSeen ? new Date(status.lastSeen).toLocaleString() : 'Never'}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">Federation Mode</span>
<span className="text-[var(--text-secondary)]">
{status.federationMode}
</span>
</div>
</div>
</div>
)}
</div>
);
}6. Heartbeat Service
The heartbeat tells dividen.ai that your instance is alive. Without it, your instance will show as "stale" on the central node and may be de-prioritized in discovery results. There are two approaches:
Option A: In-App Interval (Recommended for Next.js)
Set up a Next.js API route that gets called by a client-side interval or by a cron trigger. This is the simplest approach — add it to your existing app without external infrastructure.
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
/**
* POST /api/network/heartbeat
* Called internally (cron, setInterval, or from the Network tab)
* to send a heartbeat to dividen.ai.
*/
export async function POST() {
const platformToken = process.env.DIVIDEN_PLATFORM_TOKEN;
if (!platformToken) {
return NextResponse.json({ error: 'Not registered' }, { status: 400 });
}
const hubUrl = process.env.DIVIDEN_HUB_URL || 'https://dividen.ai';
// Gather instance stats
const [userCount, agentCount] = await Promise.all([
prisma.user.count().catch(() => 0),
prisma.marketplaceAgent.count({ where: { isPublished: true } }).catch(() => 0),
]);
try {
const res = await fetch(`${hubUrl}/api/v2/federation/heartbeat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${platformToken}`,
},
body: JSON.stringify({
version: process.env.npm_package_version || '2.1.0',
userCount,
agentCount,
status: { uptime: process.uptime?.() || 0 },
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error('[Heartbeat] Failed:', err);
return NextResponse.json({ success: false, error: err.error }, { status: res.status });
}
const data = await res.json();
return NextResponse.json({ success: true, ...data });
} catch (err: any) {
console.error('[Heartbeat] Network error:', err.message);
return NextResponse.json({ success: false, error: err.message }, { status: 500 });
}
}Then add a client-side heartbeat trigger in your app layout or Network tab:
// Call this once on app startup (e.g., in a useEffect in your root layout)
export function startHeartbeat() {
const interval = parseInt(process.env.NEXT_PUBLIC_HEARTBEAT_INTERVAL || '300000', 10);
const beat = () => {
fetch('/api/network/heartbeat', { method: 'POST' })
.catch(err => console.warn('[Heartbeat] Failed:', err.message));
};
// Initial heartbeat after 10s delay (let the app settle)
setTimeout(beat, 10000);
// Then every 5 minutes (or configured interval)
setInterval(beat, interval);
}Option B: External Cron Job
If you prefer not to run the heartbeat inside the app, use an external cron service.
# Every 5 minutes — heartbeat to dividen.ai
*/5 * * * * curl -s -X POST https://dividen.ai/api/v2/federation/heartbeat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DIVIDEN_PLATFORM_TOKEN" \
-d '{"version":"2.1.0"}' > /dev/null 2>&17. Agent Sync to Bubble Store
Agent sync pushes your local agents to dividen.ai so they appear in the Bubble Store marketplace. Key rules:
Agent Sync Payload Format
Each agent in the agents array should include:
interface AgentSyncPayload {
// ── Required ──
id: string; // Your local agent ID
name: string; // Agent display name
description: string; // Short description (1-2 sentences)
// ── Recommended ──
endpointUrl?: string; // A2A/MCP endpoint URL
developerName?: string; // Attribution name
developerUrl?: string; // Developer website
category?: string; // "research" | "coding" | "writing" | "analysis" |
// "operations" | "creative" | "general"
tags?: string; // Comma-separated
longDescription?: string;// Rich markdown description
version?: string; // Semver (e.g., "1.0.0")
// ── Pricing ──
pricingModel?: string; // "free" | "per_task" | "tiered" | "dynamic"
pricePerTask?: number; // $ per execution (for per_task model)
currency?: string; // ISO 4217 (default: "USD")
// ── Capabilities ──
inputFormat?: string; // "text" | "json" | "a2a"
outputFormat?: string; // "text" | "json" | "a2a"
supportsA2A?: boolean;
supportsMCP?: boolean;
agentCardUrl?: string; // .well-known/agent-card.json URL
capabilities?: {
taskTypes?: string; // JSON array or comma-separated
contextInstructions?: string;
};
}8. Full Registration Flow
Here's the complete sequence from initial setup to agents live in the Bubble Store:
Configure Instance Identity
Set DIVIDEN_INSTANCE_NAME and DIVIDEN_INSTANCE_URL in your .env. These identify your instance to dividen.ai.
The URL must be publicly reachable over HTTPS. dividen.ai will verify it can reach your instance.
Register with dividen.ai
Click "Start Registration" in the Network tab. This calls POST /api/v2/federation/register on dividen.ai.
Returns a platformToken and instanceId. The token is automatically saved to your .env file.
Wait for Admin Approval
New registrations start as pending_approval. A dividen.ai admin must set isActive: true and isTrusted: true.
You can verify your status by checking the Network tab — it will update from "Pending" to "Connected" once approved.
Heartbeat Starts Automatically
The heartbeat service sends periodic pings to dividen.ai, keeping your instance visible in the network.
Default interval is every 5 minutes. dividen.ai marks instances as stale if no heartbeat is received for 30 minutes.
Enable Marketplace (Automatic)
The registration wizard enables marketplace participation by default (calls /api/v2/federation/marketplace-link).
You can toggle this on/off later via the Network tab or by calling the marketplace-link endpoint directly.
Sync Agents
Click "Sync Agents" in the Network tab to push your published agents to the Bubble Store.
Each agent enters pending_review. Contact the dividen.ai admin (Jon) to approve your agents.
Agents Go Live
Once approved, your agents appear in the Bubble Store at dividen.ai/marketplace.
Users across the network can discover, install, and execute tasks against your agents. Cross-instance relay handles the communication.
9. Dashboard Integration
There are two places the Network tab should appear in the open-core UI:
A. Dashboard Center Panel — Network Group
In the main dashboard, add a "Network" button to the top tab bar. On dividen.ai, this group includes: Discover, Connections, Teams, Tasks, Federation Intel. For the open-core version, the Network group should include at minimum: Network Status (the NetworkTab component above) andDiscover (pulling from the central node's discovery feed).
// Add to your primary tabs or as a grouped dropdown:
const networkTabs = [
{ id: 'network-status', label: 'Network', icon: '' },
{ id: 'discover', label: 'Discover', icon: '' },
{ id: 'marketplace', label: 'Bubble Store', icon: '' },
];
// In the tab content area:
{activeTab === 'network-status' && <NetworkTab />}B. Settings → Network Tab
The Settings page should include a "Network" tab with the full federation configuration (federation mode, inbound/outbound toggles, API key management) plus the NetworkTab component for registration and status.
// In your settings tab list:
{ id: 'network', label: 'Network', icon: '' }
// In the tab content area:
{activeTab === 'network' && (
<div className="space-y-6">
<div className="panel">
<div className="panel-header">
<h2 className="font-semibold">Network</h2>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
Connect to the DiviDen network and manage federation settings.
</p>
</div>
<div className="panel-body">
<NetworkTab />
</div>
</div>
</div>
)}10. Troubleshooting
Registration returns HTTP 422 "Self-registration is not allowed"
You are calling your own instance's register endpoint instead of dividen.ai's. Make sure DIVIDEN_HUB_URL is set to https://dividen.ai and your /api/network/register route calls the central node URL, not localhost.
Registration returns HTTP 403 "API key mismatch"
Your instance was previously registered with a different apiKey. Ask the dividen.ai admin to delete the old instance record, or use the same apiKey that was used during initial registration.
Heartbeat returns 403
Your platformToken is invalid or expired. Re-register to get a new token. The /api/network/register route handles re-registration automatically.
Agent sync returns "marketplace_not_enabled"
Call /api/v2/federation/marketplace-link with action: "enable" first. The registration wizard does this automatically, but if you registered manually, you need to enable it separately.
Agent sync returns "instance_not_trusted"
Agent sync requires isTrusted: true on your instance record at dividen.ai. Ask the platform admin to trust your instance.
Agents synced but not visible in Bubble Store
All synced agents start in pending_review status. A dividen.ai admin must approve each agent. Contact the platform admin.
Status shows "pending" indefinitely
Your registration is waiting for admin approval on dividen.ai. The admin must set isActive: true on your instance. Keep heartbeating — the admin will see your instance is alive.
CORS errors when calling dividen.ai APIs
Federation APIs should be called from your backend (API routes), not from the browser. The Network tab calls your local /api/network/* routes, which then proxy to dividen.ai server-side.
11. API Reference (dividen.ai Endpoints)
All endpoints below are on https://dividen.ai. Auth header format: Authorization: Bearer <platformToken>
Register your instance. Returns platformToken and instanceId.
Send heartbeat. Updates lastSeenAt on the central node.
Enable/disable marketplace participation.
Push agents to the Bubble Store. Max 50 per call.
List agents synced from your instance.
Execute a task on a remote agent via the central node (cross-instance relay).
12. Verification Checklist
Use this checklist to verify your federation integration is working correctly:
Related: Federation Setup (curl guide) · Federation Protocol · Relay Specification · Agent Integration Kit
Download a plain-text copy of this page