How a skillpack adds a new kind of source, and what to do when one stops loading after an update.
An ingestion source is a small piece of code that watches somewhere (a folder, an app, an API) and hands new content to your brain. nexusbrain has built in sources, and a skillpack can ship its own, for example a source that pulls in meeting transcripts.
This guide explains how a skillpack declares a source, how nexusbrain finds and loads it, how to build and test one, and what to do when a source stops loading after you update nexusbrain.
nexusbrain --version works.nexusbrain/ingestion package path.A skillpack that brings in data is a folder with a manifest file named nexusbrain.plugin.json at its root. The manifest has an ingestion_sources list. Each entry points at a JavaScript or TypeScript module whose default export is a factory function: nexusbrain calls it with a config object and gets back a source.
nexusbrain finds skillpack folders through the NEXUS_BRAIN_PLUGIN_PATH environment variable. This is the same variable that plugin subagent definitions use, so one folder can ship both.
Once running, a source only does one thing: it calls ctx.emit(event) with each new piece of content. nexusbrain validates the event, drops duplicates, applies a rate limit and queues it for writing. A source never writes to the brain directly.
| Manifest field | Required | What it means |
|---|---|---|
name | Yes | The skillpack name. Used in warnings so you know which pack a problem came from. |
plugin_version | Yes | Must be exactly nexusbrain-plugin-v1. |
ingestion_sources[].kind | Yes | The source type, for example voice-granola. Must be unique across every loaded skillpack. |
ingestion_sources[].module | Yes | Path to the factory module, relative to the skillpack folder. It may not point outside the folder. |
ingestion_sources[].api_version | Yes | The source contract the pack was built against. Currently nexusbrain-ingestion-source-v1. |
ingestion_sources[].default_config | No | A plain object of default settings passed to the source as ctx.config. |
ingestion_sources[].permissions | No | A list of strings such as network. Shown for information only in this version; not enforced. |
Sources are loaded as ordinary modules in the same process as nexusbrain. Only point NEXUS_BRAIN_PLUGIN_PATH at skillpacks whose code you have read or trust.
The folder must contain nexusbrain.plugin.json at its top level and the module file named in module, for example ./dist/source.js.
It should look like this. The kind, module and default_config values are examples.
{
"name": "granola-source",
"plugin_version": "nexusbrain-plugin-v1",
"ingestion_sources": [
{
"kind": "voice-granola",
"module": "./dist/source.js",
"api_version": "nexusbrain-ingestion-source-v1",
"default_config": { "transcription_model": "whisper-1" },
"permissions": ["network"]
}
]
}NEXUS_BRAIN_PLUGIN_PATH.The value is a list of absolute paths separated by colons, like PATH. Relative paths, paths starting with ~, and URLs are rejected. When two skillpacks declare the same kind, the one listed first wins.
export NEXUS_BRAIN_PLUGIN_PATH="/home/me/packs/granola-source:/home/me/packs/other-pack"You should seeNothing is printed. The variable is read the next time nexusbrain loads skillpack sources.
The path list is split on colons, so a Windows path with a drive letter such as C:\packs is split in two. Use macOS, Linux or WSL paths.
The contract is exported from nexusbrain/ingestion. Treat it as a versioned public API: IngestionSource, IngestionEvent, IngestionSourceContext, computeContentHash, validateIngestionEvent and the INGESTION_SOURCE_API_VERSION constant.
The factory receives the merged config and returns an object with id, kind, start and stop. healthCheck is optional. start must resolve once the source is ready to emit. stop must finish in-flight work quickly; long waits should also listen to ctx.abortSignal.
import type { IngestionSource, IngestionSourceContext } from 'nexusbrain/ingestion';
import { computeContentHash } from 'nexusbrain/ingestion';
export default function createSource(config: Record<string, unknown>): IngestionSource {
let ctx: IngestionSourceContext | null = null;
return {
id: 'voice-granola-main',
kind: 'voice-granola',
async start(c) {
ctx = c;
const text = '# Meeting notes\n...';
ctx.emit({
source_id: 'voice-granola-main',
source_kind: 'voice-granola',
source_uri: 'granola://meeting/123',
received_at: new Date().toISOString(),
content_type: 'text/markdown',
content: text,
content_hash: computeContentHash(text),
});
},
async stop() {
ctx = null;
},
};
}Events that fail validation are dropped and logged, and the source keeps running. The rules come straight from the validator.
If start throws, nexusbrain restarts the source with exponential backoff, starting at 1 second and capped at 60 seconds. After 10 consecutive crashes the source is marked failed and not retried. A source that runs for 5 minutes before crashing has its crash count reset.
| Event field | Rule |
|---|---|
source_id, source_kind, source_uri | Non-empty strings. |
received_at | An ISO 8601 timestamp. Use new Date().toISOString(). |
content_type | One of text/markdown, text/plain, text/html, application/pdf, application/json, image/*, audio/*, video/*, unknown. |
content | Non-empty. Text for text types; an absolute path or data URI for binary types. |
content_hash | SHA-256 as 64 hex characters. computeContentHash(content) does this for text. |
untrusted_payload | Optional boolean. Set it to true when content came from the network or a URL. |
metadata | Optional plain object. Saved under ingestion_metadata in the page frontmatter. |
Duplicate events (same source_kind and content_hash) are dropped for 24 hours. Each source may emit up to 100 events every 10 seconds; extra events are dropped.
The package path nexusbrain/ingestion/test-harness gives you IngestionTestHarness and expectEvent. The harness passes the same validation as the real daemon but records events instead of writing them.
import { test, expect } from 'bun:test';
import { IngestionTestHarness, expectEvent } from 'nexusbrain/ingestion/test-harness';
import createSource from './source';
test('emits one valid event', async () => {
const harness = new IngestionTestHarness({ config: { transcription_model: 'whisper-1' } });
await harness.run(createSource({}));
expect(harness.validationErrors).toHaveLength(0);
expect(harness.events).toHaveLength(1);
expectEvent(harness.events[0]).toHaveKind('voice-granola');
expectEvent(harness.events[0]).toHaveContentHash();
await harness.stop();
});bun testYou should seeThe test passes. If an event was malformed, harness.validationErrors lists the field and the reason, for example content_hash: must be 64 lowercase hex characters (SHA-256).
expectEvent(e) also offers toHaveSourceId, toHaveSourceUri, toHaveContentType, toBeUntrusted, toBeTrusted and toHaveMetadata.ctx.engine, pass { engine } to the harness or the test fails with a message naming the property it tried to use.harness.stop() waits 5 seconds by default for the source to stop, then fails with source did not stop within 5000ms.The skillpack was built against a different version of the source contract than the nexusbrain you are running. nexusbrain skips that source.
Either install a version of the skillpack rebuilt against the current contract (ask the publisher), or go back to a nexusbrain version that matches the skillpack.
Set plugin_version in nexusbrain.plugin.json to exactly nexusbrain-plugin-v1.
Every entry in NEXUS_BRAIN_PLUGIN_PATH must be a full absolute path on disk. Expand ~ yourself.
Two skillpacks declare the same kind. The one listed first in NEXUS_BRAIN_PLUGIN_PATH is used. Remove or reorder one of them.
Check that module is a relative path inside the skillpack folder and that the file exists (build the pack first if it points into dist).
Make sure the module uses export default function createSource(config) { ... }.