An encrypted personal message queue: CLI-first, E2E encrypted, cross-device.
A tool for sending messages to yourself. Not a note-taking app, not a chat client: a personal inbox with queue semantics (push, peek, pop, ack) where messages are end-to-end encrypted with age and synced across devices via commodity blob storage.
- Self-messaging, not note-taking: messaging UX and mental model, not documents
- E2E encrypted by default: server/storage never sees plaintext
- CLI-first:
nts push "remember this"from the terminal - Cross-device: CLI + mobile + web, all synced
- Queue semantics: push/peek/pop/ack, not just append-only
- Per-message TTL: ephemeral and persistent messages coexist
- No account required: just an age keypair and a storage bucket
- Encryption:
age(X25519 + ChaCha20-Poly1305) viarageRust crate - Storage: Cloudflare R2 (S3-compatible, zero egress, 10GB free)
- Notifications: ntfy.sh (notification only: never carries message content)
- CLI: Rust binary (
ntscommand) - Mobile: PWA with
age-encryptionnpm package - Local AI: Optional Ollama integration for search (future milestone)
- Evaluated and rejected: Matrix (overkill), Memos+encryption (fighting the tool)
- Full details:
docs/architecture.md
Updated as project evolves
note-to-self/
├── AGENTS.md # Symlink to CLAUDE.md for agent entrypoint compatibility
├── CLAUDE.md # This file: project instructions
├── Cargo.toml # Rust project manifest
├── osv-scanner.toml # Root OSV exception policy for Cargo.lock
├── docs/
│ ├── research.md # Landscape research and competitive analysis
│ ├── architecture.md # Technical architecture and design decisions
│ ├── roadmap.md # Feature roadmap and milestones
│ └── superpowers/
│ ├── specs/ # Design specs
│ └── plans/ # Implementation plans
├── src/
│ ├── main.rs # CLI entry point (clap command routing)
│ ├── commands/ # Command implementations
│ │ ├── init, push, peek, pop, list, show, ack, delete, purge, search
│ │ ├── config_cmd.rs # nts config get/set
│ │ ├── notify_cmd.rs # nts notify setup
│ │ ├── sync_cmd.rs # nts sync
│ │ ├── status.rs # nts status
│ │ ├── export.rs # nts export [--passphrase]
│ │ └── import.rs # nts import <file> [--passphrase]
│ ├── crypto.rs # age encrypt/decrypt wrappers
│ ├── index.rs # Encrypted JSON index (message metadata)
│ ├── merge.rs # Pure merge function for index reconciliation
│ ├── notify.rs # ntfy notification logic (body, priority, HTTP POST, X-Click)
│ ├── secret.rs # Env-var-first secret resolver for r2 creds, ntfy token, identity
│ ├── sync.rs # Pull/push orchestration with ETag locking
│ ├── sync_state.rs # Pending sync tracking (sync_state.json)
│ ├── message.rs # Message struct and serialization
│ ├── config.rs # Config file management (config.toml + R2 settings + _env fields)
│ ├── display.rs # Terminal output formatting
│ ├── helpers.rs # ID generation, duration parsing
│ └── storage/
│ ├── mod.rs # Storage trait with ETag support
│ ├── local.rs # Local filesystem implementation
│ └── r2.rs # Cloudflare R2 implementation (S3-compatible)
├── tests/
│ ├── integration.rs # End-to-end CLI tests
│ └── (merge fixtures shared with PWA via web/test/fixtures/merge/)
├── src/
│ ├── device.rs # Bearer-token minting, devices.json read/write
│ └── commands/device.rs # nts device add/list/revoke
├── web/ # PWA (M4a shipped)
│ ├── package.json
│ ├── vite.config.ts # injectManifest service-worker wiring
│ ├── README.md # Deploy + onboarding guide
│ ├── src/
│ │ ├── main.tsx + app.tsx
│ │ ├── core/ # crypto, idb, identity, import, http, merge, sync, index-store
│ │ ├── routes/ # unlock, import, inbox, message, compose
│ │ ├── service-worker.ts
│ │ └── styles/global.css
│ ├── test/
│ │ ├── unit/ # vitest (jsdom + fake-indexeddb)
│ │ ├── e2e/ # playwright against `vite preview`
│ │ └── fixtures/
│ │ ├── merge/ # JSON corpus shared with src/merge.rs
│ │ └── ciphertext/ # rage-pinned fixture for crypto round-trip tests
│ └── worker/ # Cloudflare Worker R2 proxy (separate npm workspace)
│ ├── wrangler.toml
│ ├── src/index.ts # /v1/health, /v1/index, /v1/messages/:id, /v1/notify
│ └── test/ # vitest-pool-workers integration tests
├── scripts/
│ └── generate-ciphertext-fixtures.sh # pinned-identity rage fixture builder
├── .github/
│ ├── workflows/test.yml # CI: rust + pwa + worker on every push and PR
│ ├── workflows/security-scan.yml # typos, gitleaks, TruffleHog, scheduled OSV
│ └── dependabot.yml # weekly grouped PR for github-actions majors
└── .gitignore
cargo build # Rust CLI
cargo test # Rust: 91 unit + 42 integration
cd web && npm install && npm test # PWA: 152 unit
cd web && npm run e2e # PWA: 2 playwright (needs chromium)
cd web/worker && npm install && npm test # Worker: 55 integration
cargo run -- --help # CLI help
NTS_HOME=/tmp/nts-test cargo run -- init # CLI with custom data dir
npm run dev --prefix web # PWA dev server (localhost:5173)PWA deploy steps live in web/README.md.
.github/workflows/test.yml runs the three test suites (cargo, vitest for the PWA, vitest-pool-workers for the Worker) on every push to main and every pull request. Cancels in-progress runs on the same ref so force-pushes do not queue duplicates.
.github/workflows/security-scan.yml runs typos, gitleaks, TruffleHog, and pre-commit on push and pull request. OSV runs on schedule and manual dispatch. osv-scanner.toml holds the dated root Cargo.lock exception for the upstream age transitive RUSTSEC-2026-0173 advisory.
.github/dependabot.yml opens a weekly grouped PR for any new GitHub Actions major version. This is the structural fix for the periodic Node-runtime deprecations that otherwise force an emergency sweep across every repo that uses Actions.
Secrets (R2 keys, ntfy token, age identity) can be loaded from env vars
seeded by 1Password at shell-init time. See the "Moving secrets to
1Password" section of web/README.md and the ADR in
docs/architecture.md for the rationale. Plaintext config.toml /
identity.txt fields remain readable for back-compat until M5.
CLAUDE.md (this file)
| Doc | Responsibility |
|---|---|
CLAUDE.md |
Project overview, principles, structure, dev instructions |
docs/research.md |
Landscape research and competitive analysis |
docs/architecture.md |
Technical architecture and design decisions |
docs/roadmap.md |
Feature roadmap and milestones |
| Event | Update |
|---|---|
| Architecture decision made | docs/architecture.md, CLAUDE.md if structural |
| New feature planned | docs/roadmap.md |
| Research finding | docs/research.md |
| Project structure change | CLAUDE.md project structure section |
| Milestone completed | Annotated git tag mNx-complete at the boundary commit, with a multi-line message describing what shipped and test totals. m4a-complete and m4b-complete exist as precedent. |
Each milestone gets an annotated tag at its boundary commit:
- Format:
mNx-completewhereNis the milestone number andxis the sub-milestone letter (e.g.,m4a-complete,m4b-complete,m5-completeif no sub-letters). - Annotated, not lightweight:
git tag -aso the message carries a full description of what shipped, test totals, and references to the relevant handoff document. - Push immediately:
git push origin <tag>so GitHub picks it up as a release-candidate reference. - Rollback usage:
git checkout m4a-completereturns the tree to that boundary without needing to scan the log.
Existing tags on origin: m4a-complete (commit 07a9920), m4b-complete (commit 5a12d63).