moatkit / API Reference

API Reference

Every type, interface, class, and function in semantic-loop.

createLoop()

signature
function createLoop(definition: LoopDefinition): SemanticLoop
examples
createLoop({ store: "memory" })
createLoop({ store: "supabase", embedding: "openai" })
createLoop({ store: "supabase", embedding: "openai", critic: "heuristic" })
createLoop({ store: "supabase", selection: { epsilon: 0.25 }, aggregation: { decayFactor: 0.9 } })

The main entry point. Wires together a store, embedding provider, critic, and configuration into a ready-to-use loop.

signature
function createLoop(definition: LoopDefinition): SemanticLoop

The definition object tells the loop which backend to use for storage, how to generate embeddings, and how to tune the selection and aggregation algorithms. Most fields have sensible defaults.

Returns a SemanticLoop with three main methods: seed(), select(), and ingest().

signature
function createLoop(definition: LoopDefinition): SemanticLoop

Factory function that resolves declarative config into wired instances. Config slots accept three forms:

  • String shorthand — e.g., "supabase", "openai", "heuristic". Reads env vars.
  • Config object — e.g., { provider: "supabase", url: "...", serviceRoleKey: "..." }. Explicit values.
  • Raw instance — any object implementing the interface (MemoryStore, EmbeddingProvider, Critic). Escape hatch for custom backends.

Resolution is checked via duck-typing: isMemoryStore() checks for "upsertItem" in value, isEmbeddingProvider() checks for "embed" in value && "dimensions" in value.

SemanticLoop

interface
interface SemanticLoop {
  seed(items: readonly ItemInput[]): Promise<readonly SemanticItem[]>;
  select(query?: string, opts?: SelectOptions): Promise<SelectedCandidate>;
  ingest(itemId: string, platform: string, metrics: EngagementMetrics, opts?: IngestOptions): Promise<ProcessedOutcome>;
  readonly engine: SemanticLoopEngine;
  readonly embedder: EmbeddingProvider;
  readonly store: MemoryStore;
}

The object returned by createLoop(). Three methods are all you need:

MethodWhat it does
seed(items)Add content to the loop. Auto-generates IDs, timestamps, and embeddings.
select(query?, opts?)Pick the best item. The query string is embedded and used for similarity search.
ingest(itemId, platform, metrics, opts?)Record what happened when you used an item. Engagement is derived automatically.

You also get direct access to loop.engine, loop.embedder, and loop.store if you need lower-level control.

interface
interface SemanticLoop {
  seed(items: readonly ItemInput[]): Promise<readonly SemanticItem[]>;
  select(query?: string, opts?: SelectOptions): Promise<SelectedCandidate>;
  ingest(itemId: string, platform: string, metrics: EngagementMetrics, opts?: IngestOptions): Promise<ProcessedOutcome>;
  readonly engine: SemanticLoopEngine;
  readonly embedder: EmbeddingProvider;
  readonly store: MemoryStore;
}
interface
interface SemanticLoop {
  seed(items: readonly ItemInput[]): Promise<readonly SemanticItem[]>;
  select(query?: string, opts?: SelectOptions): Promise<SelectedCandidate>;
  ingest(itemId: string, platform: string, metrics: EngagementMetrics, opts?: IngestOptions): Promise<ProcessedOutcome>;
  readonly engine: SemanticLoopEngine;
  readonly embedder: EmbeddingProvider;
  readonly store: MemoryStore;
}

seed() uses embedder.embedBatch() when available (reducing API calls), then falls back to individual embed() calls. Items that already have an embedding field skip embedding generation.

select() embeds the query only when the embedder has dimensions > 0. Otherwise queryVector is undefined and the store defaults to a fixed similarity value.

ingest() calls deriveEngagementScore(metrics) unless an explicit engagementScore is provided via IngestOptions.

LoopDefinition

