feat(rsocials): wire social shapes into all cross-rApp systems

- Add GET /api/threads, /api/threads/:id, /api/campaigns, /api/campaigns/:id
  REST endpoints so other rApps can fetch rsocials data
- Register folk-social-thread/campaign/newsletter in MI triage panel,
  tool schema, system prompt, and KNOWN_TRIAGE_SHAPES
- Add rsocials MODULE_PORTS (threads-out, campaigns-out, post-published,
  campaign-data) and WIDGET_API to folk-rapp for embed/widget mode
- Add rsocials to folk-feed FEED_ENDPOINTS (threads, campaigns, newsletter)
  and normalize response arrays (threads, campaigns, drafts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-28 16:25:26 -07:00
parent 63a3f9b6c3
commit 9084de7adb
6 changed files with 82 additions and 2 deletions

View File

@ -174,6 +174,12 @@ export class FolkFeed extends FolkShape {
this.#feedData = data.nodes.slice(0, this.maxItems); this.#feedData = data.nodes.slice(0, this.maxItems);
} else if (data.flows) { } else if (data.flows) {
this.#feedData = data.flows.slice(0, this.maxItems); this.#feedData = data.flows.slice(0, this.maxItems);
} else if (data.threads) {
this.#feedData = data.threads.slice(0, this.maxItems);
} else if (data.campaigns) {
this.#feedData = data.campaigns.slice(0, this.maxItems);
} else if (data.drafts) {
this.#feedData = data.drafts.slice(0, this.maxItems);
} else { } else {
// Try to use the data as-is if it has array-like fields // Try to use the data as-is if it has array-like fields
const firstArray = Object.values(data).find(v => Array.isArray(v)); const firstArray = Object.values(data).find(v => Array.isArray(v));
@ -238,6 +244,12 @@ export class FolkFeed extends FolkShape {
itinerary: "trips", itinerary: "trips",
default: "trips", default: "trips",
}, },
socials: {
threads: "threads",
campaigns: "campaigns",
newsletter: "newsletter/drafts",
default: "threads",
},
}; };
const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule]; const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule];

View File

