Skip to main content

Overview

The real-time streaming layer enables the signature “live corkboard” effect where intelligence appears on screen as agents complete their research. Built on Convex real-time subscriptions, the system provides instant updates without WebSocket boilerplate. Location: frontend/convex/

Why Convex?

Zero WebSocket BoilerplateConvex eliminates the need to manually:
  • Set up WebSocket servers
  • Handle connection lifecycle
  • Manage subscription state
  • Implement reconnection logic
  • Synchronize client state
In a 24-hour hackathon, this saves hours of infrastructure work.

Traditional WebSocket Approach

// Traditional approach: ~200 lines of boilerplate
const ws = new WebSocket('ws://api/stream');
ws.onopen = () => ws.send(JSON.stringify({ subscribe: 'persons' }));
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  // Manually update React state
  setPersons(prev => [...prev, data]);
};
ws.onerror = () => { /* reconnection logic */ };
ws.onclose = () => { /* cleanup */ };

Convex Approach

// Convex approach: 1 line
const persons = useQuery(api.persons.listAll);
// Automatically updates when data changes ✨

Convex Schema

Location: frontend/convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  // Main person records
  persons: defineTable({
    name: v.string(),
    photoUrl: v.string(),
    confidence: v.number(),
    status: v.union(
      v.literal("identified"),
      v.literal("researching"),
      v.literal("synthesizing"),
      v.literal("complete")
    ),
    boardPosition: v.object({ x: v.number(), y: v.number() }),
    dossier: v.optional(
      v.object({
        summary: v.string(),
        title: v.optional(v.string()),
        company: v.optional(v.string()),
        workHistory: v.array(
          v.object({
            role: v.string(),
            company: v.string(),
            period: v.optional(v.string()),
          })
        ),
        education: v.array(
          v.object({
            school: v.string(),
            degree: v.optional(v.string()),
          })
        ),
        socialProfiles: v.object({
          linkedin: v.optional(v.string()),
          twitter: v.optional(v.string()),
          instagram: v.optional(v.string()),
          github: v.optional(v.string()),
          website: v.optional(v.string()),
        }),
        notableActivity: v.array(v.string()),
        conversationHooks: v.array(v.string()),
        riskFlags: v.array(v.string()),
      })
    ),
    createdAt: v.number(),
    updatedAt: v.number(),
  }),

  // Streaming intel fragments (agent results)
  intelFragments: defineTable({
    personId: v.id("persons"),
    source: v.string(),
    dataType: v.string(),
    content: v.string(),
    verified: v.boolean(),
    timestamp: v.number(),
  }).index("by_person", ["personId"]),

  // Connections between people
  connections: defineTable({
    personAId: v.id("persons"),
    personBId: v.id("persons"),
    relationshipType: v.string(),
    description: v.string(),
  })
    .index("by_person_a", ["personAId"])
    .index("by_person_b", ["personBId"]),

  // Live activity feed
  activityLog: defineTable({
    type: v.string(),
    message: v.string(),
    personId: v.optional(v.id("persons")),
    agentName: v.optional(v.string()),
    timestamp: v.number(),
  }),
});

Data Flow

Backend Pipeline

  ├─▶ Face detected
  │     └─▶ MongoDB: Raw image + embedding
  │     └─▶ Convex: Create capture record
  │           └─▶ Frontend: useQuery auto-updates ✨

  ├─▶ Person identified
  │     └─▶ MongoDB: Person metadata
  │     └─▶ Convex: Create person record (status: "identified")
  │           └─▶ Frontend: Paper spawns on corkboard ✨

  ├─▶ Agent completes
  │     └─▶ Convex: Create intel fragment
  │           └─▶ Frontend: Paper updates with new data ✨

  ├─▶ Synthesis complete
  │     └─▶ MongoDB: Full dossier stored
  │     └─▶ Convex: Update person.dossier (status: "complete")
  │           └─▶ Frontend: Paper fully revealed ✨

  └─▶ Connection detected
        └─▶ Convex: Create connection record
              └─▶ Frontend: Red string draws between papers ✨

Convex Queries

Location: frontend/convex/persons.ts

List All Persons

export const listAll = query({
  handler: async (ctx) => {
    return await ctx.db.query("persons").collect();
  },
});
Frontend usage:
const persons = useQuery(api.persons.listAll);
// Automatically re-renders when any person is added/updated

Get Person by ID

export const getById = query({
  args: { id: v.id("persons") },
  handler: async (ctx, { id }) => {
    return await ctx.db.get(id);
  },
});
Frontend usage:
const person = useQuery(api.persons.getById, { id: personId });
// Updates live as person data changes

Get Intel Fragments

