CLI
  • Curio
  • CLI
  • Forums
  • Support
  • Examples
  • Docs
  • Release Notes
  • Examples
  • Docs
  • Release Notes
  • Download
Curio CLI Examples/mycelium Integration

Integration

mycelium Integration

How John uses the Curio CLI to make his project notebooks searchable alongside bookmarks, web clippings, and other media in his personal search service.

Status
Community
Updated
September 14, 2026
By
John; standalone example adapted by Zengobi
Curio read-only Curio 34.0+ · Python 3.10+
Download Curio search exporter v1.0.0 4 KB View complete source

Gallery

Search results from Curio and other sources, alongside the original research material in Curio.

John organizes home schooling calendars and worksheets in his Curio notebook.

Equipment photos, manuals, and reference material collected in one idea space.

John’s mycelium demonstration

Watch on Vimeo

One search across a personal collection

John keeps his personal notes and documents in Curio and uses several services he built to collect bookmarks, web clippings, and other media. He wanted to search across all of them, including the notes in his Curio project notebooks.

With help from Claude Code, he created mycelium, a service that imports and indexes material from those different sources. His goal is to give people more control over their own data. Curio remains where he organizes and works with his notes; mycelium makes those notes searchable alongside the rest of his collection.

Watch John's mycelium demonstration on Vimeo, including its Curio integration. This is an early alpha of his personal service, and he expects to expand its media coverage and refine the interface over time.

The prompt that started the integration

After Curio 34 shipped, John gave his agent this prompt:

I want to add this directory @/Volumes/path/to/data/Notes.curio it is a curio file. There should be an agent for this. If you need more information please see: https://www.zengobi.com/curio/cli/

He reports that he had the integration working in about an hour. The CLI gave the agent access to the project's text, structure, metadata, attachment references, and links back to Curio.

Read the project with one CLI command

The integration begins with a query against a saved project:

curio query "not kind=nothing" \
  --fields "id,kind,title,plain_text,note,tags,section_path,organizer_path,link,modified_date,added_date,asset_id,asset_file,asset_file_kind,url,parent_id" \
  --project "/path/to/Notes.curio"

Using --project selects direct, read-only access to the saved project. Curio does not need to be running. Save your changes before exporting so the index reflects the current contents on disk.

The JSON response includes response_ok and result.items. The broad query returns organizer items and figures, giving the importer enough context to associate text with its idea space. plain_text supplies searchable text from figures using rich text or Markdown, your choice. Notes, tags, section and organizer paths add context, while link preserves the route back to the original item.

From Curio items to search documents

John's adapter creates two kinds of documents:

  • Idea-space notes. Each idea space becomes a document containing its title, note, figure text, tags, and location in the project. Its search result carries the idea space's curio:// link.
  • Attached files. Distinct referenced files are sent to mycelium's shared extractors for formats such as PDF, images, Office documents, and audio. Those documents retain a link to a referencing figure in Curio.

The CLI supplies project content and file references. Mycelium supplies the search index and the separate document extraction, OCR, and transcription services. A file's caption in the query output is not a substitute for extracting the contents of that file.

The original adapter also handles incremental indexing, cached extraction, and paced processing of larger collections. It checks exported data before ingesting it and refuses to emit deletions when a project's document count drops unexpectedly.

A foundation for Retrieval-Augmented Generation (RAG)

Retrieval-Augmented Generation (RAG) is a technique in which an AI system retrieves relevant material from a collection of documents and supplies it to a language model as context for generating an answer. This lets the model draw on your own notes and reference material when responding to a question.

John originally approached this integration with RAG in mind. Making Curio content searchable alongside his other sources provides the retrieval foundation for that workflow. An application can then pass relevant results to a language model and retain the Curio links as references back to the source material. The example here covers exporting content for indexing; answer generation would be supplied by the application using that index.

Try the standalone example

The download adapts the CLI export and note-building portions of John's contribution into a Python script with no third-party dependencies. It writes JSON Lines, with one note or attachment-reference record per line, ready for an indexer of your choice.

Install the CLI from Curio > Install Curio CLI, then use Python 3.10 or later:

python3 curio_export.py --project "/path/to/Notes.curio" \
  --name notes --output notes.jsonl

Use a unique, stable --name for each project so records from different projects have distinct IDs. Choose an existing output directory outside the project. The script checks the CLI response and replaces the output only after a successful conversion.

You can also convert a saved response on a machine that does not have Curio installed:

python3 curio_export.py --export Notes.json \
  --name notes --output notes.jsonl

This example produces note text and deduplicated attachment references. It does not include the mycelium service, build a search index, extract attachment contents, or install a scheduled job. Its README explains the record format and what an indexer needs to add.

Index on a server, open results on a laptop

John runs mycelium on a Mac Studio but works on his Curio project on his laptop. A daily rsync job copies the project to the server. A separate scheduled export runs the macOS Curio CLI there; the containerized service consumes the JSON and reads the project's attached files through a read-only mount.

