A lightweight, local-first ticketing app. Tickets are plain Markdown files in a folder tree, much like an Obsidian vault. A small background daemon keeps them consistent and exposes a local API. Everything beyond ticket management is a plugin.
Status: v0.1 scope is complete: the core (command line, daemon, local API, rules, migration, update check) and a minimal MCP plugin work end to end, with installers for macOS, Linux and Windows, a release workflow and a Docker image. Only macOS and Docker installs have been exercised on a real machine; see Install. Also built: synchronization to other trackers (a sync plugin with an archive and a Jira connector; the Jira connector has only been tested against a mock server, not a live site). Still to come: signed releases.
- Very small core, open source.
- Tickets are Markdown with frontmatter: id, title, status, priority, due, assignee, tags and links.
- Relations between tickets: parent, blocked by, related.
- Automatic rules: finishing a ticket unblocks the tickets it blocks; starting a sub-ticket marks its parent in progress.
- Local HTTP API (localhost only, token protected) for listing, creating and updating tickets.
- Runs natively on macOS, Linux and Windows (installers provided) or in Docker.
- UI, MCP server and sync to other trackers are plugins.
| Path | Contents |
|---|---|
docs/ |
Specification, design review, API and plugin documentation |
scripts/ |
Installers: install.sh (macOS, Linux), install.ps1 (Windows) |
Dockerfile |
Container image |
crates/ |
Rust crates for the core |
plugins/ |
Out-of-process plugins |
plugins/sync, plugins/connector-* |
Synchronization: the sync plugin, the connector protocol, the archive and Jira connectors |
examples/plugins/ |
Example plugins, such as follow-up (a workflow plugin) |
examples/tickets/ |
Sample ticket folder |
Build with cargo build --release; the binary is target/release/simpletickets. Point it at a tickets folder with --dir or SIMPLETICKETS_DIR (default: the current folder). Settings live in simpletickets.yml inside that folder; see examples/tickets for a sample.
simpletickets create "Write report" --priority high --due 2026-11-05 --tag work
simpletickets create "Collect data" --parent ST-1 --blocked-by ST-2
simpletickets list --status todo --tag work --blocked false --due-by today
simpletickets create "Review" --assignee ann
simpletickets assign ST-3 ann
simpletickets unassign ST-3
simpletickets list --assignee ann
simpletickets list --field team=core --json
simpletickets show ST-1
simpletickets set-status ST-1 done
simpletickets search "quarterly report" --status todo
simpletickets search quarterly --json
simpletickets doctor
simpletickets forget ST-2
simpletickets reassign-id ST-4 --path ~/tickets/copy-of-st-4.md
simpletickets rescan --dry-run
simpletickets templates
simpletickets plugins list
simpletickets plugins enable mcp
createpicks the next ID (ST-1,ST-2, ...), renders the body from a template (--template, defaultdefault) and writesID-title.md. Withparent_folderandsubticket_folderconfigured,--role parentand--parentchoose the folder.listfilters:--status,--category,--priority,--assignee,--tag,--parent,--title,--blocked,--due-by,--field key=value. Add--jsonfor machine-readable output.- The assignee is a core frontmatter key (
assignee: Ann), a single line of text.create --assignee,assign ID NAMEandunassign IDset and clear it;list --assigneematches case-insensitively andlistshows it as@name. A top-levelassigneetext or number that already exists in a file is read as the core field, so--field assignee=Annand files written before it became a core key keep working; anassigneeof any other shape (a list, say) stays a free-form key. search QUERYfinds tickets by title and body text, best match first, with a snippet of the first body match (--jsonaddsscore,match_fieldandsnippet;--limit,--status,--category,--assignee,--tagnarrow it). See Search below.set-status,createandrescanrun the rules afterwards. A sub-ticket in progress sets its parent to in progress; a done (or, by default, cancelled) blocker stops blocking. Parents insideparent_folderare specification files: their state is derived and never written. Automatic changes are appended to.simpletickets/audit.jsonl;rescan --dry-runshows them without writing.plugins list|enable|disable|statusmanages plugins: manifests are found in.simpletickets/plugins/<name>/plugin.ymland in the per-user plugins folder, and theplugins.enabledlist insimpletickets.ymlsays which are on. See docs/plugins.md.- Custom templates are Markdown files in
.simpletickets/templates/NAME.mdusing{{title}},{{id}}and{{date}}. doctorexits non-zero when it finds duplicate IDs, malformed frontmatter, missing link targets, cycles, orphans or an offline root. It also lists the rule warnings recorded by the last rule run (see below); warnings never change the exit code.forget IDacknowledges a deleted ticket (see Data safety).reassign-id ID [--path FILE]resolves a duplicate ID (see Duplicate IDs).
Release archives are named simpletickets-<target>.tar.gz (.zip on Windows) and contain simpletickets, simpletickets-mcp, simpletickets-ui, simpletickets-sync and the connectors simpletickets-connector-archive and simpletickets-connector-jira, plus a plugins/<name>/plugin.yml manifest for each; SHA256SUMS lists their checksums. The installers verify the archive against it.
| Target | Archive |
|---|---|
| macOS Apple silicon | simpletickets-aarch64-apple-darwin.tar.gz |
| macOS Intel | simpletickets-x86_64-apple-darwin.tar.gz |
| Linux x86_64 (glibc) | simpletickets-x86_64-unknown-linux-gnu.tar.gz |
| Linux aarch64 (glibc) | simpletickets-aarch64-unknown-linux-gnu.tar.gz |
| Windows x86_64 | simpletickets-x86_64-pc-windows-msvc.zip |
Pushing a tag v* runs .github/workflows/release.yml, which builds these, publishes the GitHub release and pushes a Docker image to ghcr.io. No release has been published yet, so until one exists the installers must build from a checkout (--from-source, the default when cargo is available).
scripts/install.sh # asks which addons to include
scripts/install.sh --yes --addons mcp,ui
scripts/install.sh --dry-run # print the plan and the launchd plist, change nothing
The installer builds from this checkout when cargo is available (otherwise it downloads the latest release archive), copies the binaries to ~/.local/bin, creates the tickets folder (~/SimpleTickets), writes ~/Library/LaunchAgents/com.simpletickets.daemon.plist and offers to load it so the daemon runs at login. Logs go to ~/Library/Logs/SimpleTickets/daemon.log. To remove the service: launchctl bootout gui/$(id -u)/com.simpletickets.daemon and delete the plist.
The same script, scripts/install.sh, detects Linux and writes a systemd user unit, ~/.config/systemd/user/simpletickets.service, then offers to run systemctl --user enable --now simpletickets.service. Logs go to the journal (journalctl --user -u simpletickets). To keep the daemon running while you are logged out: loginctl enable-linger $USER. Remove it with systemctl --user disable --now simpletickets.service and delete the unit file.
scripts/install.sh --dry-run --os linux previews the Linux plan, including the unit text, from any machine. Status: the script passes bash -n and shellcheck, and the dry-run output was checked, but no install has been run on a real Linux machine, and the release archives for Linux have not been built yet.
powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -Yes -Addons mcp
The script copies the binaries to %LOCALAPPDATA%\SimpleTickets\bin, creates the tickets folder (~\SimpleTickets) and registers a per-user scheduled task named SimpleTickets that starts the daemon at logon (hidden, restarted up to three times if it exits). A true Windows service is out of scope: the daemon does not implement the service control protocol, and a scheduled task needs no administrator rights. Remove it with Unregister-ScheduledTask -TaskName SimpleTickets -Confirm:$false. Parameters: -Prefix, -TicketsDir, -Port, -Addons, -PluginsDir, -FromSource, -FromRelease, -NoService, -NoStart, -Yes, -DryRun.
Status: untested. The script has not been run or parsed by PowerShell, and the Windows build is only exercised by the CI Windows job once it runs.
--prefix, --tickets-dir, --port, --addons LIST|none, --plugins-dir, --from-source, --from-release, --launchagents-dir (macOS), --systemd-dir (Linux), --no-service, --no-load, --yes, --dry-run, --os macos|linux (previews only, needs --dry-run).
install.sh builds its addon list from the plugin manifests in plugins/*/plugin.yml: a manifest with an install: block (package and binary) is offered as an addon, and its manifest is copied into the per-user plugins folder so the daemon finds it (see docs/plugins.md). install.ps1 does the same with a minimal line parser and copies the manifests into %APPDATA%\simpletickets\plugins (-PluginsDir changes it); this manifest handling is unrun, like the rest of the script.
docker build -t simpletickets .
docker run -d --name simpletickets -v ~/tickets:/data -p 127.0.0.1:7878:7878 simpletickets
cat ~/tickets/.simpletickets/token # the API token
docker exec -i simpletickets simpletickets-mcp --dir /data # MCP server inside the container
The image contains simpletickets, simpletickets-mcp, simpletickets-sync and both connectors, with their manifests in /usr/local/share/simpletickets/plugins (SIMPLETICKETS_PLUGINS_DIR), so docker exec simpletickets simpletickets plugins list shows them and plugins enable sync works. The UI is not in the image: simpletickets-ui only listens on 127.0.0.1 and has no bind-address option, so it would not be reachable from outside the container. Use the UI from a native install, pointed at the published daemon port.
The container runs simpletickets --dir /data daemon --bind-all --poll. Bind mounts often deliver no file events, so --poll (every 2 seconds, --poll-interval) detects changes by polling instead; use it for any folder on a network share or mount that misses events. --bind-all makes the daemon listen on all interfaces inside the container, which is what lets Docker publish the port; keep the 127.0.0.1: prefix on -p so the API stays reachable from this machine only. The token still applies. The image has a health check that opens a TCP connection to port 7878; it cannot call /v1/health because every API route, health included, requires the token. The process runs as UID 1000; the mounted folder must be writable by it, or pass --user $(id -u):$(id -g).
simpletickets daemon --port 7878 --scan-interval 60
Other flags: --no-watch (periodic scans only), --poll (see Docker), --token-file, --no-update-check, --event-backlog (events kept for late pollers, default 1000), --log-level, --log-file.
Logging: one line per entry, <UTC time> <LEVEL> <message> key=value ..., on stderr or appended to --log-file. --log-level is error, warn, info (default) or debug; SIMPLETICKETS_LOG_LEVEL and SIMPLETICKETS_LOG_FILE supply defaults that the flags override. The daemon logs startup and shutdown, scans (debug, except the startup scan), applied rules, rule warnings, errors, roots going offline or online, lease changes, migrations and update checks. A rule application is logged at warn when the ticket carries a tag configured as warn in tag_logging, otherwise at info. The API token is never logged.
Shutdown: on SIGINT or SIGTERM (and SIGHUP on unix; Ctrl-C, Ctrl-Break or closing the console on Windows) the daemon stops accepting requests, lets the workers and the scheduler finish, releases the folder lease, flushes the log and exits with status 0. A second daemon can take over immediately instead of waiting for the lease to go stale. launchctl bootout, systemctl --user stop and docker stop all send SIGTERM, so they shut the daemon down the same way.
Events: ticket.created, ticket.updated, ticket.status_changed, ticket.moved, ticket.deleted (for every change to a ticket, whether made through the API, a rule, the command line or by hand), plus scan, rule.applied, root.offline, root.online, warning.raised, tombstone.forgotten, ticket.id_reassigned, lease.lost, lease.acquired, state.error and others. The full contract (envelope, payloads, gap detection, delivery guarantees) is in docs/api.md.
The daemon scans all folders on startup, watches them for changes, rescans periodically and runs the rules after each scan. Every response carries X-SimpleTickets-Api-Version; within v1 changes are additive only (see docs/api.md). The API listens on 127.0.0.1 only and requires Authorization: Bearer <token>. The token is generated on first run and stored in .simpletickets/token (owner-only permissions; --token-file overrides the location). Requests whose Host is not a localhost name are refused.
| Route | Purpose |
|---|---|
GET /v1/health |
api_version (integer, 1), version, status (ok or degraded while a root is offline), ticket and frozen counts, offline_roots, rules_enabled and update (null, or the newer release found by the update check) |
GET /v1/parents |
parent tickets |
GET /v1/tickets?status=&category=&priority=&assignee=&tag=&parent=&title=&blocked=&due_by=&field.<key>=&limit= |
list and filter |
GET /v1/search?q=&limit=&<filters> |
full-text search; the same filters as the list, results carry score, match_field and snippet; q on GET /v1/tickets does the same |
GET /v1/tickets/{id} |
one ticket with body and derived data |
POST /v1/tickets |
create (title, status, priority, assignee, due, tags, parent, blocked_by, related, template, body, role) |
PATCH /v1/tickets/{id} |
update; null clears priority, assignee, due, parent |
POST /v1/rescan |
scan and run the rules now |
GET /v1/doctor |
consistency report: clean, issues, tombstones and the recorded rule warnings |
GET /v1/tombstones, DELETE /v1/tombstones/{id} |
deleted tickets that are still referenced; acknowledge one |
POST /v1/tickets/{id}/reassign-id |
give a conflicted copy of a duplicated ID a new one ({"path": "..."} optional) |
GET /v1/config, PUT /v1/config |
read or replace the configuration ({"yaml": "..."}); a change that needs a migration is refused with its plan unless "migrate": true is sent |
POST /v1/migration |
{"yaml": "...", "dry_run": true} returns the migration plan for a new config; "dry_run": false applies it |
GET /v1/migration, POST /v1/migration/resume |
journal of the last or current migration; finish an interrupted one |
GET /v1/events?since=N&wait=S&stream=ID |
event stream by long polling: events after sequence N, waiting up to S seconds; the response says when N is older than the retained backlog (gap) |
GET /v1/audit?limit=N |
automatic changes |
GET /v1/templates |
template names |
simpletickets.yml can list outgoing webhooks. Matching events are POSTed as JSON with an HMAC-SHA256 signature; the secret is read from the environment variable named by secret_env, never stored in the config.
webhooks:
- url: http://127.0.0.1:9000/simpletickets
events: ["ticket.*", "rule.applied"] # globs; default ["*"]
secret_env: SIMPLETICKETS_WEBHOOK_SECRETOnly http and https URLs are accepted; local and private addresses are fine. Failed deliveries are retried with a capped backoff (1 s doubling to 30 s, six attempts), logged, and never delay the API or the rules. Headers, signature verification and delivery guarantees are in docs/api.md.
Search covers ticket titles and Markdown bodies (other frontmatter keys are not searched; use the list filters for those). The files stay the source of truth: the daemon and the CLI build an in-memory inverted index on every scan and discard it with the scan, so results are as fresh as the last scan and nothing extra is stored on disk.
- Case-insensitive and token-based: a token is a run of letters and digits, so
ST-12is the tokensstand12. A word with punctuation matches only when its tokens sit next to each other. - Every word and every
"quoted phrase"must match (AND). A phrase matches its tokens in order, next to each other. An unterminated quote runs to the end of the query. - Words match whole tokens: no stemming, prefixes, fuzziness, accent folding, OR or NOT.
reportdoes not findreports. - Ranking: each title occurrence scores 5, each body occurrence 1; equal scores come in ID order, so the order is deterministic.
- Bodies are held in memory for snippets and matching, so memory grows with the total size of the ticket files. The index is rebuilt in full on each scan, which is fine for thousands of tickets but is not designed for very large folders.
- A query with no letters or digits is refused (
400over the API).
plugins/ui builds simpletickets-ui, a local kanban board: a small web server on 127.0.0.1 that serves a single-page app and relays its requests to the daemon, adding the API token on the server side so the browser never sees it. Other websites cannot drive it: the Host must be a localhost name, state-changing requests must come from the board's own origin, and every request needs a per-session header token issued with the page.
cargo build --release -p simpletickets-ui
simpletickets-ui --dir ~/tickets --port 7979 # then open http://127.0.0.1:7979/
Start the daemon first, or enable the plugin (simpletickets plugins enable ui) so the daemon starts and supervises the board on port 7979 (see docs/plugins.md). Columns follow the status categories, cards can be dragged between them, and the board follows the event stream live. See docs/ui.md.
plugins/mcp builds simpletickets-mcp, a separate process that speaks MCP over stdio (newline-delimited JSON-RPC) and forwards tool calls to the running daemon. It has no dependency on the daemon's code at runtime, only on the local API and its token.
cargo build --release -p simpletickets-mcp
simpletickets-mcp --dir ~/tickets # reads ~/tickets/.simpletickets/token
simpletickets-mcp --url http://127.0.0.1:7878 --token-file /path/to/token
Settings come from flags or SIMPLETICKETS_URL, SIMPLETICKETS_TOKEN, SIMPLETICKETS_DIR. Register it in an MCP client as a stdio server whose command is simpletickets-mcp with those arguments. Start the daemon first.
| Tool | Purpose |
|---|---|
list_tickets |
filters: status, category, priority, assignee, tag, parent, title, blocked, due_by, fields, limit |
search_tickets |
query (words and "quoted phrases"), optional status, category, priority, assignee, tag, parent, limit; best matches first with score and snippet |
show_ticket |
one ticket with body and derived data |
create_ticket |
title plus any of the other ticket fields, body, template, role (parent or sub) |
update_ticket |
id plus the fields to change; null clears priority, assignee, due, parent |
next_ticket |
the first unblocked todo ticket matching the optional filters, ordered by order (default due, priority; id is also accepted). Tickets without a due date sort last; priorities rank critical, high, medium, low, then P0-style numbers, then the rest |
API errors come back as tool errors, so a client sees the daemon's message (for example an unknown status).
plugins/sync builds simpletickets-sync, a service plugin that mirrors tickets to other trackers through separate connector programs. The core reserves no fields for it: the plugin keeps its own state (ticket to remote item, last synced values, conflicts) under .simpletickets/sync/ and never writes into ticket files. It follows the event stream (resyncing on a gap), reconciles periodically, pushes local changes through the connector protocol, and with direction: both writes remote status and field changes back through the API as the actor plugin:sync. Local wins conflicts by default; conflicts are always recorded and listed, never guessed, and a ticket body is never overwritten. Deleted tickets close the remote item and never delete it.
scripts/install.sh --addons sync,connector-archive,connector-jira
simpletickets plugins enable sync
simpletickets-sync status [--check]
simpletickets-sync dry-run
simpletickets-sync push-now [ID...] [--prefer local|remote]
simpletickets-sync list-conflicts
connector-archivewrites one JSON or Markdown file per ticket to a folder; it is the reference connector and the test double.connector-jiratalks to Jira Cloud (REST API v3): issues, status transitions, priority, labels, assignee, sub-tasks, blocked-by links, descriptions rendered to ADF. Credentials come only fromJIRA_SITE_URL,JIRA_EMAILandJIRA_API_TOKEN. It is tested against an in-process mock server only; behaviour on a live Jira site is unverified.
Configuration, the state model, conflict and deletion rules, the connector protocol and the Jira mapping and limits are in docs/sync.md.
The daemon asks the GitHub releases of MichalVacek/SimpleTickets for the latest release at startup and then every 24 hours (--update-interval-hours). A newer version is announced once through an update.available event and in the update field of /v1/health. Nothing is downloaded or installed. Being offline or hitting a GitHub error is silent; the check simply runs again later. Disable it with --no-update-check.
Changing parent_folder, subticket_folder or the status mapping can require moving files or rewriting statuses. A migration is planned first (dry run), then applied through a journal in .simpletickets/migration.json that is saved after every step. Steps are idempotent, so an interrupted migration is finished with migrate resume (the daemon does this on startup). The new config file is written last. Ticket IDs never change and links are IDs, so they stay valid; a check after completion confirms that no ticket disappeared and no link broke.
simpletickets migrate plan new-config.yml # show the steps, change nothing
simpletickets migrate apply new-config.yml # journaled apply, then install the config
simpletickets migrate resume
simpletickets migrate status
- Tickets inside the old parent or sub-ticket folder move to the new folder of the same kind, keeping their relative path. Moves that would overwrite a file or leave every scanned folder are reported as conflicts and skipped.
- Configuring
parent_folderorsubticket_folderfor the first time, when tickets already sit scattered in the roots, moves them into place by role: a ticket that has sub-tickets pointing at it goes toparent_folder, a ticket with aparentgoes tosubticket_folder, keeping its file name. Tickets that are neither, and tickets already inside a configured folder, stay where they are. A ticket that is both a parent and a sub-ticket, a target file that already exists, two tickets that would land on the same file name, and a ticket in an offline root are reported as conflicts and skipped, never overwritten. Runmigrate planfirst; the moves are journaled and resumable like any other step. id_prefixchanges need no steps: existing IDs keep the prefix they were created with (IDs never change), and only new tickets use the new prefix. The plan prints a warning saying so, andPUT /v1/configreturns it inwarnings. Sequence numbers are counted per prefix: withid_prefix: TK, ticketsST-1..ST-5andTK-2give the next IDTK-3, and switching back toSTcontinues atST-6. IDs of deleted tickets (tombstones) are never reused, whatever their prefix.- A status removed from the mapping is replaced by the first status of its old category.
- A step whose file changed since the plan is skipped rather than overwritten; a step that fails (locked or read-only file) is retried by
migrate resume. - The daemon only suggests a migration (
migration.suggestedevent) when the config file is edited by hand.
- Specification
- HTTP API and event contract
- Plugins
- Synchronization and the connector protocol
- Design review
MIT.