type
interface LoopDefinition {
  readonly store: "supabase" | "memory" | { provider: "supabase", url: string, serviceRoleKey: string } | { provider: "memory" } | MemoryStore;
  readonly embedding?: "openai" | { provider: "openai", apiKey: string, model?: string, dimensions?: number, baseUrl?: string } | EmbeddingProvider;
  readonly critic?: "heuristic" | { provider: "heuristic", noveltyKeywords?: string[], penaltyKeywords?: string[] } | Critic;
  readonly selection?: Partial<SelectionConfig>;
  readonly aggregation?: Partial<AggregationConfig>;
  readonly telemetry?: Telemetry;
}

The configuration object passed to createLoop(). Only store is required.

FieldRequiredDescription
storeYesWhere items live. "memory" for testing, "supabase" for production.
embeddingNoHow text becomes vectors. "openai" or omit for no embeddings.
criticNoHow items are scored. Defaults to "heuristic" (keyword-based).
selectionNoTune selection: epsilon, weights, freshness half-life.
aggregationNoTune aggregation: decay factor, critic/engagement weights.
telemetryNoPlug in tracing (OpenTelemetry, Datadog). Defaults to no-op.

Each config slot uses a union type allowing string shorthand, typed config object, or raw interface instance. Resolution order in the factory:

  1. Check if value implements the interface (duck typing)
  2. Check for string shorthand
  3. Check for config object with provider field
  4. Throw ValidationError on unrecognized config

String shorthands read env vars via Deno.env.get(). Missing env vars throw ValidationError with a descriptive message.

SemanticLoopEngine

class
class SemanticLoopEngine {
  constructor(options: EngineOptions)
  seed(items: readonly SemanticItem[]): Promise<void>
  selectNext(request: SelectRequest): Promise<SelectedCandidate>
  ingestOutcome(outcome: OutcomeSignal): Promise<ProcessedOutcome>
}

The core engine that orchestrates selection and ingestion. You usually access it via loop.engine rather than constructing it directly.

MethodWhat it does
seed(items)Validates and stores items (expects fully-formed SemanticItem objects with IDs and embeddings).
selectNext(request)Retrieves candidates from the store, scores them, and picks one via epsilon-greedy. Throws NotFoundError if no candidates match.
ingestOutcome(outcome)Looks up the item, runs the critic, computes the final score, records the outcome, and updates aggregate state.
class
class SemanticLoopEngine {
  constructor(options: EngineOptions)
  seed(items: readonly SemanticItem[]): Promise<void>
  selectNext(request: SelectRequest): Promise<SelectedCandidate>
  ingestOutcome(outcome: OutcomeSignal): Promise<ProcessedOutcome>
}

The engine is stateless — all state lives in the MemoryStore. Every method is a request-response cycle against the store.

EngineOptions:

EngineOptions
interface EngineOptions {
  readonly store: MemoryStore;
  readonly critic: Critic;
  readonly telemetry?: Telemetry;
  readonly config?: Partial<LoopConfig>;
  readonly random?: () => number;  // injectable for deterministic tests
  readonly now?: () => Date;       // injectable for deterministic tests
}

The random and now injectors make the engine fully deterministic for testing. The engine catches nothing — errors propagate to the caller. Private fields use true ES # private members.

The ingestOutcome pipeline: validate → getItem → getAggregate → critic.score → combineScores → appendOutcome → updateAggregate. Final score = criticScore × criticWeight + engagementScore × engagementWeight.

SemanticItem

type
interface SemanticItem {
  readonly id: string;
  readonly tribe: string;
  readonly kind: string;
  readonly content: string;
  readonly embedding: EmbeddingVector; // readonly number[]
  readonly metadata: JsonObject;
  readonly createdAt: string;       // ISO 8601
  readonly updatedAt: string;       // ISO 8601
  readonly archivedAt?: string;     // ISO 8601
}