In his setup, clicking a result on the laptop opens the corresponding item in his local project. Preserve the CLI-provided curio:// links rather than constructing links from server file paths. His server copy comes from the same project and retains its item identifiers; a matching filename alone should not be treated as a guarantee that an unrelated project will resolve the same links.

The supplied server export script also includes a staged-copy fallback for its particular file-access environment. That deployment machinery is omitted here. The standalone example reads the project directly, and the CLI must have permission to read its location.

For a container-based importer, attachment paths from the Mac may need to be mapped to the container's read-only mount. John's adapter resolves asset IDs within the mounted project when necessary and skips aliases that point outside it. Keep those boundaries when adding attachment extraction to your own importer.

About this contribution

John shared his integration notes and source with permission to feature them here. The downloadable script is a standalone adaptation prepared by Zengobi from that material. The video shows his broader service; the example focuses on the Curio CLI portion that other developers can reuse.

Inspect before downloading

Complete source

2 files

Every text file included in the download is available here. Open a filename to inspect its contents.

README.md3 KB
# Curio export for personal search

Adapted by Zengobi from John's mycelium integration, shared with permission.
See the walkthrough: https://www.zengobi.com/curio/cli/examples/?item=mycelium
Video: https://vimeo.com/1224764392/803afa7ec8

## Run

Requires Python 3.10+ and, for a live export, macOS with the Curio CLI installed
from Curio 34 or later. No Python packages are required. Save the project first.

```bash
python3 curio_export.py --project "/path/to/Notes.curio" --name notes --output notes.jsonl
```

Alternatively, pass `--export Notes.json` instead of `--project` to consume the
JSON envelope from the CLI query shown in the walkthrough. Conversion of saved
JSON does not require macOS or the CLI. Use an existing output directory.

## Output

Each line is a JSON object. `note` records include a project-namespaced ID,
title, body, tags, section/organizer paths, dates, and a Curio deep link in `url`.
Figures are grouped by idea space, following parent IDs for nested collections.
Text stays in export order; collection text may overlap its children's text.
Dates retain Curio's source representation; the script does not assume UTC.

`asset_reference` records include the asset ID, exported file path and kind,
referencing figure IDs, and the first referencing figure's Curio link. They are
deduplicated by asset ID (falling back to file path), not by file contents.
The script does not open attachment files or follow aliases.

## Connect an indexer

Index note title, body and tags; retain `url` as the result's open-in-Curio target.
For attachments, add your own format-specific text extraction, OCR or
transcription. Validate resolved paths against your project mount before reading
files, skip external aliases, and map host paths when working in a container.
`asset_file` describes the exported environment, not necessarily the current one.

Choose a unique, stable `--name` for each project. Reuse it on subsequent exports
so an indexer can upsert by `external_id`. This script exports a full snapshot;
it implements neither incremental indexing nor index deletion. Before deleting
old index entries, validate the snapshot and check for unexpected count drops.
The script rejects failed/malformed responses and exports without idea spaces,
and atomically replaces the output only after conversion succeeds.

## Scope

This is a standalone adaptation, not John's original mycelium adapter. It omits
his service-specific imports, extractors, caches, database, scheduler, deployment
configuration, rsync, and staged-copy fallback. It calls the CLI directly with a
15-minute timeout. It does not reproduce his network-denying sandbox wrapper;
normal CLI behavior, including any update checks, still applies.

The CLI reads the saved project without modifying it. The only persistent output
created by this script is the specified JSONL file, which contains your exported
project data. Nothing in this script uploads that output.
curio_export.py7 KB
#!/usr/bin/env python3
"""Export Curio notes and attachment references for a search index.
Adapted by Zengobi from John's mycelium integration, shared with permission.
Python 3.10+, standard library only. See README.md for scope and usage.
"""
from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile

FIELDS = ('id,kind,title,plain_text,note,tags,section_path,organizer_path,link,'
          'modified_date,added_date,asset_id,asset_file,asset_file_kind,url,parent_id')
SPACE = 'organizerideaspace'
ORGANIZERS = {SPACE, 'organizersection', 'organizerfolder', 'organizeralias', 'organizerdocument'}
TEXT_KINDS = {'text', 'indexcard', 'list', 'table', 'stack', 'mindmap', 'pinboard',
              'group', 'album', 'tabloid', 'matrix', 'ideaspacelink'}
LINK = re.compile(r'curio://[A-Za-z0-9._~%+-]+\?[A-Za-z0-9=&_.%~+-]+')


def clean(value):
    return value.strip() if isinstance(value, str) else ''


def tags(item):
    values = item.get('tags')
    return {v.strip() for v in values if isinstance(v, str) and v.strip()} if isinstance(values, list) else set()


def link(item):
    value = clean(item.get('link'))
    return value if LINK.fullmatch(value) else None


