Bobbin — the suite's work tracker
Threads, workstreams, pulls, and handoff capsules — for a person and their personas.
Bobbin tracks the work a person and their agent personas do together. A bobbin holds thread wound and ready to be pulled — and that is the verb the surface uses: you pull the next thread off the queue, work it, and hand it back with everything the next owner needs to continue.
Bobbin is a flag-gated preview at sneka.ai/bobbin. Every screen answers one of four questions: what do I pull next, who is stuck, what is rotting, and are the goals moving.
The thread
A thread is one work item. Its important fields:
| Field | What it is |
|---|---|
| goal | The one-or-two-sentence outcome statement — the prompt you could hand an agent and have it just work. Always present. |
| type | unit (a step toward a workstream's goal, the default), ktlo (work that makes operating the system better without being a feature — not necessarily recurring, and not a bug), or papercut (the small wrongness everyone has learned to step over). |
| acceptance criteria | The checkable rubric. Optional — on a small thread the goal alone suffices. |
| verification steps | The commands or checks that prove the acceptance criteria. |
| handoff capsules | What one owner knew when they put the thread down, written for whoever picks it up. |
The six capture fields — title, goal, problem, description, acceptance criteria, verification steps — are the ones create writes and the only ones edit rewrites. Everything else about a thread belongs to another verb: state moves it, settle gives it an effort and a home, tag and relate add edges, and handoff writes a capsule. So re-cutting a badly captured thread never risks any of them.
States
A thread is always in exactly one of five states:
ready → queued and pullable. in_flight → someone owns it now. blocked → waiting on something, named in its waiting on field. shipped → done. abandoned → given up on, and it always carries a note saying why; the preview has no delete, so that note is the whole record of the decision.
Shipped and abandoned are the two ways a thread stops needing attention. Both hide it from the queue; only shipped counts toward a goal's progress.
Stages — where on the ladder, and what comes next
in_flight alone says nothing: who owns it, what are they doing with it, what happens next? So beside its state a thread carries a stage — where it is on the ladder — and a next stage, the decision about what follows. The ladder, in order: design · design_review · planning · plan_review · implementing · implementation_review · shipping · shipment_review.
The two read together. ready + plan_review is "ready for plan review": whoever pulls it does the plan review. in_flight + implementing + an owner is "implementing · Claude Code". blocked keeps the stage it was in. A thread nobody has placed on the ladder yet reads ready · not staged — every capture starts there.
The next stage is a decision recorded on the thread, never only a derivation, because most threads never see a design phase. When nobody has recorded one, the default path applies and it skips design: planning → plan_review → implementing → implementation_review → shipping → shipment_review → shipped. Every surface says which it was showing — next: plan review (default) versus a bare next: design — so a default is never mistaken for a choice. design and design_review are entered only when someone names them: create --next design, state <id> in_flight --stage design, or handoff --next design.
Three rules keep the record honest. in_flight requires an owner: pull stamps you, and a bare state <id> in_flight stamps you too when nobody carries the thread. pull keeps the thread's stage — pulling a "ready for plan review" thread puts you in plan review — and an unstaged thread enters the ladder at its recorded next stage or, absent that, at planning. A handoff means "I finished my stage": the thread goes back to the queue advanced to its next stage (the one the capsule names with --next, else the one recorded on the thread, else the default path's), so the next reader sees exactly what is needed.
Whether a thread is SETTLED is not a state — it is derived. A thread is unsettled while nobody has confirmed how big it is or given it a home, which is what Loose Ends lists. On screen that reads as what is missing — "N threads with no effort or no home" — not as the word "unsettled".
What the numbers mean
Every count that carries a state's name is that state's count and nothing else: "threads in flight" is the number of threads in in_flight, "threads blocked" the number in blocked. A thread held by an open blocked_by edge is still ready; the edge decides what pull offers, never what a count says. "In flight 3+ days" is the in-flight threads whose state last changed three days ago or more. The dashboard's two rates — "threads shipped / day" and "effort shipped / day" — are 7-day averages, and velocity prints the same two rates over 28 days as well. "N% shipped" on a workstream is threads shipped over every child it has, whatever their scores; "no plan" means it has no children yet. Effort sums ("effort remaining N of M", a velocity arrival row's remaining) use the effective score — the confirmed one, else the proposal — on every surface alike. The full audit, with the expression behind every figure, is docs/specs/staged/bobbin-numbers-audit.md.
The queue orders ready threads stalest first — nothing rots silently at the bottom.
Workstreams, effort, and depth
A workstream is a thread in its interior role: a thread with children is a workstream (an interior node), and other threads collect under it. The role is structural, never a score, and trees may be arbitrarily deep — any thread can be decomposed by writing a plan under it. It is not a second entity with its own table, so it carries a thread's own id, and its slug is the alias older links use. A workstream reached this way — by gaining children, rather than through ws create — has no slug until named, and shows its bare id in ws ls/ws show until then; sneka bobbin ws rename <id> <slug> sets one, subject to the same grammar and uniqueness ws create enforces. Every number a workstream surface shows derives from its threads at read time.
Stored effort (1–5) lives ONLY on childless threads. 1–2 is workable now; 3 and up means the thread must be decomposed before anyone works on it — the digit is a size prediction of what it will decompose into, and pull and the quick wins refuse it until real children replace it. The moment a thread gains children its stored score is retired: writes to it are refused, and every surface shows the derived sum of its subtree's childless threads instead, computed at read and never stored.
State and stage follow the same rule. A thread with children reads the state its childless descendants derive, over the whole subtree: shipped once every leaf is shipped or abandoned (abandoned when all were given up on), blocked while any leaf waits, in_flight while any is carried, and ready otherwise. A parked leaf that is still open counts as open, so a workstream with a parked leaf finishes only once that leaf is unparked and shipped or abandoned. An interior thread shows no stage until it derives terminal, then shipment_review, and never a next stage. Writing a state or stage to a thread with children is refused, naming its child count, the derived value and the leaves still open; a write that merely restates the derived state is accepted and dropped. Ship the leaves and the workstream ships itself; a plan is not finished while a child nobody placed is still open.
A thread in a workstream may hold a place in that workstream's plan: threads sharing a step run concurrently, steps run in ascending order, and "next up" is always the lowest step with unfinished work — derived, never chosen, so it cannot go stale. The place is a row in the plan, not a field on the thread, so reordering a plan never edits the work.
Each entry in a plan's steps is one of two things. It writes a new thread when it carries a capture (title, problem, goal, proposeEffort, and an optional description), and it adopts an existing one when it carries threadId instead — that thread is re-homed under the plan's parent and takes the position stated. Adoption is how a workstream grown by capture and settle gets a plan: its children already have a home, and naming their ids places them rather than recreating them. A capture field beside threadId is refused by name, and one id may appear only once in a plan.
A planned thread's fields land where create's land. problem is the one-line problem statement the workstream page reads back as the thread's problem, and description is the long-form context an id chip's hover shows. So a plan entry that wants a hover summary states description explicitly; one that states only the capture minimum leaves that hover empty, exactly as a created thread without a description already does.
Chunks — what the critical path is cut into
A chunk is a phase's leaf work: the block an operator hands to one agent, spoken as "phase 3 of bb-1211". A nested workstream contributes its own chunks at its position in the outer plan. Direct leaves collect through that step before the nested chunks; leaves in a later step start another outer chunk. The nested workstream's whole effort never inflates an outer chunk. A horizon joins its workstreams' expanded paths in horizon rank order.
A childless thread with no phases that is finished, or scored at effort 2 or less, is its own work: one "thread" chunk holding itself, shown by its own name, and it can be pulled as it is. Any other workstream with no phases is one "decompose" chunk carrying its remaining effort; away from its own page that chunk is shown by the workstream's name. A partial plan has a trailing "unplaced" chunk for live children without positions. Finished placed rows remain visible; parked work is omitted. The first chunk holding in-flight or blocked work is current, the first open chunk after it is next, and the rest are later or done. When nothing is moving, the first open chunk is next. A chunk with more than 8 leaf effort points still open says "over budget · split it" (what is already shipped or abandoned no longer needs splitting); a dependency into a later chunk says "waits on chunk N".
A horizon page warns when pullable threads sit directly under the horizon outside every workstream. The path pulls each as it is, one chunk each in rank order, until a plan groups them; the warning lists them and offers a prompt an agent can run to adopt them into a workstream's plan.
sneka bobbin ws show <id> prints this expanded path. plan <id> --show keeps the stored phases and steps used for editing. Pull follows the same nested plan: earlier steps gate later ones, while direct and nested branches sharing a step remain concurrent. pull --from horizon:<id> walks the named horizon, bare pull the primary horizon, and pull --from <ws> one workstream.
Tags
A tag is one lowercase string an actor puts on a thread, plus who put it there and when. There is no value field: someone who wants a name and value writes foo:bar, and the whole string is opaque — effort-verified:2026-08-25 and effort-verified:2026-08-26 are two different tags, on purpose.
Tagging changes nothing about the thread: not its state, its home, its effort, or its place on any screen. It classifies without reclassifying, and a thread carries at most one row per exact string, so re-tagging changes nothing and is not an error. Every count a tag surface shows derives from tag rows at read time. Nothing stores a count. The cloud at /bobbin/tags counts only live threads (ready, in_flight, or blocked), including parked threads in those states. The cloud draws a tag only when two or more live threads carry it. A tag on one live thread groups nothing, so it stays out of the cloud; so does a tag carried only by shipped or abandoned threads. When no tag qualifies, the cloud says no tag has two live threads. A tag the cloud leaves out is still there: /bobbin/tags/<tag> lists its carriers, and sneka bobbin ls --tag finds them. Historical tag links still show finished carriers, and tag suggestions and scope-write counts still include the full thread history.
A group write reaches a workstream's whole tree or every thread under a horizon, reports what it changed AND what already carried the tag, and its undo removes exactly the rows that write created — never the tag from threads that already had it.
Bulk import
sneka bobbin import takes one JSON document — {"items": [...]}, at most 5 MiB — and answers at once with a ticket. Each item is a capture (title, goal, optional problem, description, acceptance, verification) plus what a migration carries and a capture never asks: tags, type, a proposeEffort, an origin note, a parent, and relations. Every item names a ref, the identifier it has in whatever system it came from; Bobbin stamps it on the thread as the ref:<ref> tag and reports per ref — imported with the thread id, failed with a reason, or skipped when that ref was imported before. A parent or a relation's other end may name another item's ref (in this body or an earlier import) or an existing thread id, so a whole graph arrives in one post.
One item's refusal never sinks the batch. --dry-run walks every item and rolls everything back, so the verdicts arrive before anything lands; --wait polls the ticket until it settles and exits non-zero if any item failed. The service is generic: it knows nothing about knots, Jira, or Linear. Whatever turns a foreign export into this shape lives outside the product.
Handoff capsules
A capsule records everything the outgoing owner knew: a headline, what landed (done), what the next owner should do first (next), the traps (watch out), and long-form context. The capsule's author is always the signed caller, so a persona's capsule renders with its portrait. The thread page renders each context entry as markdown, so a table or a list written into a capsule reads the same there as in sneka bobbin brief.
Writing one PUTS THE THREAD DOWN, in the same act: the owner comes off and a running thread returns to the queue for whoever picks it up next. Only the person carrying it can do that. A capsule on someone else's thread is a note and changes nothing about whose it is, and a finished thread is carried by nobody, so its owner (who shipped it) stays. Ownership itself is stamped by pull, which is what makes a handoff its opposite.
A capsule may be addressed with --to, and the recipient is an identity, not a name: persona:<slug> or user:<name>. Bobbin resolves it against the people it has actually seen act and refuses a kind it cannot corroborate, because the thread page draws the recipient as an owner chip and the wrong kind is a wrong face.
The CLI
The sneka bobbin family works as you or as a persona ($SNEKA_AGENT), exactly like every other sneka verb:
sneka bobbin focus
sneka bobbin velocity
sneka bobbin loose-ends --kind <kind|all> --limit <n> [--cursor <c>] [--json]
sneka bobbin ls [--state --stage --type --lane --workstream --owner --q --mine --unsettled]
sneka bobbin ls --waiting-on-me [--limit <n>] [--cursor <c>] [--all]
sneka bobbin show <id>
sneka bobbin create --title <t> --goal <g>
[--problem <text|-> --description <text|->]
[--ac <criterion> ...] [--verify <step> ...] [--from <id>]
[--propose-effort <1-5>] [--propose-parent <home>]
[--next <stage>] [--nickname <short name>]
sneka bobbin edit <id> [--title <text|-> --goal <text|-> --problem <text|->]
[--description <text|->]
[--ac <criterion> ...] [--verify <step> ...]
[--nickname <short name>]
sneka bobbin pull [<id>] [--from <workstream|horizon:<id>|ktlo|papercut>] [--ahead <n>]
sneka bobbin ws ls | ws show <slug> | ws rank <id> <n> | ws rename <id> <slug>
sneka bobbin ws create --name <n> --goal <g> [--problem <p> --description <d>]
[--acceptance <criterion> ...] [--verification <step> ...]
--nickname <short name>
sneka bobbin horizon ls | horizon create --name <n> --outcome <o>
--nickname <short name>
| horizon edit <id> [--name <n>] [--outcome <o>]
[--nickname <short name>]
| horizon rank <id> <n> | horizon park <id> --note <w>
| horizon unpark <id> (aliases of park/unpark)
| horizon path [<id> --view next|full] [--json]
(the path-preview wire; an id reads one
horizon, cleared ones included)
sneka bobbin plan <id> --show | --plan - [--nickname <short name>] | --confirm
sneka bobbin settle <id> [--effort <1-5>] [--parent <home>] [--as-suggested] [--undo]
[--nickname <short name>]
sneka bobbin suggest <id> [--effort <1-5>] [--parent <home>]
sneka bobbin state <id> <state> [--stage <stage>] [--next <stage>]
[--waiting-on <user:name|persona:name|text>]
[--note <why>]
sneka bobbin relate <id> <blocked_by|blocks|duplicates|relates_to> <other>
sneka bobbin unrelate <id> <blocked_by|blocks|duplicates|relates_to> <other>
sneka bobbin rank <id> <n>
sneka bobbin park <id> --note <w>
sneka bobbin unpark <id>
sneka bobbin check <id> <n> [--undo]
sneka bobbin handoff <id> --capsule <text|-> [--to <persona:slug|user:name>]
[--next <stage>]
sneka bobbin brief <id>
sneka bobbin tag --name <t> (--thread <id>... | --thread - | --workstream <id> | --horizon <id>)
sneka bobbin untag --name <t> (--thread <id>... | --thread - | --workstream <id> | --horizon <id>)
sneka bobbin tags
sneka bobbin ls --tag <t> [--tag <t> ...]
sneka bobbin import <file|-> [--dry-run] [--wait]
sneka bobbin import-status <id> [--wait]Thread listing returns a bounded page by default. Filters compose on the server, and repeated tags require every named tag. A lane-filtered list reads in lane rank order, so a small limit can answer “what are the top two ready KTLO threads tagged grokbot?” without downloading every match.
Use the list command's limit and cursor options to choose a page size and continue with the same filters. Human output distinguishes rows shown from all matches and includes a continuation instruction. JSON includes items, total (the matching population), and nextCursor; a null cursor means the end. A changed page size is allowed when continuing.
An explicit all-results option retrieves every matching thread and is the right choice for reports, counts and backlog audits. It fails if traversal cannot complete, rather than returning a successful partial report. Changing membership or ordering between pages invalidates the cursor and requires a restart; this is a live listing, not a frozen snapshot. See sneka bobbin ls --help for the installed version's exact flags and bounds.
Within one search page, though, every row is read on a single database snapshot, so a thread edited while that page is being built is still shown with the text it was matched and ordered on rather than with a newer title that the search no longer matches.
edit <id> re-cuts a thread's capture in place, for the thread that was written down badly — most often one imported with a whole knot body crammed into its goal, or one whose goal has moved on and left its title behind. It takes create's six capture fields, each optional: what you name is rewritten, what you leave out is left exactly as it was, and - reads a field from stdin (never implicit, as everywhere else). --title renames the thread itself — the name every row and page shows — and is not --nickname, the short label a surface shows in place of the id; a blank title is refused, and edit is the only verb that writes one after capture. --ac and --verify REPLACE their lists rather than merging into them, and a check mark survives only where an item's text is unchanged — a reworded criterion is a different criterion, so it starts unchecked. Nothing outside the capture moves: the id, state, tags, relations, capsules, owner, effort and home all read the same after. A shipped or abandoned thread is refused, because its capture is the record of what was done.
park <id> --note <w> takes a node off the board with the reason Friday's review reads, and unpark <id> puts it back and prints it at the rank it landed on. One pair of verbs for both kinds of node: a bg- id is a horizon, and any other id — a thread's, or a workstream's slug — is a thread. horizon park and horizon unpark are the same move under the older names.
focus is the one reading: the primary horizon, its path, a glance at the next horizon, the two homeless lanes together, the quick wins, and how many threads still have no effort or no home.
velocity is the other one: what ships per day, and what the board says that pace is being spent on. Four sections, numbers first. Effort shipped per day and threads shipped per day, each as its 7-day and 28-day average, and the trend between the two effort means. The last 28 days in whole numbers: effort shipped, threads shipped, and how many of those threads carried no effort score. An arrival row per ranked workstream, at that workstream's own last-week pace. And the allocation, which asks whether effort is following priority: one row per ranked horizon in rank order, one per parked horizon that took effort, and whatever landed outside every horizon.
Where there is no pace there is no estimate. A workstream nobody shipped into in 28 days says so; one that shipped earlier in the window and has gone quiet this week says that instead, because they are different facts. A 28-day baseline of zero prints a middle dot for the trend rather than a percentage of nothing.
How a velocity reading is derived
The whole reading is computed when you ask for it. Nothing is stored, nothing is swept, and there is no job that could be behind.
- Effort shipped is summed over childless threads that reached shipped. An
interior node's own score is retired the moment it has children: its leaves already carry the truth, and counting both would count the same work twice.
- The score is the effective one — the confirmed effort, and the proposal
when nothing is confirmed yet. Not confirmed-only.
- **A ship carrying no score at all counts in
shippedand inunscored, and
adds nothing to effort.** It is never silently patched to a number. That is why the three figures in the 28-day block do not sum, and are not meant to.
- Day buckets are relative age,
floor(hours since the state change / 24),
not calendar days. "Yesterday" means 24 to 48 hours ago, in any timezone.
- The windows roll and end today: the last 7 days and the last 28.
- **Per-workstream attribution rolls each ship up to its nearest
horizon-parented ancestor**, so the keys are board-level workstreams rather than direct parents. Effort whose chain reaches no horizon lands honestly in the outside share.
remainingis a whole-subtree sum of the same effective score. One
reading carries one notion of size: the effort already shipped and the effort still to come both read the confirmed score, else the proposal. A confirmed-only remaining read zero on every workstream whose leaves carried only a proposal, which after a bulk import was all of them.
- Threads shipped per day is folded off
days[].shippedby the CLI and
the web alike; the reading serves no second summed field to drift.
atis the read instant, stamped by the service as it answers, and
carried so no consumer needs a clock of its own. It is not a reference day. The CLI prints it to the second.
loose-ends reads the Loose Ends groups by kind: complexity (no effort yet), home (no home yet), decompose (a workstream waiting on its plan), plan (a plan waiting on you), unplanned (threads on a horizon outside every workstream) and orphaned (threads a plan does not place), or all, which prints the four decision kinds, then orphaned, then unplanned. --kind and --limit (1 to 200 rows per kind) are both required, and nothing is assumed for either. A decision kind ends with <shown> of <total>; the two parent groups carry no total. Where a kind has another page, its last line is a more: command carrying --kind, --limit and the served cursor, and running it as printed reads the next page. The cursor is bound to the page size it was read at, so change neither. --cursor belongs to one kind and is refused with --kind all. Under all an empty kind is left out; a kind asked for by name prints its own sentence instead. --json prints the served body for one kind, and for all one object keyed looseEnds, unplanned and orphaned, each the served body unchanged. Until bb-32f5 ships, the server refuses orphaned with a 400: --kind orphaned fails with that sentence, while --kind all prints it as the orphaned group's line and still succeeds (under --json, the served error body sits under orphaned).
On the web, Loose Ends opens with a Waiting on you group, above the orphaned group. It lists every blocked thread waiting on you, parked ones included, one row per thread: the thread, the person it waits on, and its note, which says what is being asked. A thread parked itself also shows how long it has been parked and why. The group shows 25 rows at a time, and "show more" reads the next 25. loose-ends has no kind for this group; its CLI read is ls --waiting-on-me.
A --parent home is horizon:<id>, a thread id (a workstream is a thread), or one of the two homeless lanes, ktlo or papercut. A lane has ONE name, singular, and every place that takes one takes that name: --parent, ls --lane, and pull --from. The plural is prose — it belongs to a heading over a list, and no endpoint accepts it. pull --from horizon:<id> spells a horizon the way --parent does, and walks that horizon's own path instead of the primary's; a bare pull is still the primary horizon.
relate records one edge between two threads and moves neither state. A thread can carry several, which is what separates an edge from state <id> blocked --waiting-on: that one forces the thread into blocked and records a single thing waited on. blocked_by is the edge pull reads. A thread carrying one whose blocker has not finished is skipped, on the path and in a lane alike, and it drops off the quick wins for the same reason. A blocker that has shipped holds nothing, so nothing has to be undone when the other thread lands. A thread's parent is its home and settle --parent moves it, so relate does not write one.
blocked is only for a human decision or an escalated operator action. Ordering between threads is a relate <id> blocked_by <other> edge on a ready thread, and a thread id is never named in --waiting-on (Andrew, 2026-09-28). To block on a person, run state <id> blocked --waiting-on user:<name> or --waiting-on persona:<name>, using the name show prints, and put the decision or action in --note. The CLI still sends a thread id or free text as before, but the service refuses free text that starts with user: or persona:. A persona is matched by its display name, which is not unique across accounts.
ls --waiting-on-me lists every blocked thread waiting on you, parked ones included. It reads GET /api/bobbin/waiting-on-me and takes only --limit, --cursor and --all; no other ls filter combines with it.
unrelate removes exactly the named edge recorded on the first thread. Repeating it succeeds when that edge is already absent; both threads must exist. Edges in the reverse direction, other relation kinds, and both threads' state and home remain unchanged. Removing one blocked_by edge leaves any other blockers in force; removing the last lets an otherwise eligible thread appear in pull and quick wins again. This removes a relation, not a thread.
A nickname is the short display text (at most 24 characters, e.g. Stripe Billing) a surface shows in place of a bare id. A horizon and a thread with children each require one; a leaf thread may carry one. Where a write requires a nickname and none was given, a terminal is offered one proposed from the title, and an empty line accepts it. A non-interactive caller (--json, or no terminal) is refused, naming --nickname. A nickname is display text only: ids and slugs stay the lookup handles.
horizon rank moves a horizon to place n on the one ranked list. Rank ORDERS and never parks: rank 1 is the primary horizon, rank 2 the other one, and rank 3 and down sit on the fence, however long the list is. Parked is a separate flag a horizon only ever carries because someone ran horizon park with a note, and horizon unpark puts it back at the foot of the ranked list.
rank moves a ktlo thread or a papercut to place n in its lane. A rank is a POSITION, not a label: whatever sat at n shifts down, the lane always reads 1..N with no gaps, and a number past the end means last. It is the same move the ops screen's arrows make, and ls --lane ktlo reads the lane back in that order. Use --lane, not --type: --type filters the column a thread was CAPTURED as, which never moves, while --lane is the population that is currently homeless in that lane, and rank reorders the latter.
tag and untag take exactly one subject: threads by id (--thread - reads ids from stdin, one per line, and is the only way this family touches stdin), a workstream's tree, or a horizon's threads. The receipt names both halves — sec-0 → 9 threads · 5 already had it. tags prints every distinct tag with its derived counts, and ls --tag filters to threads carrying every named tag.
show and ls print the state and the stage as one pair — in flight · implementing, ready · plan review — and show follows it with the next needed stage and whether anybody decided it. ls --stage filters by the stage column exactly, so a thread nobody has staged matches none.
brief emits an agent-ready markdown brief — goal, description, acceptance, verification, and the newest capsule — so handing a thread to an agent is one command. handoff takes the capsule on --capsule: pass - to read it from stdin (never implicit), or give the text directly. It is structured markdown, the first line the headline, then optional ## done, ## next, and ## watch out bullet sections; everything else becomes context, one paragraph per entry: lines keep their own breaks inside a paragraph, so a table or a numbered list reads back as written, and a blank line starts the next.
What the preview is not
No Knots integration — Bobbin owns its own store and vocabulary. No comments, no notifications, no delete, no search ranking, no second user: the preview is one person's tracker, allowlist-gated, and stays that way until it earns more.