A piece of content stored in the loop. Every item has:

  • id — unique identifier (auto-generated UUID if not provided)
  • tribe — audience segment (e.g., "founders", "developers")
  • kind — content type (e.g., "hook", "prompt", "headline")
  • content — the actual text content
  • embedding — vector representation for similarity search
  • metadata — any extra JSON data you want to attach
  • createdAt / updatedAt — timestamps
  • archivedAt — set when you want to retire an item without deleting it

All fields are readonly. The embedding is readonly number[] (aliased as EmbeddingVector). Dates are ISO 8601 strings, not Date objects, for serialization safety across edge runtimes.

ItemInput

type
interface ItemInput {
  readonly content: string;
  readonly tribe?: string;      // default: "default"
  readonly kind?: string;       // default: "item"
  readonly id?: string;         // default: crypto.randomUUID()
  readonly metadata?: JsonObject;
  readonly embedding?: EmbeddingVector; // skip auto-embed if provided
}

What you pass to loop.seed(). Only content is required — everything else has defaults.

type
interface ItemInput {
  readonly content: string;          // required
  readonly tribe?: string;          // defaults to "default"
  readonly kind?: string;           // defaults to "item"
  readonly id?: string;             // defaults to crypto.randomUUID()
  readonly metadata?: JsonObject;    // defaults to {}
  readonly embedding?: EmbeddingVector; // if provided, skips auto-embedding
}

When embedding is provided, the item skips the embedding provider entirely. This is useful for pre-computed embeddings or migrating data. The seed() method batch-embeds all items missing embeddings in a single embedBatch() call when available.

Candidate

type
interface Candidate {
  readonly item: SemanticItem;
  readonly aggregate: AggregateState;
  readonly similarity: number; // [0, 1]
}

A candidate for selection. Combines the item, its aggregate scores, and its similarity to the current query. Returned by store.retrieve().

Similarity is cosine similarity mapped to [0, 1] via (dot / (|a| * |b|) + 1) / 2. When no query vector is provided, the store assigns a default similarity of 0.5.

SelectedCandidate

type
interface SelectedCandidate {
  readonly candidate: Candidate;
  readonly strategy: "greedy" | "explore";
  readonly weightedScore: number; // [0, 1]
}

The result of select(). Tells you which item was picked, whether the algorithm exploited (picked the best) or explored (picked randomly from the top pool), and the computed weighted score.

The weightedScore is the four-dimensional weighted sum: similarity × w1 + scoreAvg × w2 + exploration × w3 + freshness × w4. When strategy is "explore", the selected candidate may not have the highest weightedScore — it was randomly sampled from the top-k pool.

AggregateState

type
interface AggregateState {
  readonly itemId: string;
  readonly attempts: number;
  readonly scoreSum: number;
  readonly scoreAvg: number;       // [0, 1]
  readonly criticAvg: number;      // [0, 1]
  readonly engagementAvg: number;  // [0, 1]
  readonly lastScore?: number;
  readonly lastCriticScore?: number;
  readonly lastEngagementScore?: number;
  readonly lastOutcomeAt?: string;
  readonly updatedAt: string;
}

The running scores for an item. Updated every time an outcome is ingested. The scoreAvg is the key number that drives selection — items with higher averages get picked more often.

Aggregation uses decay-weighted sums: scoreSum = oldScoreSum × decayFactor + finalScore, then scoreAvg = scoreSum / attempts. All averages are clamped to [0, 1]. The last* fields are from the most recent outcome only — useful for debugging and analytics.

OutcomeSignal

type
interface OutcomeSignal {
  readonly id: string;
  readonly itemId: string;
  readonly platform: string;
  readonly occurredAt: string;
  readonly metrics: EngagementMetrics;
  readonly engagementScore: number; // [0, 1]
  readonly payload?: JsonObject;
}

A raw outcome event. Created automatically by loop.ingest() or passed directly to engine.ingestOutcome(). The platform field tracks where the outcome came from (e.g., "instagram", "twitter", "email").