def load_items(stream):
    raw = stream.read(64_000_001)
    if len(raw) > 64_000_000:
        raise ValueError('Export exceeds 64 MB')
    data = json.loads(raw)
    if not isinstance(data, dict) or data.get('response_ok') is not True:
        raise ValueError('CLI export did not report response_ok: true')
    result = data.get('result')
    items = result.get('items') if isinstance(result, dict) else None
    if not isinstance(items, list) or len(items) > 200_000:
        raise ValueError('Missing or oversized result.items')
    if any(not isinstance(i, dict) or not isinstance(i.get('id'), str) for i in items):
        raise ValueError('Every item must have a string id')
    return items


def documents(items, project):
    by_id = {i['id']: i for i in items}
    spaces = {i['id']: i for i in items if i.get('kind') == SPACE}
    groups = {sid: [] for sid in spaces}
    assets = {}
    for item in items:
        if item.get('kind') in ORGANIZERS:
            continue
        # Follow ancestors for figures inside collections; guard malformed cycles.
        parent = item.get('parent_id')
        seen = set()
        while isinstance(parent, str) and parent not in spaces and parent in by_id and parent not in seen:
            seen.add(parent)
            parent = by_id[parent].get('parent_id')
        if isinstance(parent, str) and parent in groups:
            groups[parent].append(item)
        aid = clean(item.get('asset_id')) or clean(item.get('asset_file'))
        if aid and item.get('kind') not in TEXT_KINDS:
            asset = assets.setdefault(aid, {
                'type': 'asset_reference', 'external_id': f'{project}:asset:{aid}',
                'project': project, 'title': clean(item.get('title')),
                'asset_id': item.get('asset_id'), 'asset_file': item.get('asset_file'),
                'asset_file_kind': item.get('asset_file_kind'), 'url': link(item),
                'figure_ids': [],
            })
            asset['figure_ids'].append(item['id'])
    for sid, space in spaces.items():
        parts = [clean(space.get('note'))]
        all_tags = tags(space)
        dates = [clean(space.get('modified_date'))]
        for figure in groups[sid]:
            parts.extend([clean(figure.get('plain_text')) or clean(figure.get('title')),
                          clean(figure.get('note')), clean(figure.get('url'))])
            all_tags.update(tags(figure))
            dates.append(clean(figure.get('modified_date')))
        yield {
            'type': 'note', 'external_id': f'{project}:is:{sid}', 'project': project,
            'title': clean(space.get('title')) or 'Untitled',
            'body': '\n\n'.join(p for p in parts if p), 'tags': sorted(all_tags),
            'section_path': clean(space.get('section_path')),
            'organizer_path': clean(space.get('organizer_path')),
            'url': link(space), 'created_at': space.get('added_date'),
            'updated_at': max(dates) or None,
        }
    yield from assets.values()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument('--project', type=Path, help='Read a saved .curio project on this Mac')
    source.add_argument('--export', type=Path, help='Read a previously saved CLI JSON response')
    parser.add_argument('--name', required=True, help='Unique, stable project name for index IDs')
    parser.add_argument('--output', type=Path, required=True, help='Destination JSON Lines file')
    args = parser.parse_args()
    try:
        if args.project:
            project = args.project.expanduser().resolve(strict=True)
            if project.suffix.lower() != '.curio' or not project.is_dir():
                raise ValueError('--project must be a .curio project directory')
            # Capture to a temporary file so a large response does not fill memory.
            with tempfile.TemporaryFile() as response:
                subprocess.run(['curio', 'query', 'not kind=nothing', '--fields', FIELDS,
                                '--project', str(project)], stdout=response, check=True, timeout=900)
                response.seek(0)
                items = load_items(response)
        else:
            with args.export.expanduser().open('rb') as response:
                items = load_items(response)
        if not any(i.get('kind') == SPACE for i in items):
            raise ValueError('No idea spaces found; check the project and file access permissions')
        output = args.output.expanduser().resolve()
        if args.project and (output == project or project in output.parents):
            raise ValueError('Choose an output location outside the Curio project')
        if args.export and output == args.export.expanduser().resolve():
            raise ValueError('Output must differ from the input export')
        # Replace only after a complete successful conversion. The temp file is private.
        temporary = None
        try:
            with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=output.parent,
                                             delete=False) as stream:
                temporary = Path(stream.name)
                count = 0
                for document in documents(items, args.name):
                    stream.write(json.dumps(document, ensure_ascii=False) + '\n')
                    count += 1
            os.replace(temporary, output)
        finally:
            if temporary is not None:
                temporary.unlink(missing_ok=True)
        print(f'Wrote {count} records to {output}', file=sys.stderr)
    except (OSError, ValueError, subprocess.SubprocessError) as exc:
        parser.exit(1, f'Export failed: {exc}\n')


if __name__ == '__main__':
    main()
SHA-2568251cc4f5a9ade9a4cba51cea1e5ca6c283d0189ddf0ba1c11e1561c58001b8d

  • Copyright © Zengobi, Inc.
  • Contact Us
  • Terms of Use
  • Privacy Policy