// frontend/convex/intel.ts
export const byPerson = query({
  args: { personId: v.id("persons") },
  handler: async (ctx, { personId }) => {
    return await ctx.db
      .query("intelFragments")
      .withIndex("by_person", (q) => q.eq("personId", personId))
      .collect();
  },
});
Frontend usage:
const intel = useQuery(api.intel.byPerson, { personId });
// New fragments appear instantly as agents complete

Convex Mutations

Location: frontend/convex/persons.ts

Create Person

export const create = mutation({
  args: {
    name: v.string(),
    photoUrl: v.string(),
    confidence: v.number(),
    boardPosition: v.optional(v.object({ x: v.number(), y: v.number() })),
  },
  handler: async (ctx, { name, photoUrl, confidence, boardPosition }) => {
    const now = Date.now();
    
    // Auto-position if not provided
    const pos = boardPosition ?? {
      x: 100 + Math.random() * 800,
      y: 100 + Math.random() * 500,
    };
    
    const personId = await ctx.db.insert("persons", {
      name,
      photoUrl,
      confidence,
      status: "identified",
      boardPosition: pos,
      createdAt: now,
      updatedAt: now,
    });
    
    // Log to activity feed
    await ctx.db.insert("activityLog", {
      type: "identify",
      message: `Identified: ${name} (${Math.round(confidence * 100)}% confidence)`,
      personId,
      timestamp: now,
    });
    
    return personId;
  },
});

Update Status

export const updateStatus = mutation({
  args: {
    id: v.id("persons"),
    status: v.union(
      v.literal("identified"),
      v.literal("researching"),
      v.literal("synthesizing"),
      v.literal("complete")
    ),
  },
  handler: async (ctx, { id, status }) => {
    await ctx.db.patch(id, { status, updatedAt: Date.now() });
    
    const person = await ctx.db.get(id);
    if (person) {
      await ctx.db.insert("activityLog", {
        type: status === "complete" ? "complete" : "research",
        message: `${person.name}: status → ${status.toUpperCase()}`,
        personId: id,
        timestamp: Date.now(),
      });
    }
  },
});

Update Dossier

export const updateDossier = mutation({
  args: {
    id: v.id("persons"),
    dossier: v.object({...}),  // Full dossier schema
  },
  handler: async (ctx, { id, dossier }) => {
    await ctx.db.patch(id, {
      dossier,
      status: "complete",
      updatedAt: Date.now(),
    });
  },
});

Backend Integration

Location: backend/db/convex_gateway.py The backend uses the Convex Python client to push updates:
from convex import ConvexClient

class ConvexGateway(DatabaseGateway):
    def __init__(self, deployment_url: str):
        self._client = ConvexClient(deployment_url)
    
    async def store_person(self, person_id: str, data: dict):
        """Create or update person in Convex."""
        await self._client.mutation(
            "persons:store",
            {"data": {"person_id": person_id, **data}}
        )
    
    async def update_person(self, person_id: str, data: dict):
        """Update person status/dossier."""
        await self._client.mutation(
            "persons:update",
            {"person_id": person_id, "data": data}
        )
    
    async def store_intel_fragment(
        self,
        person_id: str,
        source: str,
        content: str,
        urls: list[str] | None = None,
        confidence: float = 1.0,
    ):
        """Stream intel fragment to frontend."""
        await self._client.mutation(
            "intel:create",
            {
                "personId": person_id,
                "source": source,
                "content": content,
            }
        )

Frontend Integration

Location: frontend/app/page.tsx

Corkboard Component

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

export default function Corkboard() {
  // Subscribe to all persons (live updates)
  const persons = useQuery(api.persons.listAll);
  
  // Subscribe to activity feed
  const activities = useQuery(api.activityLog.recent, { limit: 20 });
  
  if (persons === undefined) {
    return <LoadingSpinner />;
  }
  
  return (
    <div className="corkboard">
      {persons.map((person) => (
        <PersonCard
          key={person._id}
          person={person}
          // Card auto-updates when person data changes
        />
      ))}
      
      <ActivityFeed activities={activities} />
    </div>
  );
}

Person Card Component

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { motion } from "framer-motion";

function PersonCard({ person }) {
  // Subscribe to intel fragments for this person
  const intel = useQuery(api.intel.byPerson, { personId: person._id });
  
  // Subscribe to connections
  const connections = useQuery(api.connections.byPerson, { personId: person._id });
  
  return (
    <motion.div
      initial={{ scale: 0, rotate: -10 }}
      animate={{ scale: 1, rotate: 0 }}
      transition={{ type: "spring", stiffness: 260, damping: 20 }}
      style={{
        left: person.boardPosition.x,
        top: person.boardPosition.y,
      }}
      className="person-card"
    >
      <img src={person.photoUrl} alt={person.name} />
      <h3>{person.name}</h3>
      
      {/* Status badge updates live */}
      <StatusBadge status={person.status} />
      
      {/* Intel fragments stream in */}
      {intel?.map((fragment) => (
        <IntelFragment key={fragment._id} fragment={fragment} />
      ))}
      
      {/* Dossier appears when synthesis complete */}
      {person.dossier && <Dossier data={person.dossier} />}
    </motion.div>
  );
}

