A local-first literary magazine submission tracker for macOS. Track submission calls, deadlines, and which of your stories you've sent where — all stored in plain Markdown files you own and edit directly.
No cloud account, no subscription, no database to manage. The app reads and writes two Markdown files on your disk.
- AI-powered deadline scraping — refresh pulls the current submission window from each magazine's website using Claude (via pi)
- Local-first data — everything lives in
magazines.mdandstories.mdat the project root; edit them in any text editor - Multi-call tracking — one magazine can have many submission calls (e.g. "General Submissions" and "Flash Contest") tracked separately
- Smart cache — scrapes are cached for 7 days; set
fixed_deadlineon any entry to pin it permanently - Story tracker — log your stories, word counts, status, and which calls each one is submitted to
- Optional phone view — deploy a read-only web view to GitHub Pages for checking deadlines on the go
- Native macOS app — built with Tauri; fast, no Electron overhead
| Tool | Version | Install |
|---|---|---|
| Node | 22 | nodejs.org or nvm use (see .nvmrc) |
| Rust | stable | rustup.rs |
| pi | latest | npm install -g @earendil-works/pi-coding-agent |
| Xcode CLT | — | xcode-select --install |
macOS only for the native Tauri window. The web preview (
npm run dev) runs anywhere.
git clone https://github.com/yourusername/courier
cd courier
npm installcp magazines.example.md magazines.md
cp stories.example.md stories.mdEdit magazines.md with the magazines you want to track. The example file explains every field. stories.md is optional — delete its contents if you don't want story tracking yet.
Deadline extraction uses pi to run Claude. Set up your Anthropic credentials once:
pi # opens the interactive shell
/login
# follow the prompts to add your Anthropic API keyCredentials are stored in ~/.pi/agent/auth.json — never in this repo.
Optional offline fallback: install Ollama and pull a local model:
ollama pull qwen2.5:7bThe scraper uses this automatically when the cloud model is unavailable.
npm run tauri:devThe app opens a native window. The Refresh button (or auto-refresh on load) scrapes deadlines for any entry not cached within 7 days.
All data lives in two YAML-frontmatter Markdown files:
magazines.md— submission calls with deadlines, URLs, notes, and scraper outputstories.md— your stories and which calls they're submitted to
These files are gitignored by default. They're yours; back them up however you like.
When you click Refresh (or on app load), Rust pipes the unfixed entries to scripts/resolve-deadlines.mjs. That Node worker:
- Fetches each magazine's submission/guidelines pages (plain HTTP, then Jina Reader for JS-heavy SPAs)
- Sends the page text to Claude Haiku for extraction
- Returns structured JSON:
{ deadline, confidence }
Low-confidence results get a fixed_deadline are never scraped — set this field when you know the deadline is stable or the scraper can't read the site.
Requests are deduped by URL: multiple calls sharing the same pages cost a single model request. Results are cached for 7 days; use Force Refresh to bypass.
Each entry is a call, not a magazine. (name, call_name) is the unique key — the same magazine can appear many times with different call_name values. Never dedupe by name alone.
Edit magazines.md directly:
- name: "Magazine Name"
homepage: "https://example.com"
guidelines: "https://example.com/guidelines"
submit_info: "https://example.com/submit"
external_submit_url: "https://example.submittable.com/submit" # optional
genre: "Fiction"
call_name: "General Submissions"
word_length: "up to 5,000 words"
# Optional overrides:
# fixed_deadline: "Always Open" # scraper will never touch this entry
# reopen: "September" # displayed as a hint when closed
# call_notes: "Notes about this call..."
# custom_name: "Short Name" # overrides display in the tableReload the app (or click Refresh) to see new entries.
You can deploy a read-only web view of your tracker to GitHub Pages.
- Push this repo to GitHub
- In your repo settings, enable GitHub Pages from the
gh-pagesbranch (or configure Actions to deploydist/) - Set the base path for your repo name and build:
VITE_BASE_PATH=/your-repo-name/ npm run build:webThis runs scripts/build-data.mjs (writes public/magazines.json + public/stories.json from your Markdown files) then builds the static site.
The app can automatically rebuild the JSON and push after every data change. To enable:
touch .mobile-syncThis creates the opt-in marker that activates spawn_mobile_sync() in Rust. It runs on a background thread after every refresh or in-app edit. Requires non-interactive git auth (SSH key or credential helper). Delete .mobile-sync to disable.
npm run tauri:buildCreates a signed .dmg in src-tauri/target/release/bundle/dmg/.
courier/
├── magazines.example.md # template — copy to magazines.md
├── stories.example.md # template — copy to stories.md
├── magazines.md # your data (gitignored)
├── stories.md # your data (gitignored)
├── scripts/
│ ├── build-data.mjs # MD → public/*.json for web deploy
│ └── resolve-deadlines.mjs # pi-powered AI deadline extractor
├── src/ # React frontend
├── src-tauri/
│ └── src/
│ ├── commands.rs # Tauri commands (read/write/refresh)
│ ├── data.rs # data structs
│ └── yaml_emit.rs # lossless YAML writer
└── public/
├── magazines.json # built artifact (gitignored)
└── stories.json # built artifact (gitignored)
See CLAUDE.md for data model details, field conventions, and workflow notes aimed at AI coding assistants.
MIT — see LICENSE.