Description
n8n-nodes-keyvalue
A filesystem-based key-value store and trigger node for n8n. Store, retrieve, list, and delete key-value pairs using plain directories and text files. Includes full-text search, vault navigation, tag management, and YAML frontmatter metadata. Also includes a trigger node that watches directories for new or changed records.
~/.n8n-keyvalue/ ← your store root
├── customers/ ← a directory
│ ├── alice ← a record (text file)
│ └── bob
└── orders/
├── order_001
└── order_002
Installation
Install from the n8n community nodes panel, or:
npm install n8n-nodes-keyvalue
No external dependencies — uses only Node.js built-in modules (fs, path, os).
> Note: This node operates on the local filesystem and cannot run on n8n Cloud.
Quick Start
1. Add a KeyValue node to your workflow.
2. Select Directory → Create, enter my_dir.
3. Add another KeyValue node, select Record → Write, enter directory my_dir, key greeting, value Hello n8n!.
4. Add a third node, Record → Read, same directory and key — outputs Hello n8n!.
Operations Reference
Counter Resource
| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| Get | Counter Name (required) | Reads the current counter value | { "counter": "name", "value": 42 } |
| Increment | Counter Name, Increment By, Start At | Increments a counter and returns the new value. Creates it at startAt on first call | { "counter": "name", "value": 43 } |
| Reset | Counter Name, Reset To | Resets a counter to a target value | { "counter": "name", "value": 0 } |
- Increment By (default
1) — amount to add each call. - Start At (default
0) — value to create the counter at if it doesn’t exist yet. Ignored once the counter exists. - Reset To (default
0) — value to set the counter to on reset. - Counters are stored as plain text files under
counters/in the base directory. - Get and Reset on a non-existent counter throw an error.
- Directory Filter (Directory → List) — glob pattern on directory names.
matches any characters,?matches one. Example:prodmatchesprodeu,prod_us. - Key Filter (Record → List, Count, Delete) — glob pattern on filename.
matches any characters,?matches one. Example:usermatchesuseralice,user_bob. - Value Filter (Record → List, Delete) — substring match inside file content. Example:
activereturns only records containing “active”. - Tag Filter (Record → List, Count, Delete) — exact match on a frontmatter tag. Only matches records whose
tagsarray contains this value. Reads file content when enabled. - All filters combined use AND logic. Empty = match all.
Directory Resource
| Operation | Field | Description | Output |
|———–|——-|————-|——–|
| Create | Directory Name (required) | Creates a new directory | { "directory": "name", "created": true } |
| Delete | Directory Name (required) | Deletes a directory and all its records | { "directory": "name", "deleted": true } |
| List | — | Lists all directories | [{ "directory": "name1" }, { "directory": "name2" }] |
Record Resource
| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| Append | Directory Name, Key, Value, Separator | Appends value to a record (creates if missing) | { "directory": "name", "key": "k", "value": "...", "appended": true } |
| Count | Directory Name, Key Filter, Tag Filter | Counts records with optional glob and tag filters | { "directory": "name", "count": 5 } |
| Delete | Directory Name, Key Filter, Value Filter, Tag Filter | Deletes records matching filters. At least one filter required. Use “*” for all records | [{ "directory": "name", "key": "k", "deleted": true }] |
| Exists | Directory Name, Key | Checks if a record exists without throwing an error | { "directory": "name", "key": "k", "exists": true } |
| List | Directory Name, Key Filter, Value Filter, Tag Filter, Include Frontmatter | Lists records with optional glob, content, and tag filters | [{ "directory": "name", "key": "k", "value": "..." }] |
| Read | Directory Name, Key, Mode | Reads a record. Mode: Full (default) / Frontmatter Only / Body Only. JSON auto-parsed | { "directory": "name", "key": "k", "value": "...", "frontmatter": {...}, "body": "..." } |
| Touch | Directory Name, Key | Updates mtime. Creates empty record if key does not exist | { "directory": "name", "key": "k", "touched": true, "created": false } |
| Write | Directory Name, Key, Value, Frontmatter (collection) | Creates/overwrites. Optional YAML frontmatter metadata | { "directory": "name", "key": "k", "value": "...", "written": true } |
List Filters
Frontmatter (YAML Metadata)
Records can optionally include YAML frontmatter — structured metadata stored as --- delimited blocks at the top of the file:
---
tags: [n8n, error-handling, critical]
description: Error format rules for n8n nodes
updated: "2026-06-29T14:30:00Z"
---
Error must be at top level of INodeExecutionData.
Write with frontmatter: expand the Frontmatter collection on Record → Write. Fields: Tags (comma-separated), Description, Updated (ISO timestamp — auto-filled if empty).
Read modes: Record → Read offers three modes via the Mode dropdown:
value, frontmatter, and bodyfrontmatter metadata (no content I/O for body)value (backward compatible)List with frontmatter: enable Include Frontmatter to return frontmatter per item. Use values via {{ $json.frontmatter.tags }}.
The value field is always the clean content (body), never polluted by YAML metadata. Files without frontmatter return frontmatter: null — fully backward compatible.
Append Separator
The Separator field on Record → Append defaults to a newline. You can type \n for a newline, use , for CSV-style, or leave empty for direct concatenation.
KeyValue Trigger Node
The KeyValue Trigger node watches a directory for new or changed records and triggers your workflow when changes are detected. Place it at the start of a workflow — it requires no upstream input.
Trigger Operations
| Field | Type | Required | Default | Description |
|——-|——|———-|———|————-|
| Directory Name | string | ✅ | '' | Directory to watch for changes |
| Poll Time | number | ❌ | 30 | Interval in seconds between each scan. Minimum: 5 seconds |
| Watch Events | multiOptions | ✅ | ['add', 'change'] | File Created — new record files ; File Changed — modified record files |
| Key Filter | string | ❌ | '' | Glob pattern to filter records by filename. Leave empty to match all |
| Include Content | boolean | ❌ | true | Read and return the content of detected records. Disable for faster scanning |
Trigger Output
Each detected file produces one item:
{
"directory": "incoming",
"key": "order_001.json",
"event": "add",
"value": { "id": 1, "total": 99.99 },
"mtimeMs": 1719403200000
}
When no changes are detected, the scan is silent (no execution).
Trigger Quick Start
1. Add a KeyValue Trigger node to a new workflow.
2. Set Directory Name to my_queue, Poll Time to 15.
3. Execute the workflow — it activates and waits.
4. In a separate workflow, use KeyValue (Record → Write) to create a file in my_queue/.
5. Within 15 seconds, the trigger workflow fires with event: "add".
Trigger Examples
KeyValue Trigger (directory: "orders", Poll Time: 10, events: add + change)
→ IF ({{ $json.event }} = "add")
→ HTTP Request (POST to fulfillment API)
KeyValue Trigger (directory: "logs", Key Filter: "error_*", Include Content: true)
→ Email (send alert with file content)
Polling Limitation
The trigger uses time-based polling (setInterval). It compares file modification timestamps (mtime) between scans, not individual write operations:
change event.Poll Time: 15 → at most 1-2 events captured.Poll Time (minimum 5s) for high-frequency use cases.State File
The trigger stores its state in .keyvalue-watch-state.json inside the watched directory:
{ "filename": 1719403200000, ... }
It tracks mtimeMs for each file. This file is overwritten on every scan and visible in Record → List. Deleting it resets the trigger to a clean baseline (no existing files will fire events on restart).
Examples
Counter: basic counting
KeyValue (Counter → Increment, name: "page_visits")
→ first call returns { "counter": "page_visits", "value": 1 }
→ second call returns { "counter": "page_visits", "value": 2 }
KeyValue (Counter → Reset, name: "page_visits")
→ returns { "counter": "page_visits", "value": 0 }
Counter: starting at a custom value
KeyValue (Counter → Increment, name: "invoice", Start At: 1000)
→ first call returns { "counter": "invoice", "value": 1001 }
→ second call returns { "counter": "invoice", "value": 1002 }
Create a directory and write a record
KeyValue (Directory → Create, name: "config")
→ KeyValue (Record → Write, directory: "config", key: "api_url", value: "https://api.example.com")
List all directories, then delete one
KeyValue (Directory → List)
→ KeyValue (Directory → Delete, name: "old_dir")
Filter records by pattern
KeyValue (Record → List, directory: "users", Key Filter: "admin_*")
→ returns only keys starting with "admin_"
Count records in a directory
KeyValue (Record → Count, directory: "users", Key Filter: "admin_*")
→ returns { "directory": "users", "count": 3 }
Delete records by filter
Delete a single record by exact key
KeyValue (Record → Delete, directory: "cache", Key Filter: "old_report")Delete all records matching a pattern
KeyValue (Record → Delete, directory: "cache", Key Filter: "temp_*")Delete all records (explicit — must type "*")
KeyValue (Record → Delete, directory: "cache", Key Filter: "*")Delete records containing "expired" in content
KeyValue (Record → Delete, directory: "cache", Value Filter: "expired")
At least one filter is required. Leaving both empty throws an error — this is a safety guard.
Conditional branching with Exists
KeyValue (Record → Exists, directory: "cache", key: "latest_report")
→ returns { "directory": "cache", "key": "latest_report", "exists": true }
→ use {{ $json.exists }} in an IF node to branch
Heartbeat / liveness check with Touch
Cron (every 5 min) → KeyValue (Record → Touch, directory: "health", key: "worker_alive")
→ returns { "directory": "health", "key": "worker_alive", "touched": true }Monitor workflow → KeyValue (Record → Stat / Read, directory: "health", key: "worker_alive")
→ check mtime or use a KeyValue Trigger on "health/" with Event: File Changed
→ if no touch in 10 minutes → alert
Touch creates the record on first call (created: true), then only updates timestamps on subsequent calls (touched: true). The KeyValue Trigger treats Touch as a change event.
JSON auto-detection
When you write a record whose value is a valid JSON object or array ({...} / [...]), it is stored as structured JSON and automatically parsed back on Read and List:
KeyValue (Record → Write, directory: "contacts", key: "jean",
value: {"prenom": "Jean", "nom": "Dupont", "telephone": "+33 6 12 34 56 78"})
→ stored as formatted JSON on diskKeyValue (Record → Read, directory: "contacts", key: "jean")
→ returns { "prenom": "Jean", "nom": "Dupont", ... }
→ use {{ $json.value.prenom }} in downstream nodes
KeyValue (Record → Write, directory: "contacts", key: "jean_copy",
value: {{ $json.value }})
→ the parsed array/object is passed directly and stored as JSON
Plain text, numbers, and booleans are stored as-is (strings). Only {...} and [...] trigger JSON mode. The Append operation always treats values as plain text.
Search Resource
| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| Query | Query (required), Directory Filter, Tag Filter, Limit, Include Snippets | Full-text search across all files (key + body). AND logic, case-insensitive, scored | [{ "directory": "d", "key": "k", "score": 4, "tags": [...], "snippet": "..." }] |
key: when the match is in the key. Disable with Include Snippets: false for faster queries.Vault Resource
| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| Backlinks | Target Path (required), Directory Filter | Finds files that reference a target file (exact path, wiki link [[...]], or bare filename) | [{ "sourcedirectory": "...", "sourcekey": "...", "matched_pattern": "[[...]]", "line": N, "context": "..." }] |
| Recent | Limit, Directory Filter, Tag Filter | Lists recently modified files sorted by mtime | [{ "directory": "d", "key": "k", "updated": "ISO", "size": N }] |
| Stats | — | Aggregate vault statistics | { "files": N, "directories": N, "totalsizekb": N, "uniquetags": N, "lastmodified": "...", "lastmodifiedat": "ISO" } |
| Tree | Path, Max Depth | Displays the vault directory structure as an ASCII tree | { "tree": "db1/n├── file1n└── file2", "directories": [...], "files": [...] } |
Tag Resource
| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| List | Directory Filter | Lists all unique tags across the vault with file counts | [{ "tag": "n8n", "count": 12, "files": ["dir1/file1", ...] }] |
Data Model
| n8n concept | Filesystem |
|————-|————|
| Counter | Plain text file under counters/ containing a number |
| Directory | Subdirectory under ~/.n8n-keyvalue/ |
| Record (key-value pair) | File whose name is the key, contents are the value |
| Delete (filter-based) | Scans directory, applies Key Filter (glob), Value Filter (substring), and/or Tag Filter (exact tag). At least one filter required |
| Value | Plain UTF-8 text stored in the file. JSON objects/arrays ({…} / […]) are auto-detected on Write and auto-parsed on Read |
| Count | Lightweight tally of records — reads file contents only when Tag Filter is enabled |
| Exists | fs.existsSync check — no file contents read, never throws |
| Touch | Updates record mtime only — no content I/O on existing records. Creates an empty record if the key does not exist |
| Frontmatter | YAML metadata block at the top of the file (--- delimited). Contains tags, description, updated. Stripped from value on read. |
| Search | Full-text scan of all files (key + body). Tokenized AND search with occurrence scoring and optional snippet extraction |
| Vault | Cross-directory navigation: Tree (ASCII), Stats (aggregates), Recent (by mtime), Backlinks (cross-references) |
| Tag | Index of all unique frontmatter tags with per-file counts |
| Trigger | Polling-based watcher on a directory. Detects add/change via mtime comparison between scans |
KeyValue Use Cases
When building robust automation in n8n, certain heavy lifting, like initializing an environment or creating a specific SQL table, should only happen exactly once. By pairing the n8n-nodes-keyvalue community node with the built-in $workflow.id variable, you can seamlessly track initialization states across executions without complex error-handling.
!Run-once initialization workflow
Environment Variables
| Variable | Default | Description |
|———-|———|————-|
| N8NKEYVALUEDIR | ~/.n8n-keyvalue | Override the base directory. Useful for Docker / persistent volume mounts. |
Setting up a persistent directory (Docker)
If your n8n container has a persistent volume at /home/node/.n8n, set up KeyValue inside it:
Inside the container (or add to your Dockerfile/entrypoint)
mkdir -p /home/node/.n8n/keyvalue
Then set the environment variable on your n8n container:
N8NKEYVALUEDIR=/home/node/.n8n/keyvalue
All directories and records will now be stored under /home/node/.n8n/keyvalue/ and survive container restarts.
> Security note: If your n8n instance has N8NRESTRICTFILEACCESSTO set, include the KeyValue directory in its semicolon-separated list:
>
> N8NKEYVALUEDIR=/home/node/.n8n/keyvalue
> N8NRESTRICTFILEACCESSTO=/home/node/.n8n
>
Limitations
/, <, >, :, ", , |, ?, *) will cause OS-level errors. Stick to alphanumeric names, hyphens, and underscores for now.Development
git clone https://github.com/stephaneheckel/n8n-nodes-keyvalue.git
cd n8n-nodes-keyvalue
npm install --legacy-peer-deps
npm run build
npm run dev # starts n8n with hot-reload at localhost:5678
Hermes Agent External Memory
This node powers an external memory system for Hermes Agent. The KeyValue store at ~/.n8n-keyvalue/ holds structured markdown files with YAML frontmatter in a single reserved directory:
| Directory | Purpose |
|-----------|---------|
| memory/ | Hermes external memory — conventions, pitfalls, projects, user context |
Taxonomy is encoded in YAML tags, not subdirectories. A single search finds everything:
search_files("n8n", path="~/.n8n-keyvalue/memory/").
Hermes accesses these files via readfile/searchfiles, and n8n workflows can navigate them via Search, Record, Vault, and Tag resources. Full documentation: EXTERNAL-MEMORY-SYSTEM.md
License
MIT