A sovereign edge cognition appliance for MapleOS. Local-first semantic memory, document indexing, and an MCP-compatible tool server, running on a Radxa ROCK 4C+ under Armbian.
Rockchip is officially supported. Dual-Core Arm Cortex-A72 and Quad-Core Cortex-A53 are officially supported.
maplenode is a tiny, always-on appliance built around three ideas:
- Local-first memory. Vector-searchable semantic store, on-device, with no cloud round-trip on the hot path.
- Pluggable embeddings.
sentence-transformers/all-MiniLM-L6-v2(384-d) by default, with a deterministic hash fallback so the API stays up even if heavy ML deps fail to install. - Discoverable. mDNS publishes
maplenode.local, and the node broadcasts its capabilities on UDP:8766every 30s so MapleOS can find it without manual config.
It is not a GPU server. It is an edge memory + orchestration node.
| Target board | Radxa ROCK 4C+ (RK3399, 4GB) |
| OS | Armbian Bookworm (Debian 12), kernel 6.7.x |
| Stack | FastAPI · SQLite · LanceDB · sentence-transformers (CPU) |
| HTTP | :8765 |
| UDP discovery | :8766 |
| mDNS | maplenode.local, service _maplenode._tcp |
| Process | systemd unit, runs as user maplenode |
| Auth | one setup token, sent via X-MapleSeed-Token |
On a freshly-flashed ROCK 4C+ with Armbian (headless / multi-user.target):
sudo hostnamectl set-hostname maplenode
git clone https://github.com/CanXPAI/maplenode.git ~/maplenode-src
cd ~/maplenode-src
bash scripts/install.shThen, from any machine on the same LAN:
curl http://maplenode.local:8765/health
# {"status":"ok","device":"maplenode","hostname":"maplenode","version":"0.1.0"}The installer prints the setup token at the end. Grab it:
sudo cat /opt/maplenode/app/data/.setup_tokenTOKEN=<paste the setup token>
BASE=http://maplenode.local:8765
# Open endpoints — no token required
curl -s $BASE/health
curl -s $BASE/capabilities
curl -s $BASE/mcp/tools
# Write endpoints — require X-MapleSeed-Token
curl -s -X POST $BASE/memory/store \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"User prefers local-first agents.","metadata":{"source":"test"}}'
curl -s -X POST $BASE/memory/search \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"agent preferences","top_k":5}'
# Upload a document and index it
curl -s -X POST $BASE/documents/upload \
-H "X-MapleSeed-Token: $TOKEN" \
-F file=@notes.txt -F namespace=default
# returns {"id":"<doc-id>", ...}
curl -s -X POST $BASE/documents/index \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"document_id":"<doc-id>"}'
# MCP-style tools
curl -s $BASE/mcp/device_status -H "X-MapleSeed-Token: $TOKEN"
curl -s -X POST $BASE/mcp/run_command_safe \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"command":"uptime"}'Allowlisted diagnostic commands are: uptime, df, free, cpuinfo, service_status.
There is no unrestricted shell endpoint by design.
A typed client lives in client/MapleSeedClient.ts:
import { MapleSeedClient } from "./MapleSeedClient";
const node = new MapleSeedClient("http://maplenode.local:8765", { token: TOKEN });
await node.storeMemory("User prefers local-first agents.", { source: "test" });
const hits = await node.searchMemory("agent preferences", 5);
const tools = await node.listTools();Drop it into MapleOS as the integration surface.
maplenode is reachable in two complementary ways:
- mDNS / Bonjour.
maplenode.localresolves to its LAN IP. An avahi service file at/etc/avahi/services/maplenode.servicepublishes_maplenode._tcpwith TXT records:version,capabilities,api. - UDP capability broadcast. Every 30s the node sends a JSON packet on UDP
:8766:MapleOS can listen on{ "type": "maplenode.advertise", "name": "maplenode", "hostname": "maplenode.local", "api": "http://maplenode.local:8765", "version": "0.1.0", "capabilities": ["memory", "documents", "mcp"] }:8766to auto-detect nodes joining the LAN.
- Discover the node via mDNS or the UDP broadcast.
- User opens an SSH/console on the device and runs:
sudo cat /opt/maplenode/app/data/.setup_token
- User pastes the token into MapleOS.
- MapleOS stores the token and uses it in
X-MapleSeed-Tokenfor all write calls.
The token is generated on first boot (32 bytes of secrets.token_urlsafe) and persists to a 0600 file under /opt/maplenode/app/data/.
A Cognitum Seed is a small USB-attached hardware root of
trust for AI — identity keys, witness chains, custody signatures, vector memory
on a tamper-evident device. maplenode can optionally pair with one to gain
a /cognitum/* API surface and report cognitum: true in /capabilities.
It is entirely opt-in. If no seed is plugged in (or the integration is disabled), the core memory/document features work exactly as before.
Plug the seed into a USB port on the ROCK 4C+, then:
sudo bash /opt/maplenode/scripts/cognitum-pair.shWhat that does, end to end:
- Finds the USB-Ethernet interface the seed exposed and persists
169.254.42.2/24on it via NetworkManager (connection namecognitum-usb). - Mounts the seed's
COGNITUMmass-storage read-only, runs the bundledinstall-trust.shto drop its name-constrained CA into/usr/local/share/ca-certificates/cognitum-ca.crt, and runsupdate-ca-certificates. - Opens the seed's 30-second pairing window and pairs as client
maplenode. The bearer token lands at/opt/maplenode/app/data/.cognitum_token(mode0600, owned bymaplenode). - Writes
/etc/systemd/system/maplenode.service.d/cognitum.confsettingMAPLENODE_COGNITUM_ENABLED=trueand pointingREQUESTS_CA_BUNDLEat the system CA store. daemon-reload+ restart maplenode, then probes/capabilities.
Verify:
curl http://maplenode.local:8765/capabilities
# {"...","cognitum_enabled":true,"cognitum":true}TOKEN=$(sudo cat /opt/maplenode/app/data/.setup_token)
BASE=http://maplenode.local:8765
curl -s $BASE/cognitum/status -H "X-MapleSeed-Token: $TOKEN"
curl -s $BASE/cognitum/identity -H "X-MapleSeed-Token: $TOKEN"
# Sign/verify are thin pass-throughs to the seed's /api/v1/custody/{sign,verify}.
# Send the exact body the seed expects (currently {"data": <opaque blob>}).
curl -s -X POST $BASE/cognitum/sign \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"data":"sign this opaque blob"}'
curl -s -X POST $BASE/cognitum/verify \
-H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"data":"...","signature":"...","witness":"..."}'/cognitum/mcp/tools and /cognitum/mcp/call exist as experimental thin
passthroughs. The seed's MCP endpoint uses MCP Streamable HTTP transport
which requires a stateful initialize → Mcp-Session-Id handshake that
maplenode does not manage; if you need MCP tool access today, point your
MCP-aware client directly at https://169.254.42.1:8443/mcp with the bearer
token at /opt/maplenode/app/data/.cognitum_token.
The Cognitum CA is name-constrained at install time. Inspect it:
openssl x509 -in /usr/local/share/ca-certificates/cognitum-ca.crt -text \
| grep -A 5 'Name Constraints'Its permitted name space is:
- DNS:
*.local,cognitum-<id>.local - IP:
169.254.0.0/16,127.0.0.0/8,192.168.4.0/24
It cannot sign certificates for public hostnames like github.com —
even though it's in the system trust store, the blast radius is contained to
link-local + mDNS + a single private subnet.
sudo rm /etc/systemd/system/maplenode.service.d/cognitum.conf
sudo systemctl daemon-reload && sudo systemctl restart maplenode
# Optional: remove the CA too
sudo rm /usr/local/share/ca-certificates/cognitum-ca.crt
sudo update-ca-certificates --fresh
# Optional: drop the persistent USB connection
sudo nmcli connection delete cognitum-usbLong-form: see
docs/witness.mdfor the architecture, threat model, canonical encoding, end-to-end flows, security properties, and code map.
When a Cognitum Seed is paired, maplenode can ask it to sign every memory and every document chunk with the seed's hardware-rooted Ed25519 key at write time. The signature is persisted alongside the row, and any caller can later ask maplenode to re-verify it. This makes maplenode's memory tamper-evident: if anything mutates the stored text, the verify call fails before it even round-trips to the seed.
The seed remains strictly optional. With no seed paired (or
MAPLENODE_COGNITUM_ENABLED=false), /memory/store and /documents/upload
keep working unchanged — rows just land with witness_json = NULL.
Set with MAPLENODE_WITNESS_MODE in the systemd override:
| Mode | Seed reachable | Seed unreachable |
|---|---|---|
off |
no sign attempted; witness_json = NULL |
same |
when_available (default) |
sign and persist; respond witnessed: true |
log + insert with witness_json = NULL; witnessed: false |
required |
sign and persist | refuse the write with HTTP 503; no DB state |
Stored in memories.witness_json (also documents.witness_json,
chunks.witness_json):
{
"schema": "v1",
"algorithm": "Ed25519",
"signature": "<128 hex chars>",
"public_key": "<seed device public key, 64 hex>",
"device_id": "538babda-82af-436e-a44e-d2472570a172",
"canonical_hex": "<BLAKE2b-32 of the canonical record bytes>",
"signed_at": "2026-05-25T..."
}canonical_hex is what was actually signed: a BLAKE2b-32 digest of a
schema-versioned, line-delimited string covering id, namespace,
created_at, and SHA-256s of text and metadata. We hash before signing
so the seed sees a fixed 64-char hex input regardless of memory size.
TOKEN=$(sudo cat /opt/maplenode/app/data/.setup_token)
BASE=http://maplenode.local:8765
# Full row with witness
curl -s $BASE/memory/<id> -H "X-MapleSeed-Token: $TOKEN" | jq
# Re-verify against the seed
curl -s -X POST $BASE/memory/<id>/verify -H "X-MapleSeed-Token: $TOKEN" | jq
# { "valid": true, "algorithm": "Ed25519" }
# Tamper test
sudo -u maplenode sqlite3 /opt/maplenode/app/data/maplenode.db \
"UPDATE memories SET text = 'tampered' WHERE id = '<id>'"
curl -s -X POST $BASE/memory/<id>/verify -H "X-MapleSeed-Token: $TOKEN" | jq
# { "valid": false, "reason": "canonical_bytes_mismatch" }Verification short-circuits locally when the stored canonical_hex doesn't
match a freshly-recomputed one — no round-trip to the seed needed to know
a record has been tampered with. The round-trip only confirms the signature
math.
Document chunks have their own witnesses (the document_id is in the
canonical bytes, so a chunk witness binds to its document and can't be
re-targeted):
curl -s $BASE/documents/<doc-id> -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s -X POST $BASE/documents/<doc-id>/verify -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s $BASE/documents/<doc-id>/chunks/<chunk-id> -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s -X POST $BASE/documents/<doc-id>/chunks/<chunk-id>/verify \
-H "X-MapleSeed-Token: $TOKEN" | jqThe witness binds to the seed's current device key. If the seed is factory- reset, firmware-rotated, or physically swapped, all previous witnesses become unverifiable under the new device key. This is a property of hardware roots of trust, not something the integration can work around — treat the seed's Ed25519 pubkey as a trust-on-first-use root.
/memory/search results include a lightweight witnessed: bool flag.
Existing rows from before the upgrade have witness_json = NULL and are
reported as witnessed: false; no automatic backfill in v1.
# Update from a new checkout
bash /opt/maplenode/scripts/update.sh ~/maplenode-src
# Backup SQLite + LanceDB + uploaded docs + setup token
bash /opt/maplenode/scripts/backup.sh /home/vince/maplenode-backupsThe backup is a single timestamped .tar.gz. Restore by extracting it back over /opt/maplenode/app/data/.
maplenode/
├─ app/
│ ├─ main.py FastAPI app + token middleware + lifespan
│ ├─ config.py paths, ports, capability flags, setup-token mgmt
│ ├─ api/ HTTP routers: health, memory, documents, mcp, sync, cognitum
│ └─ core/
│ ├─ database.py SQLite schema + connection helper
│ ├─ embeddings.py pluggable backend (local-minilm | hash)
│ ├─ vector_store.py LanceDB wrapper
│ ├─ document_indexer.py chunker + extractor
│ ├─ discovery.py UDP capability broadcaster
│ ├─ cognitum.py optional Cognitum Seed client
│ ├─ witness.py canonical encoders + sign/verify primitives
│ └─ agent_runtime.py stub for tiny local inference (Phase 9)
├─ scripts/
│ ├─ install.sh one-shot installer (apt + venv + systemd + ufw + avahi)
│ ├─ update.sh rsync + pip refresh + restart
│ ├─ backup.sh consistent tar.gz of data dir
│ └─ cognitum-pair.sh pair with a Cognitum Seed over USB
├─ systemd/
│ ├─ maplenode.service systemd unit
│ └─ maplenode-avahi.service avahi service definition
├─ client/
│ └─ MapleSeedClient.ts TypeScript client for MapleOS
└─ README.md
Runtime data (not in repo, created by the installer):
/opt/maplenode/app/data/
├─ maplenode.db SQLite (memories, documents, chunks)
├─ vectors/ LanceDB
├─ documents/ uploaded files
├─ models/ sentence-transformers cache
├─ logs/
└─ .setup_token chmod 0600
All knobs are environment variables, read by app/config.py:
| Variable | Default | Meaning |
|---|---|---|
MAPLENODE_DEVICE_NAME |
maplenode |
name returned in /health and the broadcast |
MAPLENODE_PORT |
8765 |
HTTP port |
MAPLENODE_UDP_PORT |
8766 |
capability broadcast port |
MAPLENODE_BROADCAST_INTERVAL |
30 |
seconds between broadcasts |
MAPLENODE_EMBED_BACKEND |
local-minilm |
local-minilm or hash |
MAPLENODE_LOCAL_INFERENCE |
false |
toggles a local_inference capability flag |
MAPLENODE_CLOUD_SYNC |
false |
toggles a cloud_sync capability flag |
MAPLENODE_LOG_LEVEL |
INFO |
python logging level |
MAPLENODE_COGNITUM_ENABLED |
false |
mount the /cognitum/* routes and start probing the seed |
MAPLENODE_COGNITUM_URL |
https://169.254.42.1:8443 |
base URL for the paired Cognitum Seed |
MAPLENODE_COGNITUM_TOKEN |
<DATA_DIR>/.cognitum_token |
file containing the seed bearer token |
MAPLENODE_WITNESS_MODE |
when_available |
off, when_available, or required — see "Witness-stamped memory" |
To set them under systemd, drop a file at /etc/systemd/system/maplenode.service.d/override.conf:
[Service]
Environment=MAPLENODE_EMBED_BACKEND=hashThen sudo systemctl daemon-reload && sudo systemctl restart maplenode.
- LAN-only by design. UFW opens 22/tcp, 8765/tcp, 5353/udp (mDNS), 8766/udp (broadcast). Do not port-forward 8765 to the public internet.
- Write-gated. All non-GET endpoints (and
POST /mcp/run_command_safe) require the setup token.GET /health,/capabilities, and/mcp/toolsare open so a discovering MapleOS can probe before pairing. - No arbitrary shell. Diagnostic commands are a hard-coded allowlist.
- Process isolation. The systemd unit runs as the
maplenodesystem user withNoNewPrivileges,ProtectSystem=full,ProtectHome=true, andReadWritePaths=/opt/maplenode/app/data.
- The default PyPI
torchwheel on aarch64 pulls ~2GB of CUDA libraries that are useless on RK3399.scripts/install.shinstalls torch from the CPU-only index (https://download.pytorch.org/whl/cpu) and pointspip'sTMPDIRat the SD card to avoid filling the 1.9GB/tmptmpfs. - First call to
/memory/storeor/memory/searchwill load the 90MB MiniLM model into memory. The installer warms the cache at install time so the first user request isn't slow.
| Symptom | Try |
|---|---|
maplenode.local doesn't resolve |
sudo systemctl status avahi-daemon; make sure the client host has libnss-mdns or systemd-resolved with mDNS enabled; check both are on the same subnet |
| Service won't start | journalctl -u maplenode -n 80 --no-pager |
pip install ran out of disk |
The fix is already in the installer (CPU torch index + TMPDIR=/opt/maplenode/.tmp). If you skipped it, replicate those flags manually. |
| 401 on write endpoints | Header must be X-MapleSeed-Token; token at /opt/maplenode/app/data/.setup_token |
/memory/search returns nothing |
First write a memory; first call may pause while the embedding model downloads |
| Want to swap to hash fallback | MAPLENODE_EMBED_BACKEND=hash via the systemd override above |
cognitum: false in /capabilities after pairing |
Check ping 169.254.42.1 (USB link), then curl -v https://169.254.42.1:8443/api/v1/status (TLS / CA). The maplenode service needs REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt in its override (the pair script sets it). |
- Phase 9 — tiny local inference (
llama.cpp+ small GGUF) for intent classification and summarization - Phase 10 — CanXP AI cloud sync
- USB-gadget pairing mode for direct one-device pairing without LAN
- Per-namespace ACLs and multi-token auth
- PDF / HTML / Markdown extractors for
documents.index
MIT — see LICENSE.