@ -451,6 +451,10 @@ const MODULE_PORTS: Record<string, PortDescriptor[]> = {
rwallet: [{ name: "balance-out", type: "number", direction: "output" }, rwallet: [{ name: "balance-out", type: "number", direction: "output" },
{ name: "transfer-trigger", type: "trigger", direction: "input" }, { name: "transfer-trigger", type: "trigger", direction: "input" },
{ name: "transfer-data", type: "json", direction: "input" }], { name: "transfer-data", type: "json", direction: "input" }],
rsocials: [{ name: "threads-out", type: "json", direction: "output" },
{ name: "campaigns-out", type: "json", direction: "output" },
{ name: "post-published", type: "trigger", direction: "output" },
{ name: "campaign-data", type: "json", direction: "output" }],
}; };
const DEFAULT_PORTS: PortDescriptor[] = [ const DEFAULT_PORTS: PortDescriptor[] = [
@ -552,6 +556,20 @@ const WIDGET_API: Record<string, { path: string; transform: (data: any) => Widge
}; };
}, },
}, },
rsocials: {
path: "/api/threads",
transform: (data) => {
const threads = data?.threads || [];
const campaigns = data?.campaignCount ?? 0;
return {
stat: `${threads.length} thread${threads.length !== 1 ? "s" : ""}`,
rows: threads.slice(0, 3).map((t: any) => ({
label: t.title || "Untitled",
value: `${t.tweets?.length || 0} tweets`,
})),
};
},
},
rnetwork: { rnetwork: {
path: "/api/graph", path: "/api/graph",
transform: (data) => { transform: (data) => {

View File

@ -29,6 +29,9 @@ const TOOL_HINTS: ToolHint[] = [
{ tagName: "folk-rapp", label: "rMeets", icon: "📹", keywords: ["meeting", "jitsi", "video", "meet", "conference", "rmeets"] }, { tagName: "folk-rapp", label: "rMeets", icon: "📹", keywords: ["meeting", "jitsi", "video", "meet", "conference", "rmeets"] },
{ tagName: "folk-workflow-block", label: "Workflow", icon: "⚙️", keywords: ["workflow", "automation", "block", "process"] }, { tagName: "folk-workflow-block", label: "Workflow", icon: "⚙️", keywords: ["workflow", "automation", "block", "process"] },
{ tagName: "folk-social-post", label: "Social Post", icon: "📣", keywords: ["social", "post", "twitter", "instagram", "campaign"] }, { tagName: "folk-social-post", label: "Social Post", icon: "📣", keywords: ["social", "post", "twitter", "instagram", "campaign"] },
{ tagName: "folk-social-thread", label: "Thread", icon: "🧵", keywords: ["thread", "tweetstorm", "twitter thread", "tweets", "multi-post"] },
{ tagName: "folk-social-campaign", label: "Campaign", icon: "📢", keywords: ["campaign", "launch", "marketing", "social campaign", "content plan"] },
{ tagName: "folk-social-newsletter", label: "Newsletter", icon: "📧", keywords: ["newsletter", "email", "mailout", "subscriber", "mailing list"] },
{ tagName: "folk-splat", label: "3D Gaussian", icon: "💎", keywords: ["3d", "splat", "gaussian", "point cloud"] }, { tagName: "folk-splat", label: "3D Gaussian", icon: "💎", keywords: ["3d", "splat", "gaussian", "point cloud"] },
{ tagName: "folk-drawfast", label: "Drawing", icon: "✏️", keywords: ["draw", "sketch", "whiteboard", "pencil"] }, { tagName: "folk-drawfast", label: "Drawing", icon: "✏️", keywords: ["draw", "sketch", "whiteboard", "pencil"] },
{ tagName: "folk-rapp", label: "rApp Embed", icon: "📦", keywords: ["rapp", "module", "embed", "app", "crm", "contacts", "pipeline", "companies"] }, { tagName: "folk-rapp", label: "rApp Embed", icon: "📦", keywords: ["rapp", "module", "embed", "app", "crm", "contacts", "pipeline", "companies"] },

View File

@ -16,6 +16,9 @@ const SHAPE_ICONS: Record<string, { icon: string; label: string }> = {
"folk-map": { icon: "🗺️", label: "Map" }, "folk-map": { icon: "🗺️", label: "Map" },
"folk-workflow-block": { icon: "⚙️", label: "Workflow" }, "folk-workflow-block": { icon: "⚙️", label: "Workflow" },
"folk-social-post": { icon: "📣", label: "Social Post" }, "folk-social-post": { icon: "📣", label: "Social Post" },
"folk-social-thread": { icon: "🧵", label: "Thread" },
"folk-social-campaign": { icon: "📢", label: "Campaign" },
"folk-social-newsletter": { icon: "📧", label: "Newsletter" },
"folk-choice-vote": { icon: "🗳️", label: "Vote" }, "folk-choice-vote": { icon: "🗳️", label: "Vote" },
"folk-prompt": { icon: "🤖", label: "AI Chat" }, "folk-prompt": { icon: "🤖", label: "AI Chat" },
"folk-image-gen": { icon: "🎨", label: "AI Image" }, "folk-image-gen": { icon: "🎨", label: "AI Image" },

View File

@ -203,6 +203,45 @@ routes.get("/api/feed", (c) =>
}), }),
); );
// ── API: Threads (read-only, for cross-rApp consumption) ──
routes.get("/api/threads", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const doc = ensureDoc(dataSpace);
const threads = Object.values(doc.threads || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return c.json({ threads, count: threads.length });
});
routes.get("/api/threads/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
});
// ── API: Campaigns (read-only, for cross-rApp consumption) ──
routes.get("/api/campaigns", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const doc = ensureDoc(dataSpace);
const campaigns = Object.values(doc.campaigns || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return c.json({ campaigns, count: campaigns.length });
});
routes.get("/api/campaigns/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const campaign = doc.campaigns?.[id];
if (!campaign) return c.json({ error: "Campaign not found" }, 404);
return c.json(campaign);
});
// ── Image API routes (server-side, need filesystem + FAL_KEY) ── // ── Image API routes (server-side, need filesystem + FAL_KEY) ──
routes.post("/api/threads/:id/image", async (c) => { routes.post("/api/threads/:id/image", async (c) => {

View File

@ -235,7 +235,8 @@ include action markers in your response. Each marker is on its own line:
Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions.
Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt,
folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block,
folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed. folk-social-post, folk-social-thread, folk-social-campaign, folk-social-newsletter,
folk-splat, folk-drawfast, folk-rapp, folk-feed.
## Transforms ## Transforms
When the user asks to align, distribute, or arrange selected shapes: When the user asks to align, distribute, or arrange selected shapes:
@ -319,6 +320,9 @@ analyze it and classify each distinct piece into the most appropriate canvas sha
- Locations / addresses / places folk-map (set query prop) - Locations / addresses / places folk-map (set query prop)
- Action items / TODOs / tasks folk-workflow-block (set label, blockType:"action" props) - Action items / TODOs / tasks folk-workflow-block (set label, blockType:"action" props)
- Social media content / posts folk-social-post (set content prop) - Social media content / posts folk-social-post (set content prop)
- Tweet threads / multi-post threads folk-social-thread (set title, tweets props)
- Marketing campaigns / content plans folk-social-campaign (set title, description, platforms props)
- Newsletters / email campaigns folk-social-newsletter (set subject, listName props)
- Decisions / polls / questions for voting folk-choice-vote (set question prop) - Decisions / polls / questions for voting folk-choice-vote (set question prop)
- Everything else (prose, notes, transcripts, summaries) folk-markdown (set content prop in markdown format) - Everything else (prose, notes, transcripts, summaries) folk-markdown (set content prop in markdown format)
@ -340,7 +344,8 @@ Return a JSON object with:
const KNOWN_TRIAGE_SHAPES = new Set([ const KNOWN_TRIAGE_SHAPES = new Set([
"folk-markdown", "folk-embed", "folk-image", "folk-bookmark", "folk-markdown", "folk-embed", "folk-image", "folk-bookmark",
"folk-calendar", "folk-map", "folk-calendar", "folk-map",
"folk-workflow-block", "folk-social-post", "folk-choice-vote", "folk-workflow-block", "folk-social-post", "folk-social-thread",
"folk-social-campaign", "folk-social-newsletter", "folk-choice-vote",
"folk-prompt", "folk-image-gen", "folk-slide", "folk-prompt", "folk-image-gen", "folk-slide",
]); ]);