🦄 Unicorn.Land ▸ docs/superpowers/specs/2026-06-16-oannes-learning-loop-design.md
updated 2026-06-16

Oannes Code Learning Loop — Design

Date: 2026-06-16 Status: Approved (brainstorming → spec) Project: Oannes Content Engine (oannes-engine/) Spec author: Joshua + Claude

Goal

Close the feedback loop on the Oannes Code content engine: collect per-post performance from all four platforms (TikTok, Instagram, YouTube, Facebook), learn what “sticks” per platform (they differ), and feed that back so the engine biases future content toward what works on each.

The engine already posts 2 reels/day via Blotato with a Discord approve-from-phone gate (launchd jobs generate-daily 9am+5pm, poll-approvals every 15 min). Each published post already persists its platform + URL tied to its vibe + artKey in renders/auto/state.json (commit a7edbe3). This is the foundation we build on.

Non-negotiable constraint

Do not break the running daily machine. Every change is additive and gated: if the new data files (weights.json, field.json) and the metrics table are absent or empty, the engine behaves exactly as it does today. New CLI commands and new launchd jobs are separate from the existing ones.

Key decisions (locked during brainstorming)

  1. Storage = Cambium Postgres, own table. oannes-engine is a standalone project, NOT part of the Cambium pnpm monorepo, and Cambium Memory is built for decaying agent observations, not numeric time-series. So the engine opens its own thin pg client to the same Cambium DB (DATABASE_URL already in .env) and owns a dedicated oannes_post_metrics table. Data lives in Cambium’s home and is queryable by Cambium later, but the engine stays decoupled — no monorepo entanglement, no risk to the daily machine.
  2. No Cambium autoresearch library. autoresearch is an in-memory iterative mutation engine that runs inside the Cambium daemon and would force monorepo coupling. Analysis is instead a lightweight in-engine module running SQL aggregations over our own table.
  3. Stickiness = engagement-rate composite, normalized per platform by reach, plus a continual field-research prior about what’s working per platform.
  4. v1 divergence = shared reel + per-platform text. One rendered video fans out to all four platforms (video bytes identical). Learning biases (a) the shared daily content decision and (b) the per-platform text knobs (caption, hashtags, YouTube title). Per-platform video variants are explicitly out of scope for v1.

Architecture

Four cooperating pieces, all under a new src/metrics/ module:

collect-metrics  →  oannes_post_metrics (Postgres, append-only snapshots)
field-research   →  renders/metrics/field.json   (per-platform priors)
analyze-metrics  →  renders/metrics/weights.json  (per-platform measured weights)
generation reads →  weights.json + field.json     (biases vibe/art/topic/hook + per-platform text)

1. Collector (src/metrics/)

