API Reference
Every type, interface, class, and function in semantic-loop.
createLoop()
function createLoop(definition: LoopDefinition): SemanticLoop
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.
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().
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 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:
| Method | What 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 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 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
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.
| Field | Required | Description |
|---|---|---|
store | Yes | Where items live. "memory" for testing, "supabase" for production. |
embedding | No | How text becomes vectors. "openai" or omit for no embeddings. |
critic | No | How items are scored. Defaults to "heuristic" (keyword-based). |
selection | No | Tune selection: epsilon, weights, freshness half-life. |
aggregation | No | Tune aggregation: decay factor, critic/engagement weights. |
telemetry | No | Plug 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:
- Check if value implements the interface (duck typing)
- Check for string shorthand
- Check for config object with
providerfield - Throw
ValidationErroron unrecognized config
String shorthands read env vars via Deno.env.get(). Missing env vars throw ValidationError with a descriptive message.
SemanticLoopEngine
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.
| Method | What 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 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:
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
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 contentembedding— vector representation for similarity searchmetadata— any extra JSON data you want to attachcreatedAt/updatedAt— timestampsarchivedAt— 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
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.
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
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
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
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
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
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
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
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
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
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
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 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.
| Method | What 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 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:
upsertItemmust be idempotent (useon conflictin SQL)retrievemust return candidates sorted by similarity desc, then scoreAvg descupdateAggregatemust apply decay and clamp all averages to [0, 1]updateAggregateshould use row-level locking to prevent race conditions- All methods are
asyncfor adapter interface consistency
Critic
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 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 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
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
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:
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
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.6noveltyBoost = 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
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.