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)
- Storage = Cambium Postgres, own table.
oannes-engineis 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 thinpgclient to the same Cambium DB (DATABASE_URLalready in.env) and owns a dedicatedoannes_post_metricstable. 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. - 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.
- Stickiness = engagement-rate composite, normalized per platform by reach, plus a continual field-research prior about what’s working per platform.
- 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
- Blotato analytics errors / missing posts: log and skip that post; never throw out of the daily collection.
- No
DATABASE_URLor DB unreachable:collect-metrics/analyze-metricslog a clear message and exit non-fatally; generation still runs (gated fallback). - Malformed
weights.json/field.json: caught, treated as absent (fall back to today’s behavior). - Posts younger than the floor or with unknown reach: skipped from scoring (no fake zeros polluting the averages).
Testing (TDD, tsc-gated via npm test)
stickiness.ts: composite math, per-platform weights, null-reach handling.blotato.ts: analytics parsing + url→id matching with mockedfetch.store.ts: SQL builder / row shape (mock pg or a thin query unit; no live DB in CI).analyze.ts: aggregation logic over fixture snapshots → expected weights.apply.ts: confidence blend, money-weighting, epsilon-greedy determinism (seeded), gating fallback when files absent.- Follow existing vitest patterns (
tests/*.test.ts, injectedfetch, tmpdir state).
Build order (three commits)
- Collector — table + store + blotato analytics + stickiness +
collect-metricsCLI + launchd job. Daily machine untouched. - Field + Analysis —
field-research+analyze-metricsCLIs + their launchd schedule, producingfield.json/weights.json. - 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)
- Per-platform video variants (different render per platform). Explicitly deferred.
- Cambium autoresearch / Memory integration as a library.
- Newsletter wiring (Beehiiv) — separate follow-up.