Animation Effects

Location: frontend/components/animations.tsx

Paper Spawn Animation

<motion.div
  initial={{ 
    scale: 0, 
    rotate: -10, 
    x: -100, 
    y: -100, 
    opacity: 0 
  }}
  animate={{ 
    scale: 1, 
    rotate: 0, 
    x: 0, 
    y: 0, 
    opacity: 1 
  }}
  transition={{ 
    type: "spring", 
    stiffness: 260, 
    damping: 20,
    delay: 0.1
  }}
>
  {/* Person card content */}
</motion.div>

Intel Fragment Reveal

<motion.div
  initial={{ opacity: 0, height: 0 }}
  animate={{ opacity: 1, height: "auto" }}
  transition={{ duration: 0.3, ease: "easeOut" }}
>
  <div className="intel-fragment">
    <span className="source-badge">{fragment.source}</span>
    <p>{fragment.content}</p>
  </div>
</motion.div>

Connection String Drawing

import { motion } from "framer-motion";

function ConnectionString({ personA, personB }) {
  const pathD = calculatePath(personA.boardPosition, personB.boardPosition);
  
  return (
    <svg className="connection-layer">
      <motion.path
        d={pathD}
        stroke="#DC2626"  // Red string
        strokeWidth={2}
        fill="none"
        initial={{ pathLength: 0, opacity: 0 }}
        animate={{ pathLength: 1, opacity: 1 }}
        transition={{ duration: 0.8, ease: "easeInOut" }}
      />
    </svg>
  );
}

Status Pulse Effect

function StatusBadge({ status }) {
  const isResearching = status === "researching";
  
  return (
    <motion.div
      className={`status-badge status-${status}`}
      animate={isResearching ? {
        scale: [1, 1.1, 1],
        opacity: [1, 0.8, 1],
      } : {}}
      transition={{
        repeat: Infinity,
        duration: 1.5,
      }}
    >
      {status.toUpperCase()}
    </motion.div>
  );
}

Activity Feed

Location: frontend/convex/activityLog.ts
export const recent = query({
  args: { limit: v.number() },
  handler: async (ctx, { limit }) => {
    return await ctx.db
      .query("activityLog")
      .order("desc")
      .take(limit);
  },
});
Frontend usage:
function ActivityFeed() {
  const activities = useQuery(api.activityLog.recent, { limit: 20 });
  
  return (
    <div className="activity-feed">
      {activities?.map((activity) => (
        <motion.div
          key={activity._id}
          initial={{ x: 100, opacity: 0 }}
          animate={{ x: 0, opacity: 1 }}
          transition={{ duration: 0.3 }}
          className="activity-item"
        >
          <span className="activity-type">{activity.type}</span>
          <span className="activity-message">{activity.message}</span>
          <span className="activity-time">
            {formatRelativeTime(activity.timestamp)}
          </span>
        </motion.div>
      ))}
    </div>
  );
}

Performance Characteristics

Update Latency:
  • Backend mutation → Frontend render: 50-150ms
  • Typical: Less than 100ms for most updates
  • Convex manages WebSocket connections automatically
Subscription Overhead:
  • No manual state management
  • No stale data issues
  • Automatic reconnection on network issues
  • Optimistic updates supported
Scaling:
  • Convex handles 1000+ concurrent subscribers per table
  • Efficient delta updates (only changed data sent)
  • Automatic batching of rapid updates

Deployment

Convex Setup:
1

Initialize Convex

cd frontend
npx convex dev
2

Deploy Schema

npx convex deploy
Schema automatically synced to production
3

Configure Backend

Add Convex deployment URL to backend config:
CONVEX_URL=https://your-project.convex.cloud
4

Deploy Frontend

npm run build
vercel --prod
Convex credentials automatically injected

Debugging

Convex Dashboard: Access real-time logs and data at https://dashboard.convex.dev Features:
  • Live query inspector
  • Mutation history
  • Table browser
  • Function logs
  • Performance metrics
Frontend Debugging:
// Log every Convex update
import { useQuery } from "convex/react";

function useDebugQuery(query, args) {
  const result = useQuery(query, args);
  
  useEffect(() => {
    console.log("Query updated:", { query: query.name, args, result });
  }, [result]);
  
  return result;
}

Next Steps

Architecture

Full system architecture and component integration

Agent Swarm

How agent results feed into the real-time stream

Build docs developers (and LLMs) love