Kin / How Kin works

Tasks DAG

Maintain a persistent dependency graph and distinguish tasks from the current-work checklist.

Read as Markdown

The tasks tool maintains a dependency graph across turns and session resume. Use it for work that needs durable status and dependencies.

Tasks vs todos

todos tasks
Scope A flat ordered checklist for the current work; drives the pinned panel above the composer A session-long graph of work items, possibly across many turns
Lifecycle Replaced wholesale on every todos call; at most one item may be in_progress Edits add / update / complete / remove entries; the DAG grows
Persistence Kept in session memory and re-injected after compaction; resets when the session restarts Written through to a sidecar JSON file (the journal AND the sidecar)
Dependency None — a flat ordered list blockedBy edges between entries; cycle-checked on every mutation

Use todos for “here are the ordered steps for the work in front of me.” Use tasks for “here’s the longer-lived work graph, including dependencies and things I’ll come back to after a restart.”

A todo list is also a progress contract. Kin creates the full list for multi-step work, keeps one current item active, completes it only after its evidence succeeds, and updates the list before starting the next item. A work tool call begins its item, so one model response does not batch work for multiple checklist items. The final completed list remains visible. Tool success by itself never changes the list; the harness can only remind the model once per unchanged active revision to reconcile what the evidence supports.

The singular task tool spawns a subagent; it does not add a graph entry.

The tasks tool

One tool, six actions. The tool returns the post-mutation DAG snapshot as its tool_result (so the model has the authoritative shape after each call) and emits a tasks_changed event so the UI updates.

The action enum

Action Args What it does
add content, blockedBy (optional), active_form (optional) Insert a new task. The id is auto-minted by the store (t_<unix_ms>_<rand>) and returned in the snapshot — the model never picks an id, which sidesteps id-collision concerns on retries
update id, content (optional), status (optional), blockedBy (optional) Mutate fields on an existing task. Cycle-checks blockedBy before applying
complete id Mark a task done. Dependents whose blockedBy are now all completed (or deleted) are NOT mutated — they stay pending; the blocked() query computes readiness at read time
remove id Soft-delete: marks the task status="deleted". The row stays in the store so historical blockedBy chains still resolve — blocked() treats deleted ids as inactive blockers
list status_filter (optional) Returns the current DAG, no mutation. The filter is one of pending / in_progress / completed / deleted; omitted, it returns every task regardless of status (deleted rows included)
blocked Returns the tasks whose blockedBy is non-empty AND has at least one still-active blocker (a blocker that isn’t completed and isn’t deleted) — useful for “what can’t I start yet?”

Returned shape

The shape differs slightly by action, but every mutating call (add / update / complete / remove) carries both the one row it touched and the full post-mutation DAG:

{
  "action": "add",
  "task": {
    "id": "t_1720600000000_a1b2",
    "content": "Seed dev fixtures",
    "status": "pending",
    "blockedBy": ["t_1720599990000_9f3c"]
  },
  "snapshot": {
    "tasks": [
      {
        "id": "t_1720599990000_9f3c",
        "content": "Provision the dev DB",
        "status": "pending",
        "blocks": ["t_1720600000000_a1b2"]
      },
      {
        "id": "t_1720600000000_a1b2",
        "content": "Seed dev fixtures",
        "status": "pending",
        "blockedBy": ["t_1720599990000_9f3c"]
      }
    ]
  }
}

list returns {"action": "list", "tasks": [...filtered rows...], "snapshot": {...the full unfiltered DAG...}}; blocked returns just {"action": "blocked", "tasks": [...]} (no snapshot — it’s a pure query, nothing changed). Note the wire shape is compact: an empty blockedBy, blocks, or active_form, and a null completed_at, are dropped from a row entirely rather than sent as [] / "" / null — a task with no dependents simply has no blocks key.

The blocks field is derived (every task whose blockedBy mentions this id) — the model doesn’t write it; the store computes it on every mutation. Keeping it derived is what makes the auto-unblock on complete correct: as soon as the DB-provisioning task becomes completed, the fixtures task sees an empty unmet blockedBy and flips to actionable.

Cycle detection

Adding a blockedBy edge that would form a cycle is refused with an error result and no mutation. The store runs a DFS with a visited set on every add / updateA blockedBy B blockedBy A and longer loops both fail. The model reads the error and rewrites the dependency.

Auto-unblock on complete

Completing a task changes that row only. Dependents keep their status and stored blockedBy edges. The blocked() query ignores completed or deleted blockers, so newly ready work no longer appears in its result.

Sidecar persistence

The graph lives in <session-id>.tasks.json beside the conversation journal. Resume loads it into the task store. Every mutation writes a temporary file and renames it into place, preserving the previous complete file if writing fails.

Crash recovery

On load, any task with status="in_progress" whose updated_at is older than 5 minutes (the recover_stale_in_progress grace window) is reaped back to status="pending". A session that crashed mid-task doesn’t strand the work item — the next launch sees it actionable.

A task that was actually running and then resumed by hand is update(status="in_progress") again; the reaper treats you as authoritative as soon as you write in_progress a second time.

The tool_result is authoritative

Each mutation returns the resulting graph, so the model need not reread the sidecar after a successful tool call.

The UI surface

Three renderers:

  • The pinned panel above the composer — the same collapsible widget the in-turn todos list uses, in “tasks” mode. It’s a lite view, not the full DAG: the in-progress task first, then up to 5 pending tasks, each tagged (blocked) if it still has an active blocker; completed and deleted entries are omitted, and the panel hides itself entirely when nothing is pending or in progress. This is the live, at-a-glance surface — no keypress needed.
  • The agent panel (++ctrl+o++ → tasks pane) — every entry as a one-line row id · status · content. Full DAG render, including completed and deleted rows.
  • Press o / ++enter++ on a row in the agent panel — opens a read-only modal with status, content, blockedBy, blocks so you can inspect a dependency at a glance.

Both panel renderers update on the tasks_changed event (full snapshot, not a delta — simpler than tracking which task id changed).

Worked example

// Illustrative tool-call sequence; use returned ids.
// turn 1
// (the store mints ids; capture them from each add's snapshot)
const setupDb = tasks(action="add", content="Provision the dev DB")
const seed    = tasks(action="add", content="Seed fixtures", blockedBy=[setupDb.task.id])
const smoke   = tasks(action="add", content="Smoke test",     blockedBy=[seed.task.id])

// turn 2
tasks(action="complete", id=setupDb.task.id)
// → seed.task.blockedBy still includes setupDb, but blocked() no longer counts it (completed)
tasks(action="update", id=seed.task.id, status="in_progress")

// turn 3
tasks(action="complete", id=seed.task.id)
// → blocked() now returns [] (smoke has no active blockers; smoke.task.status is still "pending")
tasks(action="remove", id=smoke.task.id)
// → soft-deleted; sidecar rewritten; smoke still appears in the snapshot with status="deleted"
tasks(action="list")
// → current shape, with smoke showing status="deleted"

Limits

There is no hard cap on DAG size, but the model should treat it as a working set — the DAG is rendered in the panel every refresh, and the tasks_changed event carries the full snapshot. Dozens of entries are fine; hundreds are not.