Writing an exporter¶
An exporter walks the particle store and writes an external representation — Obsidian vault, Anki deck, JSON Lines file, …
The contract (in 30 seconds)¶
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from particles.exporters.summaries import BaseExporterSummary
class MyExporter:
FORMAT = "myformat" # unique lowercase slug
async def export(
self,
session: AsyncSession,
output: Path | None,
**options: object,
) -> BaseExporterSummary:
...
return MyFormatSummary(...)
Plus a Pydantic MyFormatSummary(BaseExporterSummary) in
particles/exporters/summaries.py. Add one line to
particles/exporters/registry.py::_make_exporters(). Done.
Worked examples in the tree¶
| Output shape | File | Notes |
|---|---|---|
Directory of .md files (vault) |
particles/exporters/obsidian/ |
Template dispatch; pivot vs coin vs generic |
| Single flat file (deck) | particles/exporters/anki.py |
Cards from properties or content |
| Directory of cited articles | particles/exporters/wiki.py |
LLM synthesis via shared article_synthesis/ |
| Directory of bullet-outline pages | particles/exporters/logseq/ |
Logseq's native format; particle IDs as block UUIDs for cross-page citation |
| External HTTP API (no file) | particles/exporters/notion.py |
The first API-target exporter; idempotent upsert into one Notion database. Reference for the credential pattern below. |
Single self-contained .html graph |
particles/exporters/graph/ |
Scoped epistemic graph view: mandatory scope + disclosed caps; vendored Cytoscape.js inlined so the artifact works offline. What it renders and why is described in User guide → graph view; and the flags it accepts are in that page's options table. |
Cross-exporter contract¶
Every shipped exporter — and yours — must honour two options :
| Option | Type | Default | Behaviour |
|---|---|---|---|
min_particle_confidence |
float |
0.0 |
Drop particles below this effective_confidence BEFORE any per-exporter step. Filter input is effective confidence, never raw confidence.value. |
min_particles |
int |
per-exporter | Minimum post-filter particle count required to render a subject. |
Your summary must include particles_dropped_below_threshold: int
when the threshold is non-zero (inherited from BaseExporterSummary).
Both options reach you from two directions, and it is worth seeing each:
the operator sets a standing floor in config.yaml
(Operator guide → cross-exporter quality threshold),
and the user overrides it per run with --min-particle-confidence
(User guide → exporting). Filter on
effective_confidence, never on the stored confidence.value — the two are
deliberately different quantities.
Output shape¶
The output path is:
- a directory for vault-style outputs (Obsidian, Wiki)
- a file for single-file outputs (Anki, JSON Lines)
- None for API-based outputs (Notion, GitHub Pages push)
Your exporter is responsible for output.parent.mkdir(...) / atomic
writes / cleanup of stale entries from prior runs.
API-target exporters & credentials¶
If your exporter writes to an external HTTP API (it has output=None)
and needs a credential, follow the Notion exporter's pattern:
- Add a
get_<x>_api_key[_optional]()getter toparticles/secrets.py(raise when the target has no anonymous mode, optional when it does). The token lives only there — never inconfig.yaml, never onParticlesConfig, never a CLI flag. - Set a class attribute
REQUIRES_SECRET = "<ENV_VAR>"so the CLI pre-flight verifies the env var is present before any work, and call the getter as the first statement ofexport()(the authoritative, partial-write-proof check). - Put non-secret target parameters (ids, property names) in a config
sub-model +
config.yaml.sample. - Make
--dry-runissue zero API writes.
The worked reference is
particles/exporters/notion.py;
the operator's side of the same credential is
Operator guide → configuration → secrets.
Reaching across the seam¶
Exporters may call store/ helpers directly for simple list / get
queries (the rule of thumb: the second time you copy a query, lift
it into store/). The shared helpers already exposed:
get_particles_by_status(session, Status.ACTIVE)— ACTIVE particleslist_all_subjects(session)— every Subjectlist_particle_subject_pairs(session)— the join tableget_entry_uri_map(session, entry_ids=None)— corpus URLs
Don't recreate these in your exporter.
Synthesis-ready exporters¶
If your exporter wants per-Subject LLM-synthesised prose, import
from particles/exporters/article_synthesis/ rather than
reimplementing. The helper provides the cache key, citation
validation, Layer-B judge, and the fallback structured-listing
render. The Obsidian, Wiki, and Logseq exporters all share this
machinery.
The canonical contract is the protocol itself, in
particles/exporters/registry.py.