A self-hosted reminder service, tightly coupled with ntfy - set reminders in plain English, get them back as push notifications.
later in 25 minutes start the laundry
later in 3d rotate the tires
later next tuesday at 2pm pick up the dry cleaning
later march 3rd sort out the tax paperwork
If you're already running ntfy, that's the only other piece you need - just plain JSON files instead of a database and no user accounts needed. Text it or CLI it, get pinged when it's due.
- You run
later-server(Docker) alongside your existing ntfy server. - You send it a reminder - either the CLI (
later in 3d buy milk) or by texting to an ntfy topic from your phone. laterparses the natural language, stores the reminder (plain JSON), and fires an ntfy push notification when it's due.- Fired notifications carry one-tap Snooze 1h / Tomorrow buttons - no need to open a terminal just to push something back.
- Natural language reminders - "in 3 days call the plumber", "next tuesday at 2pm", "2h30m", whatever's natural to type. No rigid syntax to learn.
- Two ways in - the CLI (
later in 3d buy milk) or just texting a reminder to your own ntfy topic from your phone, no terminal needed. - Not sure it'll understand you? Ask first -
later test parse tomorrow at 2am go to bed(or text/test ...to your ntfy topic) shows exactly what would get scheduled - task text and due time - without actually creating anything. - Texting one in talks back - reply confirms the exact due time and the reminder's id, or explains what went wrong (bad input, a topic your token isn't allowed to use, etc.) - you're never left guessing whether it actually took.
- Tag or prioritize from your phone - add
#tag/!highto the end of a message and it carries through ("call mom tomorrow #family !high"). - Stubs for the reminders you set constantly - define
:laundryonce (later stub set laundry in 2h sort laundry, or text/stub laundry ...), then set it from any device withlater :laundryor a text saying:laundry. See Stubs. - Real push notifications - tags, priority, and a tappable link if you
want one, same as any other ntfy notification. If a reminder's been
sitting a while before it fires (say the server was briefly down), the
notification gets a
DELAYED:prefix, a warning tag, and a priority bump - so it doesn't get lost in the noise. - Flexible routing - each access token can be scoped to its own set
of ntfy topics. Skip
default_outboundand reminders with no explicit topic fan out to all of them; set it to route unscoped reminders to just one instead. - Cancel or postpone anytime - reference a reminder by id, or just
say
lastin the CLI for the one you created or postponed most recently, no need to go copy an id first. - One-tap snooze from the notification itself - fired reminders carry
"Snooze 1h" / "Tomorrow" buttons (ntfy's own
Actionsfeature), each with a scoped, short-lived, single-use credential baked in. Opt-in: configurebase_urland it turns on, leave it unset and it doesn't. - Clear a notification everywhere, not just the device you tapped - a third button, "Clear", uses ntfy's server-side clear mechanism rather than a purely local dismiss, so it syncs across every device subscribed to that topic. Support for this specific ntfy feature varies by client - check ntfy's own docs for what's currently supported on yours, and note it needs ntfy server v2.16.0+.
- A proper CLI - free text, no quotes needed, works with piped input
too. Sortable/limitable lists, quick access to what's coming up next or
what just fired, search across pending or fired reminders,
--jsonoutput if you want to script it, and simple config via flags, environment variables, or a config file. - A real HTTP API - create, list, cancel, and postpone reminders from anything that can make an HTTP request. Token-based auth, and the list endpoints support the same sort/limit/search options the CLI uses.
- No database - just plain JSON files (
pending.json,archive.json, andstubs.jsononce you define a stub) you can read, edit,jqthrough, or back up by hand. Nothing to migrate, nothing opaque. Writes are atomic (temp file + fsync + rename), so a crash or power loss mid-write can't corrupt any of them. - Docker-ready - image on GHCR for
amd64andarm64, built ondistroless/staticwith a real healthcheck baked in (later healthcheck/GET /healthz, no token needed). Timezone data is compiled in, so a plainTZ=Europe/Berlinenv var just works.
You need an ntfy server already running (self-hosted or ntfy.sh) and a token for it.
1. Get later-server running:
# compose.yml
services:
later:
image: ghcr.io/zawnk/later-server:latest
container_name: later
restart: unless-stopped
environment:
- TZ=Europe/Berlin
volumes:
- ./data:/data
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "/later", "healthcheck"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 32. Configure it - create data/config.yaml:
server:
base_url: https://later.yourdomain.com # optional: enables Snooze/Tomorrow/Clear notification buttons; omit to leave that off
ntfy:
server: https://ntfy.yourdomain.com
token: tk_XXXXX # your ntfy token
inbound:
- topic: later-input-me # text/send reminder commands here
outbound: [later-reminders-me] # reminders from this inbound topic fire here
auth_tokens:
- token: tk_replace_this_with_a_real_token_at_least_16_chars # generate your own, e.g. ntfy cli or `openssl rand -hex 16`
outbound: [later-reminders-me] # topics this token's reminders are allowed to target./data on the host holds both config.yaml and the state files
(pending.json, archive.json, and stubs.json once you define a
stub) - one directory, everything in it.
See data/config.yaml.example for every
option, including optional ones like default_outbound.
3. Run it: docker compose up -d
4. Try it:
curl -X POST https://later.yourdomain.com/reminders \
-H "Authorization: Bearer tk_replace_this_with_a_real_token_at_least_16_chars" \
-H "Content-Type: application/json" \
-d '{"text": "in 1 minute test the setup"}'
...or just install the CLI and run later in 1 minute test the setup.
Shortly after, a push notification should land on your phone.
Grab a binary from the Releases page
(pick one tagged later/vX.Y.Z, not later-server/... - that one's the
Docker-image release):
curl -LO https://github.com/zawnk/later/releases/download/later/v0.3.0/later_v0.3.0_linux_amd64
chmod +x later_v0.3.0_linux_amd64
sudo mv later_v0.3.0_linux_amd64 /usr/local/bin/later
(swap amd64 for arm64 if that's your machine; a later_checksums.txt
ships alongside each release if you want to verify the download)
Only Linux binaries are published right now. On macOS or Windows, build it yourself instead (needs Go 1.27+):
git clone https://github.com/zawnk/later.git
cd later
go build -o later ./cmd/later
Then point it at your server - ~/.config/later/config:
later_url = https://later.yourdomain.com
later_token = tk_replace_this_with_a_real_token_at_least_16_chars
(--url/--token flags or LATER_URL/LATER_TOKEN env vars work too,
and override the config file.)
later in 3d buy milk # create - no quotes needed
later list # pending reminders, soonest first
later archive --limit 10 # last 10 fired reminders
later search milk # substring search, pending by default
later next # what's coming up next
later cancel last # cancel the one you just created
later postpone last 1h # reschedule a fired reminder in an hour
later test parse tomorrow at 2am sleep # preview parsing, creates nothing
later :laundry # invoke a stub - plain free text
later stub list # every stub and what it expands to
later stub set laundry in 2h sort laundry # define one (or replace it)
later stub del laundry # delete it (rm works too)
Add --json to any read command for machine-readable output.
Not an exhaustive list - just enough to show the range. All of these are also available by text message straight to your ntfy inbound topic.
Durations:
later in 3d rotate the tires
later in 2h30m check on the laundry
later in 1w2d water the tomatoes
later in 3 days call the plumber for a quote
later in 45 minutes take the bread out of the oven
Casual dates/times:
later tomorrow evening call grandma
later tonight take out the recycling
later this afternoon reply to the neighbor about the fence
Weekdays:
later next tuesday renew the library books
later this friday order more coffee beans
Exact calendar dates:
later march 3rd sort out the tax paperwork
later 1st of september start the fantasy football draft prep
later 28/07/2026 book the campsite for the summer trip
Specific times:
later at 5pm start marinating the chicken for dinner
later at 2:30pm join the team standup
Combined:
later next tuesday at 2pm pick up the dry cleaning
later at 5pm ...-style examples resolve relative to now - if it's already past 5pm today, that's a past-due rejection, not a bug. Addtomorrow(later tomorrow at 5pm ...) if you're trying this late in the day, or just lean on the duration-based examples above, which never go stale.
Tag or prioritize inline (texted reminders only), trailing, any combination:
call mom tomorrow #family !high
water the office plants next monday #chores
Some reminders get set over and over - same text, same duration, several
times a week. A stub is a named shorthand for one of them, invoked
with a leading :.
Define it once, from the terminal:
later stub set hockey in 17m intermission is over --tag hockey --priority high
(--tag, --priority and --click are the same notification flags
reminder creation takes. There is no --topic, for the reason in the
last bullet below.)
...or by text via ntfy, where a trailing #tag / !priority works exactly as
it does on a reminder:
/stub hockey in 17m intermission is over #hockey !high
Then set it, from either, with the same few keystrokes:
later :hockey # from the CLI
:hockey # texted to your inbound topic
Both create "intermission is over", due in 17 minutes, tagged hockey at
high priority - the tags and priority come from the stub itself.
Trailing text is appended to the stub's own text, so :hockey overtime
schedules "intermission is over overtime" - still in 17 minutes. A
priority or click given at invocation time overrides the stub's; tags
from both are merged.
Managing them:
later stub list # or text /stubs
later stub del hockey # or text /unstub hockey
later test parse :hockey # preview the expansion, creates nothing
Definitions live in stubs.json, alongside pending.json and
archive.json in ./data - a plain JSON map, created the first time you
define a stub, readable and hand-editable like the rest. There is no
config key for it.
A name must be lowercase and letter-initial ([a-z][a-z0-9_-]*).
A stub's text can't itself be a stub invocation: expansion happens once,
so :a expanding to :b is rejected when you define it.
The per-device alternatives are good and still recommended: a shell
alias (alias hockey='later in 17m intermission is over'), a phone Shortcut
POSTing a fixed body to /reminders (one tap - faster than typing
:hockey ever will be), or keyboard text replacement. What a stub adds
over them is one definition, invoked identically from every device,
including devices you can't install anything on. If the alternative is
maintaining the same shortcut in two places and watching them drift,
that's the case for it. Its main purpose is convenience, not capability.
Two things worth knowing:
- Clock-time stubs go stale during the day. A stub reading
at 5pm start dinnerdefines fine at any hour, but invoking it only works before 5pm - after that it's a past-due rejection, exactly as the same text typed as a plain reminder would be. It just bites more often, because a stub is text you re-run daily. Duration stubs (in 15m) never go stale. - Routing can't be stored in a stub. There's no outbound topic field. Destinations stay with your token and inbound config, which is also where they're scoped - stubs themselves are global.
Token-based auth (Authorization: Bearer <token>), JSON in and out.
| Method | Path | Notes |
|---|---|---|
POST |
/reminders |
Create. Body: {"text": "...", "outbound_topics": [...], "tags": [...], "priority": "...", "click": "..."} - only text is required. |
GET |
/reminders |
List pending. ?sort=due|create, ?q=<substring>, ?limit=N, ?offset=N. |
GET |
/reminders/{id} |
Fetch one, pending or archived. |
GET |
/reminders/archive |
List fired. ?limit=N, ?offset=N (paging back from the newest), ?q=<substring>. |
GET |
/reminders/next |
The soonest-due pending reminder. |
GET |
/reminders/last |
The most recently fired reminder. |
DELETE |
/reminders/{id} |
Cancel a pending reminder. |
POST |
/reminders/{id}/postpone?duration=1h |
duration is a query param - compact or natural language. |
POST |
/reminders/{id}/dismiss |
Clears the fired notification across every subscribed device (ntfy server-side, not just the calling client). {id} must be archived. |
GET |
/stubs |
List every stub, alphabetically. Each object carries its own name. |
PUT |
/stubs/{name} |
Create or replace a stub. Body: {"text": "...", "tags": [...], "priority": "...", "click": "..."} - only text is required. See Stubs for the name rule. |
DELETE |
/stubs/{name} |
Delete a stub. |
POST |
/test/parse |
Preview parsing. Body: {"text": "..."}. Creates nothing. |
GET |
/healthz |
No token needed. |
Errors come back as {"error": "..."} with a non-2xx status.
- If you rotate or purge
archive.json(neither should be necessary in normal use), be aware some functionality references archived reminder IDs - postpone, for instance. - The notification action buttons (Snooze/Tomorrow/Clear) are tapped from
your phone, calling
base_urldirectly - so a private LANbase_urlonly works when the device tapping the button can actually reach it (same network, reverse proxy or a VPN back to it). - The "Clear" button's multi-device sync is only as good as your ntfy client's own support for it - not every ntfy client implements the clear/read mechanism, and support has been inconsistent across ntfy's own docs snapshots. It's still safe to tap either way (worst case it behaves like a local dismiss), just don't assume it's guaranteed to clear every device until you've confirmed your specific client(s) support it.
- A bare
base_urlwith no scheme (e.g.192.168.1.53:8080) is assumed to behttp://, nothttps://- fine for most home networks; put a scheme on it yourself once you have TLS in front of it.
- A web UI - considered out of scope for this project but there's always the HTTP API.
- A database - the JSON files being
jq-able is a feature, not a gap. - Multi-user / accounts - this is a homelab tool for one person (or one household sharing tokens), not a SaaS.
- Supporting notification backends other than ntfy - the tight ntfy
coupling is the point.
laterleans on ntfy for delivery, priorities, tags, click actions, and the action-button mechanism instead of reimplementing all of it. - Sub-minute precision - the scheduler wakes up once a minute by
design, not because tighter polling is impossible.
laternudges you about things later; it's not a countdown timer, so it doesn't try to be exact to the second.