The high-level ingest() constructs this from its arguments, auto-generating id and occurredAt. When using the engine directly, you must provide all fields. The engagementScore must be pre-derived (use deriveEngagementScore() from utils).

EngagementMetrics

type
interface EngagementMetrics {
  readonly impressions?: number;
  readonly views?: number;
  readonly watchSeconds?: number;
  readonly avgWatchSeconds?: number;
  readonly likes?: number;
  readonly comments?: number;
  readonly shares?: number;
  readonly saves?: number;
  readonly clicks?: number;
  readonly conversions?: number;
}

Raw engagement numbers from a platform. All fields are optional — pass whatever you have. The system derives a single engagementScore from these using a weighted formula.

Engagement derivation: interactionRate = (likes + comments×2 + shares×3 + saves×2 + clicks×2 + conversions×4) / views. Watch signal: clamp(avgWatchSeconds / 30). Final: interactionRate × 0.7 + watchSignal × 0.3. Uses views first, falls back to impressions, then 0.

ProcessedOutcome

type
interface ProcessedOutcome {
  readonly item: SemanticItem;
  readonly outcome: OutcomeSignal;
  readonly critic: CriticResult;
  readonly aggregate: AggregateState; // after update
  readonly finalScore: number;       // [0, 1]
}

The result of ingest(). Contains everything that happened: the item, the raw outcome, the critic's judgment, the updated aggregate scores, and the final combined score.

The aggregate is the post-update state. The finalScore is criticScore × criticWeight + engagementScore × engagementWeight (default 0.6/0.4 split).

SelectionConfig

type + defaults
interface SelectionConfig {
  readonly epsilon: number;              // 0.18
  readonly topKExplorationPool: number;  // 3
  readonly weights: SelectionWeights;    // { similarity: 0.45, scoreAvg: 0.35, exploration: 0.15, freshness: 0.05 }
  readonly freshnessHalfLifeHours: number; // 168
}

Controls how the loop picks items.

  • epsilon — probability of exploring (trying something new) instead of exploiting (picking the best). Higher = more exploration.
  • topKExplorationPool — when exploring, how many top items to randomly sample from.
  • weights — how much each factor matters in the score.
  • freshnessHalfLifeHours — how quickly the freshness bonus decays. 168 hours = 1 week.

Weights don't need to sum to 1 — the final weighted score is clamped to [0, 1]. Freshness uses exponential decay: e^(-ln(2) × hoursElapsed / halfLife). Exploration score: 1 / max(1, attempts + 1).

AggregationConfig

type + defaults
interface AggregationConfig {
  readonly criticWeight: number;      // 0.6
  readonly engagementWeight: number;  // 0.4
  readonly decayFactor: number;       // 0.95
}

Controls how outcomes are scored and aggregated.

  • criticWeight — how much the critic's opinion matters in the final score.
  • engagementWeight — how much raw engagement matters.
  • decayFactor — older scores are multiplied by this before adding new ones. Lower = faster forgetting.

The decay is applied to scoreSum before adding the new final score: scoreSum = oldScoreSum × decayFactor + finalScore. This means with a decay of 0.95, after 14 outcomes the oldest score's contribution is 0.95^14 ≈ 0.49 — halved.

SelectOptions

type
interface SelectOptions {
  readonly tribe?: string;
  readonly kind?: string;
  readonly limit?: number;          // default: 8
  readonly minSimilarity?: number;  // default: 0
  readonly includeArchived?: boolean;
  readonly selection?: Partial<SelectionConfig>;
}

Options for loop.select(). Filter by tribe, kind, set a minimum similarity threshold, or override selection config per-call.

The selection field allows per-call overrides of epsilon, weights, etc. This is useful for A/B testing different selection strategies without creating multiple loop instances.

IngestOptions

type
interface IngestOptions {
  readonly id?: string;               // default: crypto.randomUUID()
  readonly occurredAt?: string;       // default: new Date().toISOString()
  readonly engagementScore?: number;  // override auto-derivation
  readonly payload?: JsonObject;      // extra data
}

