Quickstart
From zero to a self-improving retrieval loop in five minutes.
Install
deno add jsr:@semantic-loop/core
semantic-loop runs on Deno, a modern TypeScript runtime. If you don't have Deno installed yet:
curl -fsSL https://deno.land/install.sh | sh
Then add the library to your project:
deno add jsr:@semantic-loop/core
This adds @semantic-loop/core to your deno.json import map. You can now import from it in any .ts file.
deno add jsr:@semantic-loop/core
The package ships as ESM on JSR. It uses only web-standard APIs (fetch, crypto.subtle, Request/Response), so it runs on Deno Deploy, Cloudflare Workers, or any edge runtime that supports these.
No Node.js polyfills needed. No build step. The barrel export is mod.ts.
First loop (in-memory)
import { createLoop } from "@semantic-loop/core"; // in-memory store, no external services const loop = createLoop({ store: "memory" }); // seed content await loop.seed([ { content: "The moat is the loop that compounds after every post.", tribe: "founders", kind: "hook" }, { content: "Generic productivity advice.", tribe: "founders", kind: "hook" }, { content: "Why most founders build features when they should build feedback loops.", tribe: "founders", kind: "hook" }, ]); // select best candidate const pick = await loop.select(undefined, { tribe: "founders" }); console.log(pick.candidate.item.content); // feed back real-world outcome const result = await loop.ingest(pick.candidate.item.id, "instagram", { views: 12400, likes: 340, comments: 45, shares: 89, }); console.log(result.finalScore); // select again — now informed by the outcome const next = await loop.select(undefined, { tribe: "founders" });
deno run quickstart.ts
Let's build a self-improving system from scratch. No database, no API keys, no setup. Everything runs in memory.
What is a "loop"?
A loop is a system that learns from its own output. You give it content, it picks the best item for a situation, the real world tells you how it did, and the next pick is smarter. Repeat forever.
Step 1: Create the loop
import { createLoop } from "@semantic-loop/core"; // "memory" means everything is stored in JavaScript Maps // — great for learning, no external services needed const loop = createLoop({ store: "memory" });
createLoop() is the main entry point. It wires up a store (where items live), an embedding provider (optional — turns text into vectors for similarity search), and a critic (scores items after outcomes).
Step 2: Seed content
await loop.seed([ { content: "The moat is the loop that compounds after every post.", tribe: "founders", // audience segment kind: "hook", // content type }, { content: "Generic productivity advice.", tribe: "founders", kind: "hook", }, { content: "Why most founders build features when they should build feedback loops.", tribe: "founders", kind: "hook", }, ]);
tribe is an audience segment — you can have "founders", "designers", "developers", etc. kind is the content type — "hook", "prompt", "headline", whatever you're optimizing.
IDs and timestamps are generated automatically. If you provide embeddings (vectors), they're stored. If not, the system uses the configured embedding provider (or none, in this case).
Step 3: Select
const pick = await loop.select(undefined, { tribe: "founders" }); console.log(pick.candidate.item.content); // → one of the three items above console.log(pick.strategy); // → "greedy" (best score) or "explore" (random from top pool)
The first argument to select() is a query string (used for semantic similarity when you have embeddings). We pass undefined here because we don't have embeddings yet.
The second argument filters by tribe, kind, etc. The engine uses an epsilon-greedy strategy: most of the time it picks the best item, but sometimes it explores to discover hidden gems.
Step 4: Ingest an outcome
const result = await loop.ingest(pick.candidate.item.id, "instagram", { views: 12400, likes: 340, comments: 45, shares: 89, saves: 214, avgWatchSeconds: 18, }); console.log(result.finalScore); // 0.341 console.log(result.critic.rationale); // why the critic scored it this way console.log(result.critic.tags); // ["novelty", "validated"]
ingest() takes an item ID, a platform name (for tracking where outcomes come from), and engagement metrics. It automatically derives an engagement score, runs the critic, and updates the item's aggregate scores.
Step 5: Select again
const next = await loop.select(undefined, { tribe: "founders" }); // → now informed by the outcome — picks what performed
The second select() knows about the first outcome. Items that performed well get higher scores. Items that were never tested get an exploration bonus. This is the compounding loop.
The in-memory setup uses InMemoryStore, NoopEmbedding (0-dimensional, no vectors), and HeuristicCritic (keyword-based scoring). No network calls.
import { createLoop } from "@semantic-loop/core"; const loop = createLoop({ store: "memory" }); await loop.seed([ { content: "The moat is the loop that compounds after every post.", tribe: "founders", kind: "hook" }, { content: "Generic productivity advice.", tribe: "founders", kind: "hook" }, { content: "Why most founders build features when they should build feedback loops.", tribe: "founders", kind: "hook" }, ]); const pick = await loop.select(undefined, { tribe: "founders" }); const result = await loop.ingest(pick.candidate.item.id, "instagram", { views: 12400, likes: 340, comments: 45, shares: 89, saves: 214, avgWatchSeconds: 18, }); const next = await loop.select(undefined, { tribe: "founders" });
What happens under the hood
Without embeddings, InMemoryStore.retrieve() assigns a default similarity of 0.5 to all candidates. Selection then depends on the remaining three weighted dimensions:
- scoreAvg (weight 0.35) — running average of final scores from past outcomes
- exploration (weight 0.15) —
1 / (attempts + 1), favoring untested items - freshness (weight 0.05) — exponential decay from last outcome, 168h half-life
On the first select(), all items are tied (0 attempts, 0 scores, equal similarity). Epsilon-greedy with ε = 0.18 means there's an 18% chance of random exploration from the top-k pool (default k=3).
After ingest(), the selected item has 1 attempt and a non-zero scoreAvg. The HeuristicCritic checks for novelty keywords ("why", "mistake", "hidden", "counterintuitive", "framework", "moat", "compounding") and penalty keywords ("ultimate", "best ever", "guaranteed", "viral"). The final score combines critic and engagement: criticScore × 0.6 + engagementScore × 0.4.
Engagement derivation for this example:
// interactionRate = (340 + 45*2 + 89*3 + 214*2 + 0*2 + 0*4) / 12400 // = (340 + 90 + 267 + 428) / 12400 = 1125 / 12400 = 0.0907 // watchSignal = clamp(18 / 30) = 0.6 // engagementScore = 0.0907 * 0.7 + 0.6 * 0.3 = 0.0635 + 0.18 = 0.2435
InMemoryStore is not durable. Scores and items are lost when the process exits. For persistence, use SupabaseRpcStore.Add Supabase
SUPABASE_URL=https://your-project.supabase.co SUPABASE_SERVICE_ROLE_KEY=your-key
-- creates: semantic_items, semantic_item_scores, semantic_outcomes -- creates: sl_upsert_item, sl_match_items, sl_record_outcome, sl_apply_outcome -- see docs/supabase.html for the full migration
const loop = createLoop({ store: "supabase" });
Supabase is a hosted PostgreSQL database with a REST API. It's free to start and handles everything semantic-loop needs: storing items, vector search, and aggregate updates.
Step 1: Create a Supabase project
Go to supabase.com and create a new project. Note your project URL and service role key from Settings > API.
Step 2: Run the migration
Open the SQL Editor in your Supabase dashboard and paste the contents of sql/001_init.sql. This creates three tables and four RPC functions. See the Supabase guide for the full migration with line-by-line explanation.
Step 3: Set environment variables
SUPABASE_URL=https://your-project.supabase.co SUPABASE_SERVICE_ROLE_KEY=your-key
Step 4: Change one line
// Before const loop = createLoop({ store: "memory" }); // After const loop = createLoop({ store: "supabase" });
When you pass the string "supabase", the library reads SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY from environment variables automatically.
The "supabase" shorthand reads env vars. For explicit control:
const loop = createLoop({ store: { provider: "supabase", url: Deno.env.get("SUPABASE_URL")!, serviceRoleKey: Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, }, });
Or pass a raw SupabaseRpcStore instance for full control over table names, RPC function names, and the fetch implementation:
import { createLoop, SupabaseRpcStore } from "@semantic-loop/core"; const store = new SupabaseRpcStore({ url: "https://your-project.supabase.co", serviceRoleKey: "your-key", itemsTable: "semantic_items", // default aggregatesTable: "semantic_item_scores", // default rpc: { upsertItem: "sl_upsert_item", // default matchItems: "sl_match_items", // default recordOutcome: "sl_record_outcome", // default applyOutcome: "sl_apply_outcome", // default }, }); const loop = createLoop({ store });
The SupabaseRpcStore communicates via Supabase's PostgREST API. All writes go through RPC functions (sl_*) for atomicity. Reads use direct REST queries. The store implements the MemoryStore interface, so you can swap it for any custom backend.
Add OpenAI embeddings
OPENAI_API_KEY=sk-...
const loop = createLoop({ store: "supabase", embedding: "openai", });
Embeddings turn text into numbers (vectors). When you search for "growth strategies", the system can find items about compounding and moats because they're close together in vector space, even if they don't share the same words.
Without embeddings, every item gets the same similarity score. With embeddings, select("growth strategies") returns items semantically related to that query.
Step 1: Get an OpenAI API key
Sign up at platform.openai.com and create an API key.
Step 2: Set the env var
OPENAI_API_KEY=sk-...
Step 3: Add to config
const loop = createLoop({ store: "supabase", embedding: "openai", // reads OPENAI_API_KEY from env });
Now when you call loop.seed(), embeddings are generated automatically for each item. When you call loop.select("growth strategies"), the query is embedded and compared to all items via cosine similarity.
The "openai" shorthand uses text-embedding-3-small with 1536 dimensions. For explicit control:
const loop = createLoop({ store: "supabase", embedding: { provider: "openai", apiKey: Deno.env.get("OPENAI_API_KEY")!, model: "text-embedding-3-small", // default dimensions: 1536, // default — match your pgvector column baseUrl: "https://api.openai.com/v1", // default — change for Azure, proxies }, });
The EmbeddingProvider interface has two methods:
embed(text: string): Promise<EmbeddingVector>— single text to vectorembedBatch?(texts: readonly string[]): Promise<readonly EmbeddingVector[]>— optional batch API, used byseed()
If embedBatch is defined, seed() calls it once for all items that lack pre-computed embeddings, reducing API calls. The OpenAIEmbedding class implements both.
You can also pass any object implementing EmbeddingProvider directly for custom embedding backends (Cohere, local models, etc.).
vector(1536) column definition in the SQL migration to match.Full loop cycle
import { createLoop } from "@semantic-loop/core"; const loop = createLoop({ store: "supabase", embedding: "openai", selection: { epsilon: 0.2 }, aggregation: { decayFactor: 0.9 }, }); // seed await loop.seed([ { content: "The moat is the loop that compounds.", tribe: "founders" }, { content: "Ship fast, learn faster.", tribe: "founders" }, ]); // select with semantic query const pick = await loop.select("compounding growth strategies", { tribe: "founders", }); // publish pick.candidate.item.content to your platform... // later: ingest the outcome await loop.ingest(pick.candidate.item.id, "twitter", { views: 8500, likes: 120, shares: 34, clicks: 210, });
Here's the full picture: Supabase for persistence, OpenAI for semantic search, and tuning options to control how the loop learns.
import { createLoop } from "@semantic-loop/core"; const loop = createLoop({ store: "supabase", // durable storage with vector search embedding: "openai", // semantic similarity selection: { epsilon: 0.2, // 20% chance of exploring instead of exploiting }, aggregation: { decayFactor: 0.9, // recent outcomes matter more }, }); // 1. Seed content with automatic embedding generation await loop.seed([ { content: "The moat is the loop that compounds.", tribe: "founders" }, { content: "Ship fast, learn faster.", tribe: "founders" }, ]); // 2. Select using semantic query — "compounding growth" matches "loop that compounds" const pick = await loop.select("compounding growth strategies", { tribe: "founders", }); // 3. Use the content in your app, post it, send it... console.log(pick.candidate.item.content); // 4. Later, when you have metrics, feed them back const result = await loop.ingest(pick.candidate.item.id, "twitter", { views: 8500, likes: 120, shares: 34, clicks: 210, }); // 5. Next select() is smarter. The loop compounds.
That's it. Seed content, select the best, observe what happens, feed it back. The system gets smarter with every cycle.
The full production cycle involves three method calls that correspond to the engine's internal pipeline:
// seed() → for each item: // 1. embedder.embed(content) or embedder.embedBatch(contents) // 2. buildItem(input, embedding) → SemanticItem // 3. engine.seed(items) → store.upsertItem(item) for each // select(query, opts) → // 1. embedder.embed(query) → queryVector // 2. engine.selectNext({ queryVector, tribe, ... }) // → store.retrieve(request) → candidates[] // → computeWeightedScore(candidate, config, now) for each // → sort by weighted score // → epsilon-greedy: random() < epsilon ? pick from top-k : pick best // ingest(itemId, platform, metrics) → // 1. deriveEngagementScore(metrics) → engagementScore // 2. engine.ingestOutcome(outcome) // → store.getItem(itemId) // → store.getAggregate(itemId) // → critic.score({ item, aggregateBefore, outcome }) → CriticResult // → finalScore = criticScore * 0.6 + engagementScore * 0.4 // → store.appendOutcome(outcome, critic, finalScore) // → store.updateAggregate(update, config) → new AggregateState
The SemanticLoop wrapper returned by createLoop() exposes loop.engine (the raw SemanticLoopEngine), loop.embedder, and loop.store for direct access when you need to bypass the high-level API.
Tuning parameters to experiment with:
| Parameter | Default | Effect |
|---|---|---|
selection.epsilon |
0.18 |
Higher = more exploration, slower convergence |
selection.topKExplorationPool |
3 |
How many top items to sample from during exploration |
selection.freshnessHalfLifeHours |
168 |
Lower = faster freshness decay, more recency bias |
selection.weights |
{ similarity: 0.45, scoreAvg: 0.35, exploration: 0.15, freshness: 0.05 } |
How dimensions are balanced in the weighted score |
aggregation.decayFactor |
0.95 |
Lower = faster decay of old scores, more recency weighting |
aggregation.criticWeight |
0.6 |
Critic influence on final score |
aggregation.engagementWeight |
0.4 |
Engagement influence on final score |