blotato.ts — analytics client, same auth pattern as the existing publish client (blotato-api-key header, base https://backend.blotato.com/v2/), with injectable fetch for tests: - getPublishedPosts(apiKey, fetch?)GET /v2/published-posts to match our stored result.url → Blotato published-post id (and its latest analytics). - getPostAnalytics(apiKey, publishedPostId, fetch?)GET /v2/posts/:id/analytics{ views, likes, comments, shares, saves, reach, watchTimeSec }. Uses the published-post id, not the postSubmissionId. - Tolerant parsing: any metric the platform omits is null, not 0.

stickiness.ts — per-platform composite score. Pure function, fully unit-tested:

base   = (likes + 2·comments + 3·shares + saves) / max(reach, 1)
score  = base + watchTimeWeight · watchTimeRatio   (where watch-time reported)

Per-platform weight constants live in one exported table so they’re easy to tune. Returns null when reach is unknown (can’t normalize yet).

store.ts — thin pg client to DATABASE_URL. Idempotent CREATE TABLE IF NOT EXISTS oannes_post_metrics:

column type notes
id text PK ${claimId}:${platform}:${recordedAt}
claim_id text
platform text instagram | facebook | youtube | tiktok
published_post_id text Blotato published-post id
url text the live post URL
vibe text wonder | believer
art_key text energy, declassified, …
hook text the reel’s opening hook
topic text primary claim topic
views, likes, comments, shares, saves, reach integer nullable
watch_time_sec real nullable
stickiness real computed at snapshot time, nullable
recorded_at timestamptz snapshot time
created_at timestamptz default now()

Indexes: (platform), (claim_id, platform), (recorded_at), (platform, vibe, art_key). Append-only — each run inserts a fresh snapshot row per post per platform, so we keep the time-series (growth/decay).

Additive state change: PendingItem gains optional hook?: string and topic?: string, populated at generation time from the reel script (hook) and the claim (topics[0]). Backward-compatible — older items without them still load.

CLI collect-metrics: read state.json, for each PendingItem with status === "published", walk its results[] (platform + url), match url → Blotato published-post id, fetch analytics, compute stickiness, insert a snapshot row. Skips posts younger than a configurable floor (e.g. 24h) so early-zero noise is reduced. New daily launchd job (once/day — metrics don’t move minute-to-minute).

2. Field research (src/metrics/field.ts)

A periodic Claude call (reusing the existing runClaudeCli shell-out) that asks, per platform, what’s currently working for faceless short-form content — ideal length, hook styles, caption/hashtag conventions, posting cadence. Output is structured per-platform priors written to renders/metrics/field.json:

{
  "tiktok":    { "idealLengthSec": 0, "hookStyles": [], "hashtagCount": 0, "captionTips": "", "cadence": "", "notes": "" },
  "instagram": { ... }, "youtube": { ... }, "facebook": { ... },
  "researchedAt": "ISO-8601", "sources": []
}

CLI field-research; weekly launchd job. Honest framing: this is model-knowledge guidance refreshed on a cadence — strongest as a starting prior and sanity check. Our own measured stickiness overrides it as data accumulates.

3. Analysis (src/metrics/analyze.ts)

SQL over the snapshots: - Take the latest snapshot per (claim_id, platform). - Per platform, group by each dimension (vibe, art_key, topic) and compute mean stickiness + sample count. - Write renders/metrics/weights.json:

{
  "tiktok": {
    "vibe":   { "wonder": 0.7, "believer": 0.4 },
    "artKey": { "energy": 0.6, "declassified": 0.5 },
    "topic":  { "consciousness": 0.8, "uap": 0.3 },
    "sampleSize": 12
  },
  "instagram": { ... }, "youtube": { ... }, "facebook": { ... },
  "updatedAt": "ISO-8601"
}

Sample counts travel with the scores so the apply step knows how much to trust them. CLI analyze-metrics, run right after collect-metrics in the same daily job.

4. Apply — biasing generation

Two surfaces, because in v1 one rendered video serves all four platforms:

(a) Shared daily content decision (vibe / art / topic / hook — one choice/day): - Combine the per-platform measured scores into a single money-weighted blend (weighted toward the monetizable platforms — YouTube/TikTok lead, per the project’s “money fast” priority). Weights are a single tunable constant table. - Blend measured weights with field priors using a confidence blend: field-dominant when sampleSize is small, measured-dominant as it grows (e.g. w_measured = sampleSize / (sampleSize + K)). - Exploration stays alive — epsilon-greedy. Bias toward winners most days, but reserve a fraction of days for deliberate exploration so the loop never collapses to one vibe and stops learning. The existing no-consecutive-repeat art rule is never overridden. - Hook points: bias rotate/chooseByVibe toward higher-blended-stickiness vibes; bias chooseArtDirection toward higher art scores (within the existing candidate set and repeat rule); prefer higher-stickiness topics in claim selection.

(b) Per-platform text knobs (already diverge at publish; this is where measured + field data genuinely differ per platform): - Caption tone, hashtag set/count (respecting IG’s 5-tag cap), YouTube title style, target length fed to the scriptwriter prompt as guidance.

Gating: absent/empty weights.json and field.json → today’s exact rotation and text behavior. The loop only nudges once it has data.

Components & boundaries

Unit Purpose Depends on
metrics/blotato.ts fetch analytics injectable fetch
metrics/stickiness.ts pure scoring nothing
metrics/store.ts Postgres snapshots pg, DATABASE_URL
metrics/collect.ts orchestrate collection state, blotato, stickiness, store
metrics/field.ts per-platform priors runClaudeCli
metrics/analyze.ts aggregate → weights.json store (SQL)
metrics/apply.ts read weights+field, expose bias helpers weights.json, field.json

apply.ts exposes small pure helpers (blendedScore, pickVibeWithExploration, platformTextGuidance) consumed by the existing rotate/vibe/styles/scriptwriter code, so the bias logic is testable in isolation and the existing modules change minimally.

Error handling

Testing (TDD, tsc-gated via npm test)

Build order (three commits)

  1. Collector — table + store + blotato analytics + stickiness + collect-metrics CLI + launchd job. Daily machine untouched.
  2. Field + Analysisfield-research + analyze-metrics CLIs + their launchd schedule, producing field.json / weights.json.
  3. Apply — bias helpers wired behind the gate into rotate/vibe/styles/scriptwriter. This is the only commit that touches generation, and it’s a no-op until data exists.

Out of scope (v1)