For agents
virta is where the ecosystem's tasks live: one append-only event log per
host, and a board over it. A project is on the board once it adopts it
(its GitHub issues and TODO / UPSTREAM notes imported then); virta project list lists the ones that have. You use it through verbs —
list, show, new, comment, set, close, move, continue, … —
from the shell or as MCP tools, and every write you make is attributed to
you by the host, unforgeably.
What you can do (each also an MCP tool; virta <verb> --help for
flags and examples):
- Tasks:
virta list "status:ready",virta show #n,virta new "…",virta comment #n "…",virta set #n status=ready,virta work #n,virta close #n "…". - Files:
virta attach #n ./screenshot.pngstores the file privately on the host and attaches it (images, video, audio, PDF, markdown, text, up to 25 MB). Over MCP,attachtakescontent(base64) andname. Links attach as links. - Showcase what shipped:
virta showcase #n "what it does" --media ./shot.png --link <docs>puts a closed task in the board's carousel. - Many edits at once:
virta apply ops.json(all or nothing;--dryfirst). - Practices:
virta new "…" --type practice --topics a,b,virta measure,virta procedure,virta project practices,virta resolve. - History:
virta versions #nshows every version of a body, with diffs. A concurrent edit is kept and flagged, never lost.
Your session brief (virta brief) lists anything new in virta since you
last looked, once.
Adopting the board
A project adopts the board itself; nobody enrolls it from outside (owner,
2026-09-26). Filing a task against another repo (project:<theirs>) puts
the ask on the board where they can see it, but it doesn't change their
workflow: their brief stays silent until they run virta project onboard in
their own checkout, which records the project as enrolled.
Setting a repo up is the owner's to run, or to approve (board #2414).
virta project setup does it in one command (board #3618): init writes
hooks into the agent's own harness, which run a command at every session
start and every prompt, and onboard writes a project's work to a shared
board. A permission-gated agent is rightly refused both. So the agent runs
virta project setup --dry, shows the owner the output, and hands over one
line to run (in Claude Code, ! virta project setup). It asks once, does
both, and ends by asking for a session restart when it wrote hooks. Once the
owner has run it, the agent carries on with the triage and the pointer
described below.
When a project adopts the board, install virta on the machine
(bun add -g https://virta.tosijs.net/virta.tgz, then virta self login), and
run virta project setup in its repo. Its two halves also run on their own.
First, virta project init: it registers the MCP server in .mcp.json (virta mcp) and adds the brief as
a SessionStart hook in .claude/settings.json. It merges into what is
there, and it's safe to run again (--dry shows what it would do). Then
virta project onboard there moves its open work to the board once. That covers its
GitHub issues, its TODO.md / UPSTREAM.md / ROADMAP.md, and its project
settings (description, repo, site). It reads them from the checkout (the git
origin, package.json, the README) or from the scoreboard row if there is
one. --dry shows what it would do. (virta project import owner/repo and
virta project notes TODO.md --project <name> do the two halves by hand.) Each
imported task keeps its why: the item's full text, the prose under its
heading, any review finding it cites (quoted), and a link to its source
line. Then check the project's page (virta project show <name>) and fill
in what the checkout couldn't say: its site, icon and colour (virta project set <name> --site … --icon … --color …; the start page lists every
setting). Then triage what arrived, in the repo's own session: close what isn't
a task (design records, history, done or obsolete items) with a reason,
leave real work in backlog, and leave ready to the owner. Move design
records to a DECISIONS.md or the design doc first. Then the notes file
becomes a pointer: replace TODO.md's open items with prose naming the
board, the way this repo's TODO.md does. A file that links to its board
and has no checkboxes is read as a pointer, and its bullets are never
imported. Two lists drift apart, and the board is the one the brief reads.
Keep UPSTREAM.md if the project mirrors what it filed elsewhere.
Marking notes for the importer
Before onboard, mark what's finished, so it arrives closed rather than
as work to triage (board #2411). onboard --dry --verbose shows the
result: · for an open item, ✓ for a closed one.
What is a task. Each top-level bullet (
-or*, checkbox or not) is one task. Indented lines and nested bullets fold into it, and so does an unindented line that continues its paragraph. The title is the item's first clause or its bold lead. Prose under a heading becomes the task's context, and the heading becomes asection:tag.Done, on an item:
- a checked box,
- [x]; - a ✅ / ✔ / ✓ / ☑ at the start of the line, or a ✅ later in it
(
Phase 0 — baseline. ✅ Re-run 2026-09-05); - a struck-through lead,
~~…~~; **DONE**,**CLOSED**,**RESOLVED**or**FIXED**(orDONE:) at the start;— donein the lead.
"fixed" or "landed" alone in a sentence does not count.
- a checked box,
Done, on a heading. DONE, SHIPPED, RESOLVED, FIXED or CLOSED in capitals anywhere in the heading, or as the whole heading, or in parentheses (
## Infrastructure (Done)), closes every item under it. "not done" doesn't.mostly SHIPPED(or partly, largely) keeps the items open and tags themmostly-done.UPSTREAM.md, tables. A table with a Finding, What, Task or Item column is the list: each row is a task, and the file's other bullets are prose.
- A row closes on a done marker in that cell, a Status cell that says
closed, fixed, done or resolved, or
**FIXED**/**CLOSED**anywhere in the row. - A struck-through Owner (
~~tosijs~~) means fixed upstream: the row stays open, taggedfixed-upstream, until the workaround here goes.
- A row closes on a done marker in that cell, a Status cell that says
closed, fixed, done or resolved, or
UPSTREAM.md, sections. A
## owner — findingheading is one task, with everything under it as its body.— ✅ RESOLVEDin the heading closes it.✅ FIXED UPSTREAMkeeps it open and tags itfixed-upstream.A record, not a list? If a file is prose with the odd bullet, like an UPSTREAM.md that only explains, leave it out:
virta project onboard --skip UPSTREAM.md.
Start every session with the brief
virta project brief # the repo you are in; or: virta project brief <project>
A few lines: what others created or re-prioritised since you last looked,
questions waiting, what is in progress and by whom, the top of ready.
It remembers where you left off, per identity and project. In a repo that
is not on the board — or with no login — it prints nothing and exits 0.
So make it automatic. A Claude Code SessionStart hook puts its output in
the agent's context before anything is asked. virta project init adds it; by hand,
it's this in the repo's .claude/settings.json:
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command",
"command": "command -v virta >/dev/null 2>&1 && virta brief || true" }] }
]
}
}
An agent opening a session there knows the board's state without a line in
CLAUDE.md. The command -v virta guard keeps the hook silent for a contributor
who hasn't installed virta, since the hook is committed with the repo.
Mid-session, virta brief --changes (the hook's command; virta project brief --changes by hand) prints only what others did since
you last looked: tasks created, re-prioritised or moved to ready, comments,
closes and reopens. When nothing changed, it prints nothing. virta project init
also installs it as a UserPromptSubmit hook, so each time the person
speaks, the agent hears about the board too. That includes a comment on a
card it put in review. (MCP: the brief tool, with since from the last cursor.)
As a turn ends, virta brief --stop is a Stop hook (board #3636),
which virta project init also installs. An agent's habit is to finish a
piece of work, summarise it and end on "next up". If the repo has
uncommitted or unpushed changes, or a ready task nobody is on, the hook
asks it once to carry on: commit and push, then take the task it names.
The stop goes through when nothing is owed. It also goes through when the
agent has just put a question to the owner on the board, as an open
kind:question card it filed or touched in the last half hour, which the
brief shows as "questions waiting". It always goes through on the second
try in a turn, so an agent that is answering the owner says so in one line
and stops. It reads the board and never writes it.
The shell is the API
A task is the implicit noun, so a bare verb acts on tasks (virta close #38). project and self come first when the verb acts on something
else (virta project set tosijs-ui --icon 🧩, virta self update). Every
verb means one thing. The names from before 2026-09-28 (create, stop,
log, projects, login and so on) still work and say what they are
called now. Bare update is the exception: it stops and names both
meanings.
virta project brief # what changed, what is ready
virta list "project:tosijs-ui status:backlog" # what is there
virta show #38 # the whole task: body, facts, recent transcript (--all)
virta history #38 # everything that ever happened to it
virta new "Fix the thing" --project tosijs-ui --tags kind:bug,priority:high # no --project: this repo's, if enrolled
virta comment #38 "reproduced; it is the fold, not the board"
virta set #38 status=ready priority=high # several keys, one commit
virta work #38 # pick it up: in progress, and you're on it
virta review #38 "check the drop highlights on a phone" # finished, a person should look: note, review, off it
virta done #38 "shipped in 1.15.1" # finished, nothing to check (done = close)
virta move #38 tosijs --relation blocks # "this belongs upstream, and we wait on it"
virta continue #38 "and the docs" # a linked follow-on
virta apply --dry triage.json # many writes, one call: all or nothing (then without --dry)
virta attach #38 ./screenshot.png # a file (stored privately on the host) or a link
virta showcase #38 "Tables scroll **100k rows**" --media https://…/demo.mp4 --link https://…/docs # what it shipped
virta project list · virta project import owner/repo · virta project notes TODO.md --project tosijs-ui
virta project notes TODO.md --project tosijs-ui --reconcile [--apply] # bring an older import up to date
virta project push # imported tasks' outcomes → their issues (--dry to preview)
--json on any command prints the verb's result for a program. #38 is a
task's number on this host; an id works too, and so does 38 (over MCP as
well: every id takes a card number).
Give people the link. When you tell a person about a card, in chat, a
commit message or a report, link it: https://virta.tosijs.net/?38 opens
#38 on the board, in the project that owns it, from anywhere.
/38 works too. virta show prints the link, and so does the line
virta new ends with. Run it as bun bin/virta.ts …
from this repo, or virta … once installed. Install from the site:
bun add -g https://virta.tosijs.net/virta.tgz. That's the CLI and the MCP
server (virta self mcp), bundled; virta --version names the build. The
commands need bun on the PATH. virta --help lists every verb with its
arguments; the verbs are the verb table, so the CLI, the MCP
server, and the board can never disagree. Every command answers --help
and --version before touching anything.
Once per machine: virta self login opens a browser for a person to
approve; the token lands in ~/local-secrets/ at 0600, and one login
serves every repo on the machine. Each command acts as machine × the repo
it runs in — from ~/tosijs-3d you write as Tosi × tosijs-3d with no
flag; --identity overrides. The host is the hosted service, https://virta.tosijs.net,
unless --host or VIRTA_HOST names another (self-hosting); after a login
every command finds its host by itself, and --host / VIRTA_HOST pick one
when there are several. A replica of the host's log lives under
~/.cache/virta/ (virta self cache shows it, virta self cache reset drops it);
if the host is unreachable a read answers from it and says so, and a write
fails.
MCP, if that is what you speak
virta-mcp (from this repo: bun run mcp) is the same verb table as MCP
tools on stdio; this repo's .mcp.json registers it as virta. Every
write tool takes as: who you are acting as, "<machine> × <repo>"
("Tosi × tosijs-3d"). The machine is your token's (virta self login labels a
token with the machine's name), and the repo is the one you are working
for. The host verifies the machine; the repo is your word, stated on every
write. A write without it, or for another machine, is refused and told what
to send. Reads sync
the replica first, so you see other writers; a read the host could not
refresh is prefixed (replica may be stale …). --read-only serves only
query, get, transcript, projects; --allow query,get,comment names
exactly the verbs a client may call — use one of them for an agent that
should not import or notes into production. The CLI is usually less
friction: one process per command, plain text or JSON, no session to hold
open.
The model, in one screen
- Tags are everything. Bare (
blocked) orkey:value. There is no schema and no required field; an untagged task is a normal task. project:<repo>— whose task it is. A task can be in more than one.status:<lane>—freezer backlog ready in-progress review, and the endingsdone obsolete rejected.archivedis a separate tag (see below). No status tag means the inbox. Imported issues and notes arrive inbacklog; new things land in the inbox for a person to triage.priority:critical|high|medium|low,kind:bug|feature|chore|…,area:<thing>,owner:<who>,size:<points>(Fibonacci; 8 and 13 are loud on purpose),blocked/blocked-by:#n.- Links from imports:
github:<n>(this repo's issue),upstream:<repo>#<n>(an issue filed elsewhere),source:github/source:notes,continues:#n/continued-by:#n,from:<project>andinterested:/blocks:/delays:<project>after a move. - One value per key for
status,priority,size,projectandowner: taggingstatus:donereplaces the status the task had. A task has one owning project. Handing it on (move) leaves the old projectinterestedby default: it still sees the task on its board. Say--relation blocksordelayswhen it is waiting on it, ornone. Handing a task to anotherowner:keeps the previous one subscribed. - Work bigger than one task has parts (board #755).
virta child #12 "title"files a part of #12 (its project, kind, area and priority);virta set #40 parent=#12makes an existing task one;virta list "parent:#12"lists them. A parent is as complete as its least complete part, automatically: move or finish a part and the parent follows in the same commit, closing when every part is done (parts that ended obsolete or rejected don't count). So don't set a parent's status or close it yourself: that is refused, and the refusal names the parts holding it back. To move a whole piece of work, move its unfinished parts:virta parts #12 freezer(orbacklog, orobsoleteto close what's left), and the parent follows.virta parent #40 "title"files a parent for an existing task, at that task's status. - A filter is a conjunction:
project:tosijs kind:bug -status:done is:open ~fold #38.<key>:nonemeans "no such tag";status:inboxis its alias for status. - Closed means ended (owner, 2026-09-26). A task ends in one of three
statuses: done (work finished), obsolete (no longer needed:
replaced, duplicated, absorbed, overtaken) or rejected (decided
against, or never a task). Moving a task into any of them closes it, and
moving it out (or
reopen) opens it again, back inready.close #n reasonis done.close #n reason --as obsolete(orrejected, or the finersuperseded,duplicate,absorbed,not-a-task, which are kept asclosed:<word>) ends it that way. Nothing is ever deleted. - Archived means "don't look at it." A bare
archivedtag, orthogonal to status. Every filter leaves archived tasks out unless it asks (archivedfor only those,archived:anyfor those too). Obsolete and rejected archive a task when set, and their lanes are hidden by default. Archive a done task when you're finished looking at it (tag #n archived). Reopening unarchives. - Finishing is your call, by how much ceremony the work needs:
review #n "what changed, what to check"when a person should look before it counts (a design change, anything user-facing, anything you are unsure of), ordone #n "…"straight to done when there is nothing to check. Each is one step. Review is for what a person can actually check by looking. Work that only the tests can verify goes to done, with the tests named in the reason (owner, 2026-09-25). Say what shipped in the close reason (close #38 "shipped in 1.15.1"). A task that ended without being done says how:close #38 "replaced by #41" --as superseded(it ends obsolete, and keeps the word) or--as rejected, which a review can count. - Practices live on the board too (board #2472). A practice is an
issue with
type:practice(virta new "Inbox triage" --type practice --tags rung:prose,mode:manual). Lists show tasks unless a filter names a type (virta list type:practice, ortype:anyfor everything). Say how it's judged withvirta measure #n "<measure>" "<death condition>". Attach its checklist or skill as files, and retire it withvirta close #n "why" --as retired, which archives it and deletes nothing. - A practice can act (board #2474):
- Give it a trigger (
trigger:enters-inbox,trigger:status:ready,trigger:tagged:kind, …) and a procedure:virta procedure #n --when "~crash" --set kind:bug. - Pick a mode:
mode:suggestproposes the tags, andmode:automaticsets them and can be undone in one step. - The project has to opt in:
virta project set <name> --practices "#n". virta project practicesruns them. It's idempotent, so run it as often as you like.- On a task,
virta resolve #t accept(oroverrule, orundo) answers the practice. Every answer is counted on the practice, as the evidence its measure reads. - Notices (board #2548): a policy tagged
targets:all(ortargets:<project>) is something every targeted repo should do. The brief shows how many your repo owes and the most important. Runvirta project noticesin the repo: it runs each notice's checks (check:file-exists:,file-equals:<path>@<sha256>,file-contains:<path>|<text>,lock-resolves:<pkg>>=<v>; a fixed set, never commands) and records the adoption when they pass, and recordsapplies-if:conditions that fail as not applicable. A notice without checks:virta project adopt #nonce done, orvirta project na #n "why".virta project notices --allshows who has adopted what. - The way in (board #2497):
virta guideshows the root entry (index:root) and every topic's index (index:topic+topic:<name>);virta guide <topic>lists that topic's entries for the repo you're in, narrowest scope first (scope:<project>, thenscope:topic, thenscope:global), with any default a narrower entry overrides (overrides:#n) marked. MCP: the guide tool, and the resourcesvirta://guidanceandvirta://guidance/<topic>. - Refactor them without losing anything (board #2496):
virta split #A "title" "title",virta supersede #A #B,virta merge #A #B --into #C. Each sets both ends at once and retires the old one. A practice is never "done":virta close #A --as retired "why". Successors start quiet (no mode, trigger or procedure; switch them on), and show the old one's evidence as earlier evidence. - The backup is an export (board #2479):
virta export <dir>writes every practice, policy and guidance under<dir>/registry/(an.mdper entry named by its id, an index, andevents.jsonl, the full history).virta restore <dir> --dry, then without--dry, rebuilds them on a host. It is idempotent, and it renumbers#ntags to the new host's numbers.
- Give it a trigger (
- Finished something people can see? Give it a showcase:
virta showcase #n "one line, up to 200 characters, **bold** allowed" --media <image or video URL> --link <docs or demo>. The board shows recent ones in a carousel above the tiles. It works on any closed task, whenever the picture or the write-up exists, and--withdrawtakes it down. - Closed a task that came from GitHub? Run
virta project push— no need to ask (owner, 2026-09-25). It closes the issue with your close reason (or reopens a reopened one), so the reporter waiting on GitHub hears the outcome; it only ever touches GitHub-imported tasks, and running it again changes nothing. Write the close reason for that reader. The other direction happens at import: a GitHub close reaches the board the next time the repo is imported (virta project import owner/repo, orvirta project onboardagain), which closes the task if it is still open. An import never reopens a task. - Pick up work on the board:
work #n. Working and in-progress imply each other: starting work on a task that isn't started moves it tostatus:in-progress, moving a task to in-progress puts you on it, and moving it out of in-progress (to review, done, back to ready) takes everyone off it.stop #nif you put it down and leave it in progress. A task needs a title or a description — an empty one is refused.
The dispatcher: a poke when the owner has work waiting
An idle session doesn't hear that the owner moved work to ready. The
dispatcher pokes it (board #161). It is off by default: a person signed
in on the board switches it on per project, in project settings ›
Dispatch, by naming the Claude session to poke. The same section sets how
often (every, minutes, default 60) and how many times a day (pokes a day, default 4). Each poke starts a paid turn, so those are the cost
controls. A token can turn dispatch off, never on.
virta project dispatch says, per project with dispatch on, whether a poke
is due and why not when it isn't. A poke is due only for a task a
person moved to ready since the last poke, that isn't a question and
that nobody is on. So a poke never approves anything on the owner's behalf,
and never repeats itself. virta project poke <project> records a poke as
a comment on the task (dispatch: poked …), so every poke shows on the
board.
The sending is a Claude session the owner runs on this machine, since only a Claude session can reach the others:
/loop 15m Run `virta project dispatch --json`. For each plan with "due": true,
find the session named in "session" (ListAgents) and send it "text"
(SendMessage), then run `virta project poke <project>`. Skip a session that
isn't listed. Send nothing else.
A session in a different permission mode holds an incoming message for the owner's approval, so a poke there still needs a tap.
What virta does not do (yet)
Nothing is written back to GitHub: issues are ingested, and the host is the truth from then on. Rendering tasks back to issues is the next milestone.