MeshKit is a developer-friendly toolkit for decentralized storage on mobile (React Native first) with UCANs. It wraps IPFS-style content addressing behind a small TypeScript API so app authors can store, retrieve, upload, and download without running their own infra — while optionally adding AES-GCM encryption, UCAN-based sharing, and a demo messaging layer backed by a Key Service.
| Layer | Role |
|---|---|
@meshkit/core |
Headless SDK — storage, encryption, share, send/receive |
@meshkit/react-native |
React hooks + provider for Expo / RN apps |
Key Service (py/keyservice) |
HTTP registry for content keys, UCAN delegations, and inbox indices (POC) |
| Kubo (optional) | Pins ciphertext to real IPFS CIDs when pinning is configured |
MeshKit is not a replacement for Helia on web; it targets mobile DX under Manu's storage-first direction. See MANU_SPEC.md for product scope.
With EXPO_PUBLIC_MESHKIT_ENCRYPT=true, the Expo example app is a WhatsApp-style chat: one screen, message bubbles, file attachments, identity header, and a Debug Coach panel after each send.
Sender (alice → bob) — message encrypted, pinned on Kubo, inbox entry on Key Service. Debug Coach shows the full message CID and terminal steps for the recipient:
Recipient (bob → alice) — inbox polled every few seconds; reply appears in-thread. Debug Coach shows the outbound message CID:
Under the hood:
- Ciphertext lives on Kubo (IPFS CID).
- Content encryption keys and UCAN delegations live on the Key Service.
- Inbox is a Key Service index per user — clients poll it; there are no push notifications in this POC.
| Feature | Behavior |
|---|---|
| Default user | alice (sender); override with EXPO_PUBLIC_MESHKIT_USER_ID |
| To: picker | Lists users from Key Service GET /v1/users (excludes self) |
| Sender label | Each bubble shows who sent it (senderId) |
| Inbox poll | Auto every 3 s (EXPO_PUBLIC_MESHKIT_INBOX_POLL_MS; 0 = manual ↻ only) |
| Unknown recipient | In-chat error if peer never registered |
| Debug Coach | Full message CID + copy-paste steps for second client |
The Key Service starts empty. Each client POSTs /v1/users on first app open when encrypt: true. There is no built-in alice/bob seed data.
Recipient-first rule: the user you send to must have opened the app at least once so they exist on the Key Service (GET /v1/users/{id}). That is why the demo starts bob before alice — not because alice is pre-registered.
Messages to an offline peer still work: ciphertext stays on Kubo; keys and inbox entries stay on the Key Service until the recipient opens the app and inbox polling runs.
| Tool | Version |
|---|---|
| Node.js | 22+ for Expo example (nvm use in rn/example-app) |
| uv | Python Key Service |
| Python | 3.10 – 3.13 |
Kubo (ipfs) |
Optional but required for real CIDs in encrypt demo |
One-time setup:
cd acul71/rn/example-app
chmod +x setup.sh && ./setup.shConfigure Kubo for LAN dev (bind API + gateway to 0.0.0.0, enable CORS) — see rn/example-app/README.md.
# Terminal 0 — Key Service (separate terminal: ipfs daemon on this PC too)
cd acul71/rn/example-app && npm run start:keyservice
# in another shell on the same PC: ipfs daemon
# Terminal 1 — recipient MUST open first (registers bob; script forces encrypt)
npm run start:kubo:as -- bob
# Terminal 2 — sender alice (encrypt required here — start:kubo does NOT default it)
EXPO_PUBLIC_MESHKIT_ENCRYPT=true npm run start:kubostart:kubo:as already sets EXPO_PUBLIC_MESHKIT_ENCRYPT=true and EXPO_PUBLIC_MESHKIT_USER_ID. Only alice’s terminal needs the env var prefix on web.
Then in the browser:
- Alice tab: tap To: and pick
bobfrom registered users, then send. - Bob tab: inbox loads automatically (poll every 3 s); messages show the sender name on each bubble.
- Bob replies: pick To:
aliceand send.
Android emulator variant (recipient first, same as web):
# Terminal 0 — Key Service (+ ipfs daemon on PC)
cd acul71/rn/example-app && npm run start:keyservice
# Terminal 1 — bob registers first
npm run start:kubo:android:as -- bob
# Terminal 2 — alice (default user; encrypt already on in start-kubo-android.sh)
npm run start:kubo:androidnpm run start:kubo:android defaults EXPO_PUBLIC_MESHKIT_ENCRYPT=true, Kubo URLs on 10.0.2.2, and clears Metro cache (-c is built into the script). You do not need to prefix Terminal 2 with EXPO_PUBLIC_MESHKIT_ENCRYPT=true unless you want to be explicit.
Use $HOME/.buildozer/android/platform/android-sdk as ANDROID_HOME (not a random ~/Android/Sdk path). AOSP AVD Meshkit_AOSP_34 — see rn/example-app/README.md.
Hermes lacks crypto.subtle; @meshkit/core falls back to @noble/ciphers AES-GCM and requires react-native-get-random-values (imported first in the Expo app). After SDK changes: rebuild ts/, npm install in example-app, restart Expo with -c.
Default To: is never yourself: alice → bob, bob → alice.
| Variable | Purpose |
|---|---|
EXPO_PUBLIC_MESHKIT_ENCRYPT=true |
Enable share / send / receive (required on npm run start:kubo; defaulted by start:kubo:as and start:kubo:android) |
EXPO_PUBLIC_MESHKIT_USER_ID |
Identity (alice, bob, …) |
EXPO_PUBLIC_MESHKIT_PINNING |
Kubo API URL (e.g. http://192.168.x.x:5001) |
EXPO_PUBLIC_MESHKIT_GATEWAY |
Gateway URL (e.g. http://192.168.x.x:8092) |
EXPO_PUBLIC_MESHKIT_KEY_SERVICE |
Key Service URL (default http://127.0.0.1:8787) |
EXPO_PUBLIC_MESHKIT_INBOX_POLL_MS |
Inbox poll interval (default 3000; 0 = manual only) |
EXPO_PUBLIC_MESHKIT_DEBUG |
Show Debug Coach after send (default on in dev) |
Copy rn/example-app/.env.example and restart Expo with -c after changes.
cd acul71/ts
npm install && npm run build
npm run demo:storage # memory storage
npm run demo:share # UCAN share (in-memory Key Service)
npm run demo:messaging # send / receive (in-memory)demo:remote-keys needs the Python Key Service listening on :8787. Start it in another terminal first, then run the demo:
# Terminal A — Key Service (leave running)
cd acul71/rn/example-app && npm run start:keyservice
# or: cd acul71/py && uv run meshkit-keyservice --port 8787 --host 0.0.0.0
# Terminal B — remote-keys demo
cd acul71/ts && npm run demo:remote-keysWithout Terminal A you get ECONNREFUSED 127.0.0.1:8787 — that is expected, not an SDK bug.
This repository is a Phase 1 POC, not production messaging or file sync.
| Topic | Limitation |
|---|---|
| Single shared backend | Demo assumes one Key Service + one Kubo reachable by all clients. Each machine running its own 127.0.0.1 services = separate worlds. |
| Same LAN / dev PC | Default scripts target two browser tabs or phone + PC on Wi‑Fi. Cross-city deployment needs explicit public URLs, TLS, and auth — not included. |
| No push / realtime | Inbox is HTTP polling (3 s default). No WebSocket, FCM, or libp2p pubsub delivery. |
| Recipient must register first | The peer you send to must have opened the app once (Key Service GET /v1/users/{id} → 404 otherwise). Key Service is not pre-seeded. Typos show an in-chat error. |
| Key Service in-memory | Restarting the Python server clears users, keys, and inbox — restart all clients after a keyservice restart. |
| Inbox ≠ chat thread | Inbox is keyed by signed-in user, not the To: field. Poll loads all senders; set To: only for outbound replies. |
| File notify hack | Attachments send a demo JSON string via send() so the inbox renders a file bubble — not a typed production envelope. |
| UCAN POC tokens | Delegations are simplified MeshKit UCANs verified locally; not full @ipld/dag-ucan production semantics. |
| Lit Protocol | accessControl: 'lit' is a stub; real Lit integration is deferred (LIT_SPIKE.md). |
| Kubo RPC exposure | Binding Kubo to 0.0.0.0 is for local dev only — never expose unauthenticated RPC to the internet. |
Expo --tunnel |
Proxies Metro JS only; the phone still must reach Kubo/Key Service on your LAN IP. |
| Memory mode | Without EXPO_PUBLIC_MESHKIT_PINNING, CIDs are in-app only — not on IPFS. |
Package: @meshkit/core (ts/). React Native: @meshkit/react-native (rn/) exposes the same surface via useMeshkit().
import { Meshkit } from '@meshkit/core'
const mk = await Meshkit.init({
preset: 'memory', // or 'public-gateway'
storage: 'memory', // 'memory' | 'gateway' | 'helia' (Node 22+)
gateway: 'https://ipfs.io', // read / link base URL
pinning: 'http://127.0.0.1:5001', // Kubo API — required for gateway writes
encrypt: false, // default; set true for share/messaging
keyService: 'http://127.0.0.1:8787', // omit → in-memory Key Service (single process)
accessControl: 'ucan', // when encrypt: true
identity: { userId: 'alice' }, // registers on Key Service at init
})
await mk.stop()| Field | Type | Default | Description |
|---|---|---|---|
preset |
'memory' | 'public-gateway' |
— | Shorthand; merged with explicit options |
storage |
'memory' | 'gateway' | 'helia' |
'memory' |
Where bytes are stored |
gateway |
string |
https://ipfs.io |
HTTP gateway for reads and link() |
pinning |
string |
— | Kubo /api/v0 base; enables gateway writes |
encrypt |
boolean |
false |
AES-GCM-256 envelope + Key Service |
keyService |
string |
in-memory | Shared HTTP Key Service URL for multi-user |
accessControl |
'ucan' | 'lit' |
'ucan' if encrypt |
Delegation backend |
identity |
MeshkitIdentity |
— | { userId, ethAddress?, publicKey? } |
encryption |
'aes-gcm-256' |
'aes-gcm-256' |
Only supported algorithm today |
| Field | Description |
|---|---|
userId |
Logical user name (demo) or app id |
ethAddress |
Optional 0x… — UCAN audience did:ethr:… |
publicKey |
Optional base64 Ed25519 — audience did:key:… |
On init with encrypt: true, the client POSTs /v1/users to register the identity.
JSON-serializes payload, stores bytes, returns CID.
- Plain: raw JSON bytes in storage provider.
- Encrypted: AES-GCM envelope; content key registered on Key Service for owner.
Fetches by CID and JSON-parses (plain) or decrypts (encrypted).
- Owner: no proof needed when
identity.userIdmatches key owner. - Delegate: pass
{ ucan }fromshare().
Binary upload with optional onProgress({ loaded, total, percent }).
- Encrypted: wraps bytes in envelope before
put; registers content key.
Raw bytes; decrypts when encrypt: true.
Returns {gateway}/ipfs/{cid}. Meaningful when storage: 'gateway' and content is pinned.
interface MeshkitStatus {
storage: string // 'memory' | 'gateway' | 'helia'
gateway: string
pinning?: string
encrypt: boolean
accessControl?: 'ucan' | 'lit'
gatewayReachable: boolean // pings pinning API or gateway HEAD
shareableLinks: boolean // gateway + pinning configured
}Releases storage provider resources.
- Resolves recipient on Key Service (must exist for HTTP client).
- Creates UCAN delegation for
meshkit/fetchKey/{cid}. - Returns encoded UCAN; optional
linkwhen gateway mode.
Recipient can retrieve / download with { ucan } or, after delegation is indexed by recipient id, as inbox owner.
Owner deletes content key and delegations on Key Service.
POC helper — not a standalone chat protocol. Messages are encrypted JSON blobs on IPFS plus inbox pointers on Key Service.
- Builds
{ type: 'message', text, sender, recipient, sentAt }. storeEncrypted→ message CID (id).share(cid, recipientId)— recipient can decrypt.appendInbox(recipientId, { id, sender, recipient, sentAt }).
Returns { id } where id is the message CID (shown in Debug Coach).
Decrypts message CID; validates type === 'message'.
interface ReceivedMessage {
id: string
text: string
sender: string
recipient: string
sentAt: string // ISO-8601
}Lists inbox refs for recipientId or current identity.userId.
interface InboxEntry {
id: string // message CID — pass to receive()
sender: string
recipient: string
sentAt: string
}Typical receive loop (as in the Expo app):
const inbox = await mk.listInbox()
for (const entry of inbox) {
const msg = await mk.receive(entry.id)
console.log(msg.sender, msg.text)
}import { MeshkitProvider, useMeshkit } from '@meshkit/react-native'
import { meshkitOptions } from './config'
export default function App () {
return (
<MeshkitProvider options={meshkitOptions}>
<ChatScreen />
</MeshkitProvider>
)
}
function ChatScreen () {
const { send, listInbox, receive, loading, status } = useMeshkit()
// …
}Hooks: useMeshkit(), useStore(), useUpload(), useMessaging().
Base URL e.g. http://127.0.0.1:8787:
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness |
POST |
/v1/users |
Register identity |
GET |
/v1/users |
List registered users (demo recipient picker) |
GET |
/v1/users/{userId} |
Lookup user |
POST |
/v1/keys |
Register content key for CID |
GET |
/v1/keys/{cid} |
Fetch key (owner header or Bearer UCAN) |
POST |
/v1/keys/{cid}/share |
Delegate to recipient |
DELETE |
/v1/keys/{cid} |
Revoke |
POST |
/v1/inbox/{recipientId} |
Append inbox entry |
GET |
/v1/inbox/{recipientId} |
List inbox |
Start: cd acul71/rn/example-app && npm run start:keyservice
| Error | Typical cause |
|---|---|
EncryptionError |
share/send/receive without encrypt: true |
Meshkit user not found: … |
Recipient never opened app |
Key not found or revoked for CID |
Wrong CID or revoked share |
Not authorized to fetch key |
Missing or invalid UCAN |
CID … is not an encrypted message |
receive() on non-message CID |
| Path | Description |
|---|---|
| ts/ | @meshkit/core SDK |
| rn/ | @meshkit/react-native + Expo example app |
| py/keyservice/ | Python Key Service |
| test-vectors/ | Cross-stack crypto vectors |
| MANU_SPEC.md | Product spec |
| FEASIBILITY.md | Milestones and analysis |
| run-poc.sh | Run all tests and demos |
| Milestone | Status |
|---|---|
| M0 Spec alignment | Done |
| M1 Real storage | Done |
M2 @meshkit/core |
Done |
| M3 React Native SDK | Done |
| M4 DX polish | Done |
| M5 Lit Protocol | Design doc (LIT_SPIKE.md) |
| M7 Share / UCAN / messaging | Done (POC) |
- rn/example-app/README.md — Kubo LAN setup, Android emulator, troubleshooting
- ts/README.md — build scripts, Helia optional provider
- MANU_SPEC.md — Manu's June 2026 direction

