Skip to main content
Semantic hooks let you react to what Khora extracts, in real time, as documents are ingested or recalls run. Subscribe to an event type, optionally attach a filter, and your callback fires when a matching event passes. Use them for notifications, dashboards, or downstream pipelines.
subscribe(event_type, callback, filter=None) returns a subscription id. unsubscribe(id) removes it. kb.hooks exposes the underlying dispatcher.

The 3-level filter cascade

Filters are evaluated cheapest-first. Each level only runs if the previous passed, so you pay LLM cost only for genuinely ambiguous cases:

Level 0: types and the match DSL

Type filters need no computation. The match field adds EventBridge-style structural patterns over event.data (pure data, no code execution):
Operators include prefix, suffix, equals-ignore-case, wildcard, numeric, anything-but, exists, and contains-all. Top-level $or gives disjunction, other keys combine with AND. Nested dot-notation is intentionally unsupported, so pre-flatten anything you want to match into event.data.

Level 1: embedding pre-screen

Give the filter a description and Khora compares its embedding against the entity/relationship embedding (binary-quantized Hamming, then cosine on survivors):

Level 2: LLM evaluation

Level 2 is default OFF (KHORA_HOOKS_LLM_EVALUATION_ENABLED=true to enable) and costs real money, a nano-LLM call per passing event. It only fires when the filter supplies examples (without them the LLM has no calibration and is skipped).

Event types

Subscribe to any of these stable string event types (canonical enum: EventType in khora.core.models.event):

Co-occurrence filtering

A single entity.created event carries one entity, so the match DSL can’t express “alert when X and Y appear in the same chunk.” Subscribe to chunk.entities_resolved instead. It fires once per chunk with the full set under event.data["entity_ids"] and entity_names_by_type, and you do the set check in your callback:

Persistent subscriptions

subscribe() registers an in-process callback: it lives in memory and dies with the process. A persistent subscription records a delivery target to PostgreSQL instead, so it survives a restart. Khora reloads persistent subscriptions on connect() and matches them through the same filter cascade.
subscribe_persistent(event_type, delivery, *, filter=None, namespace_id=None) returns the subscription UUID and needs a SQL backend wired at connect() (it raises RuntimeError on a store-less stack). unsubscribe_persistent(id) returns whether a subscription was removed. The delivery dict is an opaque target (a webhook URL, a queue identifier, whatever your infrastructure uses). Khora stores it and hands it back on a match, but it does not ship the webhook or queue worker itself. Wire a delivery sink into the dispatcher to forward matched events to that target.

Use case: a proactive agent that watches its own memory

Give an agent a long-term memory and the obvious design is a poll loop: every few seconds the agent re-asks its memory “has anything relevant to my goal shown up?” That’s the wrong shape for a memory that’s constantly being written to. It’s polling a firehose. Semantic hooks invert it. The agent registers its interest once (a standing subscription describing what would matter to it), and Khora calls back the instant ingestion produces a matching entity or relationship. The agent’s memory stops being something it has to interrogate and becomes something that notifies it.
Why a hook beats a poll loop here:
  • No polling: less cost, simpler architecture. A poll re-runs a search over the entire memory on every tick, whether or not anything changed, paying for embeddings and graph traversal again and again, and forcing you to build the scaffolding around it: a scheduler, a “what’s new since last time?” cursor, and de-duplication so the agent doesn’t re-fire on facts it already handled. A hook removes all of it. The filter is evaluated once per new fact, at ingest time, and its first level (type / structural match) is free. Cost tracks new information, not corpus-size × poll-frequency, and there’s no loop, no cursor, no dedupe bookkeeping to maintain.
  • Quick reaction. A poll can only react on its next tick, so your worst-case latency is the whole poll interval. The agent is always a beat behind. A hook fires inline, the instant the matching entity or relationship is created, so the agent responds in real time. For anything where lateness costs you (a renewal slipping, a risk signal, a competitor move), that gap is the difference between acting and reacting.

Cost controls

Level 2 ships three layers of protection, all OpenTelemetry-instrumented:
  1. Default-OFF gate: Level 1 is final unless you explicitly enable LLM evaluation.
  2. Token budgets: rolling-hour caps, per-namespace (llm_max_tokens_per_namespace_per_hour, default 10000) and per-subscription (default 0 = off, so one noisy filter can’t drain the namespace). On breach, the batch fails open (preserves the Level 1 match) and emits khora.hooks.llm.throttled_total.
  3. Decision cache + coalescing: identical events short-circuit the LLM (TTL + LRU cache keyed on a hashed event summary; a burst of 50 identical events → 1 LLM call).
Tune everything through KHORA_HOOKS_* env vars (ENABLED, FILTER_MODEL default gpt-4.1-nano, DEFAULT_SIMILARITY_THRESHOLD, MAX_CONCURRENT_CALLBACKS, CALLBACK_TIMEOUT_SECONDS, the LLM/budget/cache knobs).

Examples

See the whole pipeline in runnable tutorials: remember, recall, abstain, forget.

Ingestion

Where extraction events fire during the write path.