Skip to content

Quickstart: your first memory

You will stand up a working ZettelForge instance, store three pieces of threat intelligence about APT28 and Lazarus Group, and recall them by natural-language query and by actor name. The whole path runs locally with no cloud account and no API keys.

What you learn

  • Storing CTI facts with remember()
  • Recalling notes with recall() (blended vector plus graph retrieval)
  • Looking up an actor directly with recall_actor()

Prerequisites

  • Python 3.10 or newer

That is the entire list. The default install runs embeddings in-process and stores everything in a local SQLite database plus a LanceDB vector index. You do not need Docker, TypeDB, Ollama, or any API key for this tutorial.


Step 1: Install ZettelForge

Clone the repository and install it into a virtual environment.

git clone https://github.com/rolandpg/zettelforge.git
cd zettelforge
python3 -m venv .venv
source .venv/bin/activate
pip install -e .

A virtual environment is required on modern Linux distributions (Debian, Ubuntu, and others mark the system Python as externally managed under PEP 668, so a bare pip install is refused). The virtual environment is also the cleanest way to keep ZettelForge isolated from your system packages.

Confirm the install:

python -c "import zettelforge; print(zettelforge.__version__)"

You see the installed version:

2.7.0

Step 2: Store your first memories

The embedding model downloads automatically the first time you construct a MemoryManager. This is a one-time download; subsequent runs load it from cache.

Create a file called quickstart.py and run it.

from zettelforge import MemoryManager

mm = MemoryManager()

note1, status1 = mm.remember(
    "APT28 (Fancy Bear) deployed a modified X-Agent implant against "
    "Ukrainian government networks in March 2026. The implant used "
    "CVE-2025-21298 for initial access via spearphishing attachments.",
    source_type="report",
    source_ref="https://example.com/apt28-ukraine-2026",
    domain="cti",
)
print(f"Note 1: {note1.id} -- {status1}")

note2, status2 = mm.remember(
    "Lazarus Group conducted Operation DreamJob targeting defense "
    "contractors in South Korea and Japan during Q1 2026. The campaign "
    "used trojanized job offer PDFs delivering the DreamJob backdoor "
    "via LinkedIn messages.",
    source_type="report",
    source_ref="https://example.com/lazarus-dreamjob-2026",
    domain="cti",
)
print(f"Note 2: {note2.id} -- {status2}")

note3, status3 = mm.remember(
    "APT28 was also observed using Cobalt Strike beacons for lateral "
    "movement after initial X-Agent deployment. The beacons called back "
    "to infrastructure hosted on bulletproof providers in Moldova.",
    source_type="report",
    source_ref="https://example.com/apt28-cobalt-strike",
    domain="cti",
)
print(f"Note 3: {note3.id} -- {status3}")

Output:

Note 1: note_20260615_190726_70bd -- created
Note 2: note_20260615_190726_8dda -- created
Note 3: note_20260615_190729_a650 -- created

remember() returns a (MemoryNote, status) tuple. Your note IDs follow the pattern note_<timestamp>_<suffix> and will differ from the ones above. The created status confirms each note was embedded, written to storage, and entity-indexed (actors, tools, CVEs, and campaigns) on the fast write path.

Why the fast path matters

remember() returns as soon as the note is embedded, stored, and heuristically entity-indexed. Deeper LLM enrichment (causal triple extraction, note evolution) runs in a background worker and is optional, so storing intelligence never blocks on an LLM call. This is why the core of ZettelForge works fully offline.

Step 3: Recall by query

Ask a natural-language question. recall() classifies your intent, then blends vector similarity with knowledge-graph traversal and returns the most relevant notes.

results = mm.recall("What tools does APT28 use?", domain="cti", k=5)

for note in results:
    print(f"[{note.id}] {note.content.raw[:110]}...")

Output:

[note_20260615_190729_a650] APT28 was also observed using Cobalt Strike beacons for lateral movement after initial X-Agent deployment. The...
[note_20260615_190726_70bd] APT28 (Fancy Bear) deployed a modified X-Agent implant against Ukrainian government networks in March 2026. Th...
[note_20260615_190726_8dda] Lazarus Group conducted Operation DreamJob targeting defense contractors in South Korea and Japan during Q1 20...

The two APT28 notes rank above the Lazarus note because graph retrieval boosts notes that share entities with your query. The Cobalt Strike note leads because it sits closest to "tools" in both the vector space and the entity graph.

Step 4: Recall by actor name

When you already know the actor, skip the query and look it up directly. recall_actor() reads the entity index instead of running a vector search.

apt28_notes = mm.recall_actor("APT28", k=5)

for note in apt28_notes:
    print(f"[{note.id}] {note.content.raw[:110]}...")

Output:

[note_20260615_190726_70bd] APT28 (Fancy Bear) deployed a modified X-Agent implant against Ukrainian government networks in March 2026. Th...
[note_20260615_190729_a650] APT28 was also observed using Cobalt Strike beacons for lateral movement after initial X-Agent deployment. The...

Both APT28 notes come back. recall_cve() and recall_tool() work the same way for CVEs and tools.

Alias resolution needs the LLM enrichment path

Looking up a canonical name (APT28, case-insensitive) resolves from the offline entity index. Resolving an alias to its canonical actor (for example Fancy Bear to APT28, or a name the seeded index does not yet hold such as Lazarus Group) relies on the background LLM enrichment path. To get full alias resolution, configure an LLM provider in config.yaml. See the Configuration reference.

What you built

You now have a working ZettelForge instance with:

  • Three stored notes about APT28 and Lazarus Group activity, embedded and persisted to a local SQLite database and LanceDB vector index
  • Entity index entries for actors, tools (X-Agent, Cobalt Strike), CVEs (CVE-2025-21298), and campaigns (Operation DreamJob)
  • Blended retrieval that combines vector similarity with knowledge-graph traversal so related intelligence surfaces together

All of it runs on your own hardware with no external service.

Next steps

  • Synthesize answers from memory. mm.synthesize(query, format="direct_answer") retrieves relevant notes and asks an LLM to fuse them into a single answer with source attribution. It returns a dictionary shaped {"synthesis": {"answer", "confidence", "sources"}, "metadata": {...}, "sources": [...], "query", "format"}. Synthesis is the one step that requires a configured LLM provider: without one, synthesize() returns a well-formed response with an empty answer and confidence of 0.0. Set llm.provider and any required key in config.yaml, then call it. The extended synthesized_brief, timeline_analysis, and relationship_map formats are not part of the open-source core and fall back to direct_answer.
  • Ingest your first CTI report: chunk and store a long-form threat report with remember_report().
  • Memory Manager API reference: every public method, argument, and return shape.
  • Why the hybrid TypeDB plus LanceDB architecture: the design rationale behind blended retrieval.

ZettelForge is the open-source core documented on this site. ThreatRecall.ai is the hosted SaaS built on the same engine for teams that want managed recall without running infrastructure.