Optional overrides for loop.ingest(). Mostly used for replaying historical data (custom id and occurredAt) or overriding the auto-derived engagement score.

Setting engagementScore bypasses deriveEngagementScore(). Useful when you have a custom engagement formula or pre-computed scores from an external system. The payload is stored alongside the outcome for audit trails.

MemoryStore

interface
interface MemoryStore {
  upsertItem(item: SemanticItem): Promise<void>;
  retrieve(request: RetrieveRequest): Promise<readonly Candidate[]>;
  getItem(itemId: string): Promise<SemanticItem | null>;
  getAggregate(itemId: string): Promise<AggregateState | null>;
  appendOutcome(outcome: OutcomeSignal, critic: CriticResult, finalScore: number): Promise<void>;
  updateAggregate(update: AggregateUpdate, config: AggregationConfig): Promise<AggregateState>;
}

The contract for where data lives. Implement this to use any backend.

MethodWhat it does
upsertItem(item)Insert or update an item. Must be idempotent.
retrieve(request)Find candidates matching the query, tribe, and kind filters.
getItem(itemId)Get a single item by ID. Returns null if not found.
getAggregate(itemId)Get the aggregate scores for an item. Returns null if no scores yet.
appendOutcome(...)Store a raw outcome event with its critic result.
updateAggregate(...)Apply a new outcome to the running aggregates.

Ships with InMemoryStore (testing) and SupabaseRpcStore (production).

interface
interface MemoryStore {
  upsertItem(item: SemanticItem): Promise<void>;
  retrieve(request: RetrieveRequest): Promise<readonly Candidate[]>;
  getItem(itemId: string): Promise<SemanticItem | null>;
  getAggregate(itemId: string): Promise<AggregateState | null>;
  appendOutcome(outcome: OutcomeSignal, critic: CriticResult, finalScore: number): Promise<void>;
  updateAggregate(update: AggregateUpdate, config: AggregationConfig): Promise<AggregateState>;
}

Key contract requirements for custom implementations:

  • upsertItem must be idempotent (use on conflict in SQL)
  • retrieve must return candidates sorted by similarity desc, then scoreAvg desc
  • updateAggregate must apply decay and clamp all averages to [0, 1]
  • updateAggregate should use row-level locking to prevent race conditions
  • All methods are async for adapter interface consistency

Critic

interface
interface Critic {
  score(input: CriticInput): Promise<CriticResult>;
}

interface CriticInput {
  readonly item: SemanticItem;
  readonly aggregateBefore: AggregateState;
  readonly outcome: OutcomeSignal;
}

interface CriticResult {
  readonly score: number;         // [0, 1]
  readonly rationale: string;
  readonly tags: readonly string[];
  readonly meta?: JsonObject;
}

How content is judged after an outcome. The critic sees the item, its previous scores, and the outcome. It returns a score between 0 and 1, a human-readable rationale, and tags for categorization.

Ships with HeuristicCritic (keyword-based). The pro/ package adds LlmCritic and MultiSignalCritic.

The aggregateBefore field provides historical context — the critic can factor in how many attempts the item has had and its running averages. The meta field in CriticResult is stored alongside the outcome for audit trails.

EmbeddingProvider

interface
interface EmbeddingProvider {
  embed(text: string): Promise<EmbeddingVector>;
  embedBatch?(texts: readonly string[]): Promise<readonly EmbeddingVector[]>;
  readonly dimensions: number;
}

