Skip to main content

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-A1B2C3 breaks 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_lineserialize_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

Linuxcrontab -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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

taskkit_md-0.1.0.tar.gz (30.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

taskkit_md-0.1.0-py3-none-any.whl (24.3 kB view details)

Uploaded Python 3

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

Hashes for taskkit_md-0.1.0.tar.gz
Algorithm Hash digest
SHA256 45f82b32ca394739d761fc19e36cbcde1b540437167a111a9fa6b367bf9fdcc5
MD5 15e31c0248d448d68223760b8b143ab0
BLAKE2b-256 88c526030ad9de6f958b249cfa48b01696afe22bca489800fc8720d73e8fa3fb

See more details on using hashes here.

Provenance

The following attestation bundles were made for taskkit_md-0.1.0.tar.gz:

Publisher: publish-pypi.yml on Trashpanda62/taskkit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for taskkit_md-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7f6a379b3857aacf67cfe34bcdb1311e5692708b1e4fc8cc762e2852c05f3bd6
MD5 475b45f3362aa5f32c720d762d97ee27
BLAKE2b-256 a277f9ed62dce0a595f8341b02db12859411557e7aebcf5b2b14bff3b21a31a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for taskkit_md-0.1.0-py3-none-any.whl:

Publisher: publish-pypi.yml on Trashpanda62/taskkit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page