A plain-Markdown task queue for local AI workers - no database, no server, stdlib only.
Project description
taskkit
📖 Looking for the guide? → Actually Using LLMs Properly — context windows, real token costs, local models, and giving your AI a memory. This package is Part 9 of it.
A task queue for local AI workers where the queue is a folder of Markdown files.
No database. No server. No cloud. No dependencies outside the Python standard
library. You can edit the queue in Obsidian, VS Code, or Notepad while the
workers are running, and git diff shows you exactly what a robot did.
- [ ] Summarise the Q3 support tickets | owner: writer | priority: P1
- [/] Draft the release notes | owner: writer | done: 2026-07-25
- [x] Rename the config keys | owner: coder | done: 2026-07-24
- [ ] Sign the lease | cat: human
That's the whole data model. Everything else in this package is machinery for running those lines safely.
Starter vault included
starter-vault/ is a ready-to-open Obsidian vault with the memory convention
from the guide pre-wired: a memory/ index your AI loads each session, four
worked example memories, note/task/plan templates, a taskkit queue folder,
and a zero-dependency search.py. Open it as a vault, replace the examples,
and your assistant has a persistent brain made of plain text. Details in
starter-vault/README.md.
Why this exists
Most "AI agent" setups store state somewhere you can't see — a vector DB, a SaaS dashboard, a JSON blob with no history. When the agent does something stupid at 3am you have no idea what it thought it was doing.
Plain Markdown fixes that. It's greppable, diffable, version-controlled by whatever you already use, readable by every model ever trained, and editable by a human who has never heard of your tool.
Install
pip install taskkit-md
Python 3.10+, Windows and Linux, zero runtime dependencies. (The dist is
taskkit-md — plain taskkit was taken — but the import and the command are
both still taskkit.) Prefer no install at all? git clone and use
python -m taskkit everywhere you see taskkit below.
The 60-second path
taskkit init # scaffolds tasks/, output/, taskkit.json
echo "- [ ] Explain what taskkit does in one paragraph" >> tasks/writer.md
taskkit run --once # a worker claims it, runs it, files the output
taskkit status # "1 item awaiting your review"
Open output/writer/ to read what it produced, then taskkit accept writer <id>. That's the whole loop: checkbox in, reviewed deliverable out. Wire a
real engine (Ollama, Claude Code) in taskkit.json — see
examples/claude-code/ for the 4-line Claude wiring.
Five-minute tour
export ROOT=~/mytasks
python -m taskkit --root $ROOT init
python -m taskkit --root $ROOT add writer "Explain what this repo does in 150 words" --field priority:P1
python -m taskkit --root $ROOT add writer "Sign the contract" --field cat:human
python -m taskkit --root $ROOT list
python -m taskkit --root $ROOT run --once # workers drain the queue
python -m taskkit --root $ROOT status # "1 item awaiting your review"
cat $ROOT/output/writer/*.md # read what it produced
python -m taskkit --root $ROOT accept writer t-abc1234567
Note what did not happen: the cat: human task was never touched, and
nothing reached [x] without you typing accept.
Wire it to a local model
taskkit.json maps each queue to a runner:
{
"owners": {
"writer": {
"runner": "ollama",
"model": "qwen3:8b",
"host": "http://127.0.0.1:11434",
"num_ctx": 8192,
"system": "You are a careful writer. Answer directly. No preamble."
},
"coder": {
"runner": "shell",
"command": "python tools/handle_code.py {text}",
"timeout": 900
},
"human": { "runner": "echo" }
}
}
Then leave it running:
python -m taskkit --root $ROOT run --interval 300 --min-interval 30
A cycle that got work sleeps 30s so a backlog clears fast; an idle cycle relaxes to 300s so an empty queue costs nothing.
Custom runners
A runner is any callable (task, cfg) -> str. Drop this anywhere that gets
imported:
from taskkit.runners import register
@register("my-api")
def run_my_api(task, cfg):
resp = call_whatever(task["text"], model=cfg["model"])
return resp.text # return the output, or raise to fail the task
Runners never touch the queue. They take a task and return text; claiming, completing and the review gate are the store's job. That separation is what lets you swap a local 8B model for an API call for a bash script without changing anything else.
The three rules that make it work
1. A worker cannot accept its own work
There are three checkbox states, not two:
| box | meaning |
|---|---|
[ ] |
open |
[/] |
done-pending-review — a worker produced output, nobody has accepted it |
[x] |
accepted |
A worker can only ever move [ ] → [/]. The only paths to [x] are a human
running accept, or external proof that the outcome actually happened.
This sounds like bureaucracy until you skip it. An agent that can mark its own homework will report a 100% success rate while producing nothing, and you will believe it, because the dashboard is green. Make the machine unable to lie about completion rather than trusting it not to.
Watch for the softer version of the same failure: a "review" step that closes
items on a timer. If unreviewed work auto-closes after N days, [x] means
expired, not accepted, and your metrics are fiction again.
2. Claim before you work
Every worker writes a lease onto the task line before running it:
- [ ] Summarise the tickets | claim: rig01-48213-1769342115
host-pid-epoch. Another worker sees a live claim and skips the task. If a
worker crashes, the claim goes stale after an hour and the task is reaped back
into the pool automatically.
Two things here are correctness requirements, not polish:
- Sanitise the hostname. The freshness check reads the epoch by position.
A machine named
DESKTOP-A1B2C3breaks the parse, every claim reads as unparseable, every worker thinks every task is free, and mutual exclusion silently evaporates — on some machines and not others. - Give each in-process thread a distinct worker suffix. Two threads claiming in the same second mint identical stamps, and since completion matches on the exact stamp, one thread can finalise the other's task.
3. Every field must survive the round trip
parse_line → serialize_task has to be lossless. A field that gets dropped
doesn't throw — it just quietly stops working. Drop claim and two workers run
the same task. Drop recur and a recurring task never comes back. Adding a
field means adding it to FIELDS in tasks.py, and there's a test that fails
if you forget.
Related: don't split the task line on a bare |. Any task whose text
contains a pipe — a shell command, a table row, an a|b key — gets cut in half
with no error. taskkit peels known fields off the right-hand side instead, so
Run `grep foo | wc -l` and report round-trips intact. There's a test for
that too, because this bug is invisible until it's expensive.
Gating: things a robot must never pick up
Any of these on an open task makes it invisible to automation:
| field | meaning |
|---|---|
cat: human / physical / credential |
needs hands, a body, or a password |
review: 1 |
first run of a new automation — a human eyeballs it once |
approve: pending |
blocked on a decision |
A human can still claim a gated task deliberately. Automation cannot, ever.
The failure this prevents is specific and real: an autonomous conductor once claimed "fix the car" and sat on it for eleven days, because to a task queue it looked exactly like every other line.
Design notes
Failures reopen the task. A failed run clears the claim, records the error,
and puts the task back to [ ]. It'll be retried next cycle — but not in the
same cycle, or a permanently broken task becomes a hot loop that hammers your
model server all night and never gets to anything else.
Empty output is a failure. A runner that returns nothing has failed, even if it exited 0. Half a document is worse than no document, because a human skims it and assumes it's finished.
Output goes on disk, one file per task, under output/<owner>/<id>.md. The
file — not the model's own word — is what a reviewer looks at. Without an
artifact, "review" means clicking accept on a claim you can't check.
Locking is a lockfile, not fcntl/msvcrt. Identical behaviour on both
platforms, and a human can ls to see who holds it. Stale locks are cleared
after 2 minutes.
Writes are atomic (temp file + os.replace), so a crash mid-write can never
truncate your queue.
Force UTF-8 on stdout in anything that prints model output. Under Windows Task Scheduler, Python defaults stdout to cp1252 and raises on the bullets, arrows and curly quotes that LLM output is full of. The traceback lands after the model already did the work — full cost, zero result — and it only reproduces headless, so testing by hand always passes. At one point this was silently killing 34% of dispatches on the box that taught me this.
Exit code 0 is not success. A tool that printed "<engine> unavailable: ..." and exited 0 meant every caller filed an outage as a completed
deliverable. Define success explicitly — never trust the exit code alone.
A truncated generation must fail, not become a draft. When the model reports it stopped on a length limit, that's a failure. Half a document is worse than none, because a human skims it and assumes it's finished.
Path.exists() is not verification. It happily marks an empty or
malformed file as done, and it misreads negated criteria entirely — "no
console errors" passes vacuously on a file that says nothing at all. Only
check something you can actually evaluate.
Shadow mode is how you ship an autonomous loop people will tolerate. Run the decision logic for N cycles, log what it would have done, then go live. Keep the decide step a pure function of a snapshot — no clock, no disk, no network — so shadow mode is a faithful preview, not an approximation.
Adaptive cadence needs asymmetry. A productive cycle sleeps the floor; an idle cycle relaxes toward the ceiling; failures never shorten the nap. Never hammer a host that's down.
Layout
taskkit/
tasks.py the line format: parse, serialize, gating rules
store.py the queue files: locking, claim/complete/release, reaping
runners.py pluggable executors (echo, shell, ollama) + @register
worker.py the drain loop and adaptive cadence
cli.py the command line
tests/
test_taskkit.py core engine tests
test_packaging.py CLI/packaging surface (init, examples, entry point)
test_starter_vault.py starter-vault search.py
44 tests total, no network, no model required
python -m pytest tests -q
Scheduling it
Linux — crontab -e:
*/5 * * * * cd /home/you/mytasks && /usr/bin/python3 -m taskkit run --once >> cron.log 2>&1
Windows — use pythonw.exe as the action, never cmd or python, or a
console window flashes on screen every five minutes forever.
Scope
taskkit is the boring, reliable half of an agent system: durable queue, safe concurrency, honest completion. It deliberately does not do planning, retrieval, or multi-agent orchestration. Those change every six months. A task queue you can read in a text editor does not.
Licence
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file taskkit_md-0.1.0.tar.gz.
File metadata
- Download URL: taskkit_md-0.1.0.tar.gz
- Upload date:
- Size: 30.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
45f82b32ca394739d761fc19e36cbcde1b540437167a111a9fa6b367bf9fdcc5
|
|
| MD5 |
15e31c0248d448d68223760b8b143ab0
|
|
| BLAKE2b-256 |
88c526030ad9de6f958b249cfa48b01696afe22bca489800fc8720d73e8fa3fb
|
Provenance
The following attestation bundles were made for taskkit_md-0.1.0.tar.gz:
Publisher:
publish-pypi.yml on Trashpanda62/taskkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
taskkit_md-0.1.0.tar.gz -
Subject digest:
45f82b32ca394739d761fc19e36cbcde1b540437167a111a9fa6b367bf9fdcc5 - Sigstore transparency entry: 2274336046
- Sigstore integration time:
-
Permalink:
Trashpanda62/taskkit@bbb5d33d6493f5b4aa1c9e38aa1ef86fcdecb802 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Trashpanda62
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@bbb5d33d6493f5b4aa1c9e38aa1ef86fcdecb802 -
Trigger Event:
release
-
Statement type:
File details
Details for the file taskkit_md-0.1.0-py3-none-any.whl.
File metadata
- Download URL: taskkit_md-0.1.0-py3-none-any.whl
- Upload date:
- Size: 24.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f6a379b3857aacf67cfe34bcdb1311e5692708b1e4fc8cc762e2852c05f3bd6
|
|
| MD5 |
475b45f3362aa5f32c720d762d97ee27
|
|
| BLAKE2b-256 |
a277f9ed62dce0a595f8341b02db12859411557e7aebcf5b2b14bff3b21a31a1
|
Provenance
The following attestation bundles were made for taskkit_md-0.1.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on Trashpanda62/taskkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
taskkit_md-0.1.0-py3-none-any.whl -
Subject digest:
7f6a379b3857aacf67cfe34bcdb1311e5692708b1e4fc8cc762e2852c05f3bd6 - Sigstore transparency entry: 2274336120
- Sigstore integration time:
-
Permalink:
Trashpanda62/taskkit@bbb5d33d6493f5b4aa1c9e38aa1ef86fcdecb802 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Trashpanda62
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@bbb5d33d6493f5b4aa1c9e38aa1ef86fcdecb802 -
Trigger Event:
release
-
Statement type: