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):

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.

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

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.