Semantic index and change feed
A mini app backend can put its own items in the semantic index, find them by meaning, compare a few texts with the embedding model, ask a decision model typed questions, and follow its own changes on the host's change feed — the same mechanisms the core uses for chats, skills and agents.
Index your items
Register a source with ctx.host.semantic.registerSource: a collection name, a label (the
category the status bar lists), a version, and scan/read over your items. The host embeds them
with EmbeddingGemma 2, which Localix keeps on this computer, and keeps the vectors. Your collections
are namespaced mini-app:<name>:<collection>, with your mini app's name, so you can neither read
nor overwrite another's, and each is scored against itself alone.
Query by meaning
ctx.semantic is the index on every load, a route's and a tool's alike (ctx.host.semantic is the
same one, on the boot-time load only). A tool's query, rank and embed read the index the
routes read, and its changed notices reach it; a source a tool's load registers is ignored,
because the boot-time load already holds it.
query(userId, collection, text, { scope }) answers with matches carrying z and gate; a match
counts when z >= gate. embed returns raw vectors for clustering or deduplication, and status()
says whether semantic search is ready. Semantic search is always on; until its model is here,
query returns nothing and embed returns null.
Compare a few texts
rank(query, candidates, { task }) embeds a query and a handful of { key, text } candidates now,
without storing anything, and returns them nearest first with each one's cosine. Use it to pick the
closest option, route an item, or spot a near-duplicate. With similarity or classification both
sides are embedded alike; the retrieval tasks embed candidates as documents. Like embed, it
returns null until the model is ready.
Ask a decision model
ctx.decisions asks a decision model (Jev through OpenRouter) typed questions about a structured
state. It is on every load, a route's and a tool's alike (ctx.host.decisions is the same one, on
the route's load only), so pass the chat you were given: c.env.sessionId in a route,
context.session.id in a tool. A choice question picks among option keys and a bool question
gives the probability that a statement is true. All questions are answered in one pass, with
probabilities and without text.
decide(userId, { state, questions }, { purpose, sessionId }) resolves to an outcome. It is
answered with answers keyed like your questions; otherwise its status says why: unavailable
(no connection serves a decision model), deadline, cancelled or failed. Always keep your own
fallback for those. The host picks the model, waits at most deadlineMs (3 s by default, 15 s at
most), records the spend under Decisions against sessionId, and logs the decision without its
state, under mini-app:<name>:<purpose>. It rejects only a malformed purpose or a session the
user does not own. available(userId) says whether a decision would be answered now.
confidentChoice(answer, 0.8) returns a choice answer's pick when it reaches that probability,
else null.
decide(ctx, { userId, sessionId, purpose, state, question, options, fallback }) from
@localixai/mini-app-sdk/backend asks one choice question and always answers:
{ pick, by: 'model' }, or { pick: fallback, by: 'fallback', reason }, where reason is the
outcome's status, unsure below the confidence asked for, or no-host when the load reaches no
decision model at all. In a tool the agent is a model already: when it can name the pick, take it as
the tool's input and keep decide for the screen and for events no agent sees.
A game whose other side can be the decision model keeps the choice with its table
(@localixai/mini-app-sdk/opponent), asks for a turn with askDecisionForTurn (one choice
question over the legal moves; a miss comes back as the reason the board shows), and offers the
choice with OpponentMenu from @localixai/kit/opponent-menu. Show the miss with GameProblem
from @localixai/kit/game-status, over the board, with Ask again and the other opponent as its
actions.
The games of the Localix marketplace are the examples. Ask after the person's request has answered,
with decisionTurns from @localixai/mini-app-sdk/backend: the route records the model as thinking
and returns the person's move at once, and the model's move lands through the store feed.
createTurnTable, from the same entry point, does all of this for a game with one board per chat:
give it the rules (who owes a turn, the position's key, how a decision is played) and it keeps the
game, settles the opponent, wakes the agent once per position and plays the model's turns.
Follow your own changes
ctx.host.changes records thin notices on your stream, mini-app:<name>: upserted, deleted, or
resync for "any of this kind may have changed". record never throws.
subscribe(consumer, handler) reads them at a cursor that survives a restart; a handler that throws
is retried from the same notice.
followCoreDeletions(consumer, handler) reads the core's deletions the same way, read-only: a
deleted agent, chat, project or provider connection, with its ref only (CoreDeletion). Use it to
clean up what you kept about one, such as a card assigned to an agent that is gone. A renamed agent
is not a deletion: store the agent's stable agentId, never its name. A task-board mini app of the
Localix marketplace can unassign a deleted agent's cards on every board this way, listing its chat
folders with ctx.sessionStorage.listDataDirs().
This feed is durable and for backend consumers. To refresh an open view after a store write, use the store change feed instead; Mini app synchronization explains the split.
Example
const host = ctx.host
host?.semantic?.registerSource({
collection: 'cards',
label: 'Cards',
version: 1,
scan: async (cursor, limit) => listCards(cursor, limit),
read: async (keys) => readCards(keys),
})
// A card was saved: tell the index, and anything else following your stream.
host?.semantic?.changed('cards', [card.id])
host?.changes?.record({ entity: 'card', op: 'upserted', ref: { id: card.id } })
// Which column does a new card belong in? The decision model first, else the nearest by meaning.
const outcome = await host?.decisions?.decide(
userId,
{
state: { card: { title: card.title, body: card.body } },
questions: {
column: { type: 'choice', instructions: 'Where does `card` belong?', criteria: columns },
},
},
{ purpose: 'triage', sessionId },
)
const column =
(outcome?.status === 'answered' ? confidentChoice(outcome.answers.column, 0.8) : null) ??
(
await host?.semantic?.rank(
card.title,
Object.entries(columns).map(([key, text]) => ({ key, text })),
{ task: 'classification' },
)
)?.ranked[0]?.key ??
'inbox'
const stop = await host?.changes?.subscribe('digest', async (change) => {
if (change.op === 'resync') await rebuildDigest()
else await refreshDigest(change.ref.id as string)
})
// An agent was deleted: nothing of yours may still name it.
await host?.changes?.followCoreDeletions('unassign', async (deletion) => {
if (deletion.entity === 'agent' && deletion.ref.agentId) await unassign(deletion.ref.agentId)
})Availability
ctx.semantic and ctx.decisions exist on every load; register sources and follow the change feed
from the boot-time backend load, like watchPath. Types:
semantic.ts,
decision.ts,
changes.ts.