Learning Algorithm Plugin
This tutorial builds a small fixed-interval scheduler. It demonstrates the complete plugin contract, lifecycle, strict state parsing, action metadata, and JSON-safe callback results. For production scheduling, use a well-tested model and add deterministic tests around every state transition.
Read Learning Algorithms first for the host behavior, persistence rules, batching, and failure semantics.
Create the package
Section titled “Create the package”Create this structure:
fixed-interval-learning/├── package.json├── tsconfig.json└── src/ └── index.tsRequest registry.learning-algorithm and point the JavaScript entry at the
source file:
{ "$schema": "https://recall.jrtilak.dev/schemas/plugin-config/v0.1/schema.json", "name": "@example/fixed-interval-learning", "displayName": "Fixed Interval Learning", "version": "1.0.0", "description": "An example learning algorithm for Recall.", "author": "Example Developer <https://example.com>", "type": "module", "module": "src/index.ts", "recall": { "manifestVersion": "0.1", "category": "study", "tags": ["flashcards", "learning"], "permissions": ["registry.learning-algorithm"], "entry": { "runtime": "js", "file": "src/index.ts" } }, "devDependencies": { "@jrtilak-recall/runtime": "latest", "typescript": "^6" }}Use a strict TypeScript configuration that does not emit during editor checks:
{ "compilerOptions": { "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, "strict": true, "target": "ESNext" }, "include": ["src"]}Install the development dependencies with bun install.
Define the algorithm
Section titled “Define the algorithm”Create src/index.ts:
import type { LearningAlgorithm, LearningCardInput, RuntimeCtx,} from "@jrtilak-recall/runtime";
const ALGORITHM_ID = "example.fixed-interval";const STATE_SCHEMA_VERSION = 1;const DAY_MS = 24 * 60 * 60 * 1_000;
const actions = [ { id: "easy", label: "Easy", order: 0, tone: "positive", successful: true, defaultSwipeDirection: "left", }, { id: "good", label: "Medium", order: 1, tone: "neutral", successful: true, defaultSwipeDirection: "down", }, { id: "hard", label: "Hard", order: 2, tone: "warning", successful: false, defaultSwipeDirection: "up", }, { id: "again", label: "Very Hard", order: 3, tone: "negative", successful: false, defaultSwipeDirection: "right", },] as const;
type FixedIntervalState = { reviews: number; streak: number;};
export const fixedIntervalLearningAlgorithm = { id: ALGORITHM_ID, name: "Fixed Interval", description: "A small deterministic scheduler used as a plugin example.", stateSchemaVersion: STATE_SCHEMA_VERSION, actions,
createInitialState({ now }) { const createdAt = parseDate(now, "initial-state timestamp"); return { due: createdAt.toISOString(), state: { schemaVersion: STATE_SCHEMA_VERSION, data: { reviews: 0, streak: 0 }, }, }; },
evaluateCards({ cards, now }) { const evaluatedAt = parseDate(now, "evaluation timestamp").getTime(); return cards.map((card) => { const state = readState(card); const isNew = state.reviews === 0; return { cardId: card.id, bucket: isNew ? "new" : "review", status: isNew ? "untouched" : state.streak >= 5 ? "mastered" : "learning", progress: Math.min(state.streak / 5, 1), priority: isNew ? 0 : parseDate(card.due, `due timestamp for ${card.id}`).getTime() <= evaluatedAt ? 1 : 2, }; }); },
previewReview({ card, reviewedAt }) { const state = readState(card); const reviewDate = parseDate(reviewedAt, "preview timestamp"); return actions.map((action) => ({ actionId: action.id, due: getDue(reviewDate, intervalDays(action.id, state.streak)), })); },
scheduleReview({ actionId, card, reviewedAt }) { const state = readState(card); const reviewDate = parseDate(reviewedAt, "review timestamp"); const nextStreak = actionId === "again" ? 0 : state.streak + 1; const interval = intervalDays(actionId, state.streak);
return { card: { due: getDue(reviewDate, interval), state: { schemaVersion: STATE_SCHEMA_VERSION, data: { reviews: state.reviews + 1, streak: nextStreak }, }, }, log: { actionId, intervalDays: interval }, }; },} satisfies LearningAlgorithm;
function intervalDays(actionId: string, streak: number) { const multiplier = Math.max(streak + 1, 1); switch (actionId) { case "again": return 0; case "hard": return 1; case "good": return 3 * multiplier; case "easy": return 7 * multiplier; default: throw new Error(`Unsupported review action "${actionId}".`); }}
function readState(card: LearningCardInput): FixedIntervalState { if (card.algorithmId !== ALGORITHM_ID) { throw new Error(`Card "${card.id}" belongs to another algorithm.`); } if (card.state.schemaVersion !== STATE_SCHEMA_VERSION) { throw new Error(`Card "${card.id}" uses an unsupported state version.`); }
const data = card.state.data; const keys = Object.keys(data).sort(); if (keys.join(",") !== "reviews,streak") { throw new Error(`Card "${card.id}" contains invalid state fields.`); } if (!isCount(data.reviews) || !isCount(data.streak)) { throw new Error(`Card "${card.id}" contains invalid state values.`); } return { reviews: data.reviews, streak: data.streak };}
function isCount(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;}
function parseDate(value: string, label: string) { const date = new Date(value); if (!Number.isFinite(date.getTime())) throw new Error(`${label} is invalid.`); return date;}
function getDue(reviewedAt: Date, days: number) { return new Date(reviewedAt.getTime() + days * DAY_MS).toISOString();}
type LearningAlgorithms = NonNullable<RuntimeCtx["learningAlgorithms"]>;type Registration = Awaited<ReturnType<LearningAlgorithms["register"]>>;
let registration: Registration | undefined;
export async function init(ctx: RuntimeCtx) { if (!ctx.learningAlgorithms) { throw new Error("Fixed Interval Learning requires its declared permission."); }
if (registration) { const previousRegistration = registration; registration = undefined; await previousRegistration.dispose(); }
registration = await ctx.learningAlgorithms.register({ algorithms: [fixedIntervalLearningAlgorithm], });}
export async function unload(_ctx: RuntimeCtx) { if (!registration) return;
const previousRegistration = registration; registration = undefined; await previousRegistration.dispose();}Keep the algorithm id and action ids stable after publishing: Recall stores them with card state and review history. The parser rejects foreign algorithms, unknown schema versions, extra keys, and invalid values before scheduling.
The callbacks may return promises, but the sandbox-facing registry is always
asynchronous. evaluateCards must work with any bounded subset of cards, and
all state and log values must remain plain JSON. Recall rejects a callback that
returns an invalid shape or exceeds its deadline. Downloaded JavaScript plugin
callbacks have 9 seconds; the in-process registry has a 10-second guard so the
sandbox can be torn down before that outer deadline expires.
Build and verify
Section titled “Build and verify”Run the TypeScript check, then build the distributable archive:
bunx tsc --noEmitbunx @jrtilak-recall/plugin-creator build . --minify --zipAdd fixed-time tests for initial state, every action preview, every scheduling
transition, malformed persisted state, foreign algorithm ids, and plugin
init/unload. Avoid the wall clock in tests: pass explicit ISO timestamps to
each callback and assert complete JSON results.
The build writes dist/manifest.json, the bundled JavaScript entry, and
dist.zip. Install the archive in Recall and select the algorithm in Review
Settings. The selection applies only to cards created afterward.