28 KiB
Marketing Command Center — Frontend Architecture
What you want: A self-hosted web dashboard where you log in, see everything your agents produced, cherry-pick ads to push to Postiz, and drop into a Claude session to give feedback — all from the browser. Everything runs in Docker. No external services except Postiz and Claude API.
System Overview
┌─────────────────────────────────────────────────────────┐
│ YOUR BROWSER │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Campaign │ │ Asset │ │ Claude Chat │ │
│ │ Dashboard │ │ Gallery │ │ (feedback/edits) │ │
│ └──────┬───────┘ └────┬─────┘ └────────┬──────────┘ │
└─────────┼───────────────┼─────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS APP (Docker Container) │
│ │
│ API Routes: │
│ /api/campaigns /api/assets /api/claude │
│ /api/publish /api/agents /api/auth │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ SQLite │ │ Local │ │ Claude Agent SDK (TS) │ │
│ │ Database │ │ File │ │ Spawns Claude Code │ │
│ │ (Prisma) │ │ Storage │ │ as subprocess │ │
│ └──────────┘ └──────────┘ └───────────┬───────────┘ │
└──────────────────────────────────────────┼───────────────┘
│
┌──────────────────────┼──────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────┐
│ Postiz │ │ Tavily │ │Nextdoor│
│ (Docker) │ │ API │ │ API │
│ IG+TikTok│ │ Research │ │ Direct │
│ +Media │ └──────────┘ └────────┘
│ Hosting │
└──────────┘
Zero external services for core functionality. Postiz and your Next.js app both run in Docker on your machine. The only outbound calls are to Tavily (research), Nextdoor API (publishing), and Anthropic (Claude).
Tech Stack
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 15+ (App Router) | Full-stack React, API routes, SSR |
| UI | shadcn/ui + Tailwind CSS | Clean components, fast to build |
| Auth | NextAuth.js (Auth.js v5) | Self-contained — credentials provider, no external auth service |
| Database | SQLite via Prisma | Zero infrastructure — single file, runs in Docker volume |
| File Storage | Local filesystem (Docker volume) | Pipeline outputs stored locally, Postiz handles public hosting |
| Claude | Claude Agent SDK (TypeScript) | Spawn Claude sessions, stream responses |
| Publishing | Postiz (@postiz/node SDK) | Media upload + hosting + scheduling + IG/TikTok publishing |
| Realtime | Server-Sent Events (SSE) | Pipeline progress streaming — no Redis/WebSocket service needed |
| Deployment | Docker Compose | Everything self-hosted, single docker compose up |
Database Schema (SQLite via Prisma)
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./data/marketing.db"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
password String // bcrypt hashed
name String?
createdAt DateTime @default(now())
}
model Campaign {
id String @id @default(cuid())
name String
status String @default("draft") // draft, running, review, approved, published
prompt String? // the trigger prompt
platforms String // JSON array: ["instagram","tiktok","nextdoor"]
config String? // JSON: full campaign config from form
outputPath String? // local path to outputs/
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agentRuns AgentRun[]
assets Asset[]
claudeSessions ClaudeSession[]
}
model AgentRun {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
agentName String // trend-scout, research, script-writer, etc.
status String @default("pending") // pending, running, completed, failed
startedAt DateTime?
completedAt DateTime?
durationMs Int?
outputSummary String?
outputPath String?
tokenUsage Int?
error String?
assets Asset[]
createdAt DateTime @default(now())
}
model Asset {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
agentRunId String?
agentRun AgentRun? @relation(fields: [agentRunId], references: [id])
type String // image, video, copy, research, script
platform String? // instagram, tiktok, nextdoor, all
format String? // png, mp4, json, txt, html, md
filePath String // local path in outputs/
fileName String
dimensions String? // 1080x1080, 1080x1920, etc.
metadata String? // JSON: caption, hashtags, hook text, scene plan
status String @default("draft") // draft, approved, rejected, published
publishedTo String? // JSON array of platforms published to
postizPostId String? // ID from Postiz after scheduling
postizMediaId String? // media ID from Postiz upload
createdAt DateTime @default(now())
}
model ClaudeSession {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
sessionId String? // Claude Code session ID for --resume
messages String? // JSON array of conversation history
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TrendReport {
id String @id @default(cuid())
name String
filePath String // path to the HTML/JSON report
summary String? // brief description
createdAt DateTime @default(now())
}
Why SQLite over Postgres:
- Zero config — no separate database container
- Single file — easy to backup (just copy
marketing.db) - Fast enough for a single-user dashboard
- Runs anywhere Docker runs
- Prisma makes it trivial to switch to Postgres later if needed
Authentication (NextAuth.js — Self-Contained)
// lib/auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcryptjs";
import { prisma } from "./prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
return valid ? user : null;
},
}),
],
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
});
First-run setup: A seed script creates your admin user. No external auth provider needed.
// prisma/seed.ts
import bcrypt from "bcryptjs";
import { prisma } from "../lib/prisma";
async function main() {
await prisma.user.upsert({
where: { email: process.env.ADMIN_EMAIL! },
update: {},
create: {
email: process.env.ADMIN_EMAIL!,
password: await bcrypt.hash(process.env.ADMIN_PASSWORD!, 12),
name: "Admin",
},
});
}
main();
Real-Time Pipeline Progress (Server-Sent Events)
No Redis, no WebSocket server, no Supabase Realtime. Just native SSE from a Next.js API route:
// app/api/campaigns/[id]/stream/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Subscribe to pipeline events for this campaign
const unsubscribe = pipelineEvents.subscribe(params.id, (event) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
});
// Keep connection alive
const keepAlive = setInterval(() => {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
}, 15000);
req.signal.addEventListener("abort", () => {
unsubscribe();
clearInterval(keepAlive);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Frontend hook:
// hooks/usePipelineProgress.ts
export function usePipelineProgress(campaignId: string) {
const [agents, setAgents] = useState<AgentRun[]>([]);
useEffect(() => {
const source = new EventSource(`/api/campaigns/${campaignId}/stream`);
source.onmessage = (event) => {
const data = JSON.parse(event.data);
setAgents((prev) => updateAgentStatus(prev, data));
};
return () => source.close();
}, [campaignId]);
return agents;
}
Claude Integration
// lib/claude.ts
import { spawn } from "child_process";
import { EventEmitter } from "events";
export const pipelineEvents = new EventEmitter();
export async function launchPipeline(campaignId: string, prompt: string, cwd: string) {
// Update campaign status
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "running" },
});
const claude = spawn("claude", [
"-p", prompt,
"--output-format", "stream-json",
"--verbose",
"--allowedTools", "Read,Edit,Write,Bash,Grep,Glob",
], { cwd, env: { ...process.env } });
claude.stdout.on("data", async (chunk) => {
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// Detect agent starts/completions from tool use events
const agentEvent = parseAgentEvent(event);
if (agentEvent) {
// Save to database
await upsertAgentRun(campaignId, agentEvent);
// Broadcast to SSE clients
pipelineEvents.emit(campaignId, agentEvent);
}
// Detect new files created
const fileEvent = parseFileCreatedEvent(event);
if (fileEvent) {
await createAsset(campaignId, fileEvent);
pipelineEvents.emit(campaignId, { type: "asset_created", ...fileEvent });
}
} catch {}
}
});
claude.on("close", async (code) => {
const status = code === 0 ? "review" : "failed";
await prisma.campaign.update({
where: { id: campaignId },
data: { status },
});
pipelineEvents.emit(campaignId, { type: "pipeline_complete", status });
});
}
// Claude chat session for feedback
export async function sendChatMessage(
sessionId: string | null,
message: string,
cwd: string
): AsyncGenerator<string> {
const args = ["-p", message, "--output-format", "stream-json", "--verbose"];
if (sessionId) args.push("--resume", sessionId);
args.push("--allowedTools", "Read,Edit,Write,Bash,Grep,Glob");
const claude = spawn("claude", args, { cwd, env: { ...process.env } });
// Stream text deltas back
for await (const chunk of claude.stdout) {
yield chunk.toString();
}
}
Postiz Integration (Media Upload + Publishing)
// lib/postiz.ts
import { Postiz } from "@postiz/node";
import fs from "fs";
import path from "path";
import FormData from "form-data";
const postiz = new Postiz({
apiKey: process.env.POSTIZ_API_KEY!,
baseUrl: process.env.POSTIZ_URL!,
});
// Upload a local file to Postiz and get back a hosted URL + media ID
export async function uploadToPostiz(filePath: string) {
const form = new FormData();
form.append("file", fs.createReadStream(filePath));
const res = await fetch(`${process.env.POSTIZ_URL}/public/v1/upload`, {
method: "POST",
headers: { Authorization: process.env.POSTIZ_API_KEY! },
body: form,
});
const { id, path: publicUrl } = await res.json();
return { mediaId: id, publicUrl };
}
// Push an approved asset to Postiz for scheduling
export async function pushToPostiz(asset: Asset, scheduledAt: string) {
// Upload media first
const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath);
// Get the right Postiz integration (channel) ID
const integrations = await postiz.integrations.list();
const integration = integrations.find(
(i) => i.providerIdentifier === asset.platform
);
if (!integration) throw new Error(`No Postiz channel for ${asset.platform}`);
// Parse caption from asset metadata
const metadata = JSON.parse(asset.metadata || "{}");
// Create the scheduled post
const post = await postiz.posts.create({
type: "schedule",
date: scheduledAt,
integration: integration.id,
content: metadata.caption || "",
image: [{ id: mediaId, path: publicUrl }],
});
return post;
}
File Serving (Local Assets → Browser Preview)
Your pipeline outputs (images, videos) live on the local filesystem. The dashboard needs to serve them for preview:
// app/api/files/[...path]/route.ts
import { readFile } from "fs/promises";
import path from "path";
import { auth } from "@/lib/auth";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || "/app/pipeline";
export async function GET(req: Request, { params }: { params: { path: string[] } }) {
// Auth check — only logged-in users can view files
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const filePath = path.join(PIPELINE_ROOT, ...params.path);
// Security: prevent path traversal
if (!filePath.startsWith(PIPELINE_ROOT)) {
return new Response("Forbidden", { status: 403 });
}
try {
const buffer = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const contentType =
{ ".png": "image/png", ".jpg": "image/jpeg", ".mp4": "video/mp4",
".html": "text/html", ".json": "application/json", ".md": "text/markdown",
".txt": "text/plain", ".css": "text/css" }[ext] || "application/octet-stream";
return new Response(buffer, { headers: { "Content-Type": contentType } });
} catch {
return new Response("Not found", { status: 404 });
}
}
Asset gallery uses: <img src="/api/files/outputs/campaign_123/ads/instagram_feed.png" />
Video preview uses: <video src="/api/files/outputs/campaign_123/video/tiktok_ad.mp4" />
Docker Setup
Dockerfile (Next.js App)
# Dockerfile
FROM node:20-alpine AS base
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install Playwright browsers (for ad creative generation)
RUN npx playwright install chromium --with-deps
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Copy the marketing pipeline
COPY pipeline/ ./pipeline/
EXPOSE 3000
CMD ["node", "server.js"]
Docker Compose (Full Stack)
# docker-compose.yml
version: "3.8"
services:
# Your marketing dashboard + pipeline
app:
build: .
ports:
- "3000:3000"
environment:
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- TAVILY_API_KEY=${TAVILY_API_KEY}
- POSTIZ_URL=http://postiz:5000
- POSTIZ_API_KEY=${POSTIZ_API_KEY}
- NEXTDOOR_API_TOKEN=${NEXTDOOR_API_TOKEN}
- NEXTDOOR_ADVERTISER_ID=${NEXTDOOR_ADVERTISER_ID}
- PIPELINE_ROOT=/app/pipeline
volumes:
- app-data:/app/prisma/data # SQLite database
- pipeline-outputs:/app/pipeline/outputs # Generated content
- pipeline-knowledge:/app/pipeline/knowledge # Brand files (editable)
depends_on:
- postiz
# Self-hosted Postiz (social media scheduling + media hosting)
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postiz-db:5432/postiz
- REDIS_URL=redis://redis:6379
- NEXT_PUBLIC_BACKEND_URL=http://postiz:5000
- STORAGE_PROVIDER=local
- UPLOAD_DIRECTORY=/uploads
volumes:
- postiz-uploads:/uploads
- postiz-config:/config
depends_on:
- postiz-db
- redis
# Postiz database (Postgres — required by Postiz, not by your app)
postiz-db:
image: postgres:16-alpine
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=postiz
volumes:
- postiz-pgdata:/var/lib/postgresql/data
# Postiz cache (Redis — required by Postiz, not by your app)
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
app-data: # Your SQLite database
pipeline-outputs: # All generated content (images, videos, reports)
pipeline-knowledge: # Brand identity, platform guidelines, campaign docs
postiz-uploads: # Postiz media storage
postiz-config: # Postiz configuration
postiz-pgdata: # Postiz database
redis-data: # Postiz cache
Environment File
# .env
# --- Your App ---
NEXTAUTH_SECRET=generate-a-random-32-char-string-here
ADMIN_EMAIL=you@yourdomain.com
ADMIN_PASSWORD=your-secure-password
# --- Claude (pipeline engine) ---
ANTHROPIC_API_KEY=sk-ant-your-key
# --- Research ---
TAVILY_API_KEY=tvly-your-key
# --- Postiz (publishing + media hosting) ---
POSTIZ_API_KEY=your-postiz-key
POSTGRES_PASSWORD=a-secure-password-for-postiz-db
# --- Nextdoor (direct API — Postiz doesn't support it) ---
NEXTDOOR_API_TOKEN=your-token
NEXTDOOR_ADVERTISER_ID=your-id
Startup
# First run
docker compose up -d
docker compose exec app npx prisma db push # Create tables
docker compose exec app npx prisma db seed # Create admin user
# Open in browser
open http://localhost:3000
After first run, just docker compose up -d and everything starts.
Project Folder Structure
marketing-command-center/
├── app/ # Next.js App Router
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx # Sidebar + header
│ │ ├── page.tsx # Dashboard home
│ │ ├── campaigns/
│ │ │ ├── page.tsx # Campaign list
│ │ │ ├── new/page.tsx # New campaign form
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Pipeline progress tab
│ │ │ ├── assets/page.tsx # Asset gallery tab
│ │ │ └── chat/page.tsx # Claude feedback tab
│ │ ├── assets/page.tsx # Global asset library
│ │ ├── trends/page.tsx # Trend reports
│ │ └── queue/page.tsx # Postiz queue / calendar
│ └── api/
│ ├── auth/[...nextauth]/route.ts
│ ├── campaigns/
│ │ ├── route.ts # CRUD
│ │ ├── [id]/
│ │ │ ├── launch/route.ts # Trigger pipeline
│ │ │ └── stream/route.ts # SSE pipeline progress
│ ├── assets/
│ │ └── route.ts # Asset CRUD + filtering
│ ├── claude/
│ │ └── route.ts # Chat sessions (stream)
│ ├── postiz/
│ │ └── route.ts # Upload + schedule to Postiz
│ └── files/
│ └── [...path]/route.ts # Serve local pipeline files
├── components/
│ ├── ui/ # shadcn/ui
│ ├── campaign-card.tsx
│ ├── asset-gallery.tsx
│ ├── asset-card.tsx
│ ├── pipeline-progress.tsx
│ ├── claude-chat.tsx
│ ├── postiz-push-modal.tsx
│ ├── trend-report-viewer.tsx
│ └── campaign-form.tsx
├── hooks/
│ ├── usePipelineProgress.ts # SSE hook
│ └── useClaudeChat.ts # Chat streaming hook
├── lib/
│ ├── auth.ts # NextAuth config
│ ├── prisma.ts # Prisma client
│ ├── claude.ts # Claude SDK wrapper
│ ├── postiz.ts # Postiz SDK wrapper
│ └── utils.ts
├── prisma/
│ ├── schema.prisma # Database schema
│ ├── seed.ts # Admin user creation
│ └── data/ # SQLite file lives here
│ └── marketing.db
├── pipeline/ # Your marketing pipeline
│ ├── .claude/
│ ├── skills/
│ │ ├── trend-scout/SKILL.md
│ │ ├── marketing-research-agent/SKILL.md
│ │ ├── script-writer/SKILL.md
│ │ ├── ad-creative-designer/SKILL.md
│ │ ├── video-ad-producer/SKILL.md
│ │ ├── copywriter-agent/SKILL.md
│ │ └── distribution-agent/SKILL.md
│ ├── knowledge/
│ │ ├── brand_identity.md
│ │ ├── platform_guidelines.md
│ │ └── product_campaign.md
│ ├── assets/ # Brand images, logos
│ ├── outputs/ # All generated content
│ ├── remotion-ad/ # Remotion video project
│ ├── CLAUDE.md
│ └── package.json
├── public/
├── .env
├── docker-compose.yml
├── Dockerfile
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
Pages & Features
1. Dashboard Home (/)
- Active campaigns with status badges (Running / Review Needed / Published)
- Recent trend reports from Trend Scout
- Quick stats: assets created, published this week, pending review
- "New Campaign" button
2. Campaign View (/campaigns/[id])
Pipeline Tab — real-time agent execution progress via SSE:
✅ Trend Scout 12s "5 trending hooks found"
✅ Research Agent 2m "Market report generated"
✅ Script Writer 3m "15 scripts written"
🔄 Ad Creative 1m... "Generating static ads..."
⏳ Video Producer — waiting
⏳ Copywriter — waiting
⏳ Distribution — waiting (pending approval)
Assets Tab — gallery of everything produced:
- Grid of image/video previews with platform badges
- Filter by: platform, type (image/video/copy), status
- Click to expand: full preview + caption + metadata
- Approve / Reject buttons per asset
- "Push to Postiz" on approved assets
Claude Tab — live feedback chat:
- Streaming chat interface connected to Claude Code
- Session persists via
--resume - Type: "make it snarkier", "add competitor X to research", "regenerate hook B"
- New/updated assets auto-appear in the gallery
3. Asset Library (/assets)
- All assets across all campaigns
- Search by hook text, caption, platform
- Bulk approve / reject / push to Postiz
4. Trend Reports (/trends)
- Auto-generated trend reports (iframe HTML viewer)
- "Create Campaign from This Report" button
5. Postiz Queue (/queue)
- Everything pushed to Postiz with status (scheduled / published / failed)
- Calendar view of upcoming posts
- Link to Postiz dashboard at
localhost:5000for visual calendar
What Runs Where
| Component | Runs In | External Calls |
|---|---|---|
| Next.js dashboard | Docker (your machine) | None — fully self-contained |
| SQLite database | Docker volume | None |
| Auth (NextAuth) | Docker (your machine) | None |
| File serving | Docker (your machine) | None |
| Pipeline progress (SSE) | Docker (your machine) | None |
| Claude Code sessions | Docker (spawns subprocess) | → Anthropic API |
| Research agent | Claude subprocess | → Tavily API |
| Image generation | Claude subprocess | → NanoBanana (Gemini API) |
| Video rendering | Claude subprocess | → Local Remotion render |
| Postiz | Docker (your machine) | → Instagram API, TikTok API |
| Nextdoor publishing | Your app API route | → Nextdoor API |
Outbound network calls: Anthropic, Tavily, Gemini (NanoBanana), Instagram (via Postiz), TikTok (via Postiz), Nextdoor. Everything else is local.
Build Phases
Phase 1: Scaffold (Week 1)
- Next.js + shadcn/ui + Tailwind setup
- Prisma + SQLite schema
- NextAuth credentials provider
- Docker Compose with Postiz
- Basic dashboard layout with sidebar nav
Phase 2: Pipeline Integration (Week 2)
- Campaign CRUD pages
- Campaign launch form → spawns Claude Code subprocess
- SSE pipeline progress streaming
- File serving API route for asset preview
- Auto-scan outputs folder → populate assets table
Phase 3: Asset Gallery + Claude Chat (Week 3)
- Asset gallery with grid view + filtering
- Image/video inline preview
- Approve / reject workflow
- Claude chat component with streaming
- Session persistence (--resume)
Phase 4: Postiz Publishing (Week 4)
- Postiz upload integration
- "Push to Postiz" modal (select → schedule → confirm)
- Queue page with Postiz status sync
- Nextdoor direct API publishing
- Trend report viewer
Phase 5: Polish (Week 5)
- Bulk actions (approve all, reject all, push batch)
- Asset search
- Mobile-responsive
- Trend Scout auto-scheduling (cron in Docker)
- Error handling + retry logic