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/.
# 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
│ └─ 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
│ └─ 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
├─ 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 |
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 |
- 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.