Turns text into vectors for similarity search. The dimensions property tells the system how long the vectors are (e.g., 1536 for OpenAI's text-embedding-3-small).

embedBatch is optional but recommended — it lets seed() embed all items in one API call instead of one per item.

When dimensions is 0 (as with NoopEmbedding), select() skips query embedding entirely. Implement this interface for custom embedding backends (Cohere, local ONNX models, etc.).

Telemetry

interface
interface Telemetry {
  startSpan(name: string): SpanLike;
}

interface SpanLike {
  setAttribute(name: string, value: string | number | boolean): void;
  end(): void;
}

Observe the loop itself. Maps to OpenTelemetry, Datadog, or any tracing provider. Defaults to NoopTelemetry (zero overhead).

Span names: "semantic_loop.seed", "semantic_loop.select_next", "semantic_loop.ingest_outcome". Attributes include item count, tribe, kind, strategy, scores. The interface is intentionally minimal to avoid vendor lock-in.

InMemoryStore

usage
import { InMemoryStore } from "@semantic-loop/core";
const store = new InMemoryStore();

A MemoryStore backed by JavaScript Map objects. Data lives in memory and is lost when the process exits. Great for testing and prototyping.

Accepts an optional now?: () => Date constructor argument for deterministic testing. Similarity without a query vector defaults to 0.5. Sorting: similarity desc, then scoreAvg desc. Retrieval limit defaults to 8.

SupabaseRpcStore

usage
import { SupabaseRpcStore } from "@semantic-loop/core";
const store = new SupabaseRpcStore({
  url: "https://your-project.supabase.co",
  serviceRoleKey: "your-key",
});

A MemoryStore backed by Supabase (PostgreSQL + pgvector). Durable, supports vector similarity search, and works from edge functions. See the Supabase guide for setup.

Options:

SupabaseRpcStoreOptions
interface SupabaseRpcStoreOptions {
  readonly url: string;
  readonly serviceRoleKey: string;
  readonly itemsTable?: string;       // "semantic_items"
  readonly aggregatesTable?: string;  // "semantic_item_scores"
  readonly rpc?: Partial<SupabaseRpcNames>;
  readonly fetcher?: typeof fetch;    // injectable for testing
}

All writes go through RPC functions for atomicity. sl_apply_outcome uses SELECT ... FOR UPDATE for row-level locking. The fetcher option lets you inject a mock fetch for testing without hitting Supabase.

HeuristicCritic

usage
import { HeuristicCritic } from "@semantic-loop/core";
const critic = new HeuristicCritic();
const critic2 = new HeuristicCritic({
  noveltyKeywords: ["why", "hidden", "counterintuitive"],
  penaltyKeywords: ["viral", "guaranteed"],
});

A critic that scores items using keyword matching. Novelty keywords boost the score, penalty keywords lower it. No API calls, runs instantly.

Default novelty keywords: "why", "mistake", "hidden", "counterintuitive", "framework", "moat", "compounding".

Default penalty keywords: "ultimate", "best ever", "guaranteed", "viral".

Score formula: 0.2 + engagementBoost + noveltyBoost + historicalStability - penalty, where:

  • engagementBoost = engagementScore × 0.6
  • noveltyBoost = min(0.25, noveltyHits × 0.06)
  • penalty = min(0.2, penaltyHits × 0.05)
  • historicalStability = min(0.15, scoreAvg × 0.15) (only when attempts > 0)

Tags emitted: "novelty" (has novelty hits), "hype-risk" (has penalty hits), "validated" (engagement ≥ 0.5), "needs-more-data" (engagement < 0.5).

OpenAIEmbedding

usage
import { OpenAIEmbedding } from "@semantic-loop/core";
const embedder = new OpenAIEmbedding({ apiKey: "sk-..." });
const embedder2 = new OpenAIEmbedding({
  apiKey: "sk-...",
  model: "text-embedding-3-small",
  dimensions: 1536,
  baseUrl: "https://api.openai.com/v1",
});

Generates embeddings using OpenAI's API. Supports both single and batch embedding. Defaults to text-embedding-3-small with 1536 dimensions.

The baseUrl option supports Azure OpenAI, proxy endpoints, and compatible APIs. The embedBatch() method sends all texts in a single API call and sorts results by index to maintain order. Throws on non-OK responses with the HTTP status code.