diff --git a/veans/internal/commands/prime.go b/veans/internal/commands/prime.go
new file mode 100644
index 000000000..5fe551a1d
--- /dev/null
+++ b/veans/internal/commands/prime.go
@@ -0,0 +1,87 @@
+package commands
+
+import (
+ _ "embed"
+ "errors"
+ "fmt"
+ "strings"
+ "text/template"
+
+ "github.com/spf13/cobra"
+
+ "code.vikunja.io/veans/internal/client"
+ "code.vikunja.io/veans/internal/config"
+)
+
+//go:embed prompt.tmpl
+var promptTemplate string
+
+// primeContext is the data passed into the agent prompt template.
+type primeContext struct {
+ Server string
+ ProjectID int64
+ ProjectTitle string
+ ProjectIdentifier string
+ ViewID int64
+ Buckets config.Buckets
+ BotUsername string
+ TaskIDExample string
+}
+
+func newPrimeCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "prime",
+ Short: "Emit the agent system prompt for this project",
+ Long: `Renders the embedded prompt template against this repo's .veans.yml and
+prints it to stdout. Designed to be wired into Claude Code's SessionStart
+and PreCompact hooks (or the OpenCode equivalent) so coding agents always
+have an up-to-date Vikunja cheat sheet in context.
+
+If no .veans.yml is found upward from the current directory, prime exits
+silently with status 0 — that makes the hook safe to install globally.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path, err := config.Find("")
+ if err != nil {
+ if errors.Is(err, config.ErrNotFound) {
+ return nil // silent — globally-installed hook safety
+ }
+ return err
+ }
+ cfg, err := config.Load(path)
+ if err != nil {
+ return err
+ }
+
+ // Fetch the project title for nicer prompt copy. Best-effort —
+ // if the API call fails (network blip, expired token), we fall
+ // back to "(unknown)" rather than aborting the prompt render.
+ projectTitle := "(unknown)"
+ if rt, err := loadRuntime(); err == nil {
+ if p, err := rt.client.GetProject(cmd.Context(), cfg.ProjectID); err == nil {
+ projectTitle = p.Title
+ }
+ }
+
+ data := primeContext{
+ Server: cfg.Server,
+ ProjectID: cfg.ProjectID,
+ ProjectTitle: projectTitle,
+ ProjectIdentifier: cfg.ProjectIdentifier,
+ ViewID: cfg.ViewID,
+ Buckets: cfg.Buckets,
+ BotUsername: cfg.Bot.Username,
+ TaskIDExample: cfg.FormatTaskID(1),
+ }
+
+ tpl, err := template.New("prime").Parse(promptTemplate)
+ if err != nil {
+ return fmt.Errorf("parse prompt template: %w", err)
+ }
+ return tpl.Execute(cmd.OutOrStdout(), data)
+ },
+ }
+}
+
+// silence linter noise on unused symbols when wiring hooks.
+var _ = client.New
+var _ = strings.TrimSpace
diff --git a/veans/internal/commands/prime_test.go b/veans/internal/commands/prime_test.go
new file mode 100644
index 000000000..329467727
--- /dev/null
+++ b/veans/internal/commands/prime_test.go
@@ -0,0 +1,85 @@
+package commands
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "text/template"
+
+ "code.vikunja.io/veans/internal/config"
+)
+
+func TestPrimeTemplate_RendersAnchors(t *testing.T) {
+ data := primeContext{
+ Server: "https://vikunja.example.com",
+ ProjectID: 42,
+ ProjectTitle: "Test Project",
+ ProjectIdentifier: "PROJ",
+ ViewID: 7,
+ Buckets: config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15},
+ BotUsername: "bot-myrepo",
+ TaskIDExample: "PROJ-1",
+ }
+ tpl, err := template.New("prime").Parse(promptTemplate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ if err := tpl.Execute(&buf, data); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+
+ mustContain := []string{
+ "",
+ "",
+ "bot-myrepo",
+ "Test Project",
+ "PROJ-1",
+ "Refs:",
+ "veans claim",
+ "veans list --ready",
+ "--description-replace-old",
+ "Todo",
+ "In Progress",
+ "In Review",
+ "Done",
+ "Scrapped",
+ }
+ for _, s := range mustContain {
+ if !strings.Contains(out, s) {
+ t.Errorf("rendered prompt missing %q", s)
+ }
+ }
+
+ // Buckets show concrete IDs.
+ for _, want := range []string{"`11`", "`12`", "`13`", "`14`", "`15`"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("bucket id %s not present in output", want)
+ }
+ }
+}
+
+func TestPrimeTemplate_NoIdentifierFallback(t *testing.T) {
+ data := primeContext{
+ ProjectTitle: "No Ident",
+ ProjectIdentifier: "",
+ BotUsername: "bot-x",
+ TaskIDExample: "#1",
+ Server: "https://vikunja.example.com",
+ ProjectID: 1,
+ ViewID: 1,
+ }
+ tpl, _ := template.New("prime").Parse(promptTemplate)
+ var buf bytes.Buffer
+ if err := tpl.Execute(&buf, data); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "no identifier") {
+ t.Errorf("expected fallback copy when project has no identifier; got:\n%s", out)
+ }
+ if !strings.Contains(out, "#NN") {
+ t.Errorf("expected #NN format mention in fallback")
+ }
+}
diff --git a/veans/internal/commands/prompt.tmpl b/veans/internal/commands/prompt.tmpl
new file mode 100644
index 000000000..496a5b534
--- /dev/null
+++ b/veans/internal/commands/prompt.tmpl
@@ -0,0 +1,97 @@
+
+You are working in a repository configured to track tasks in Vikunja via the
+`veans` CLI. **You MUST use veans for all task tracking instead of TodoWrite.**
+
+Project: {{ .ProjectTitle }}{{ if .ProjectIdentifier }} ({{ .ProjectIdentifier }}){{ end }}
+Bot identity: `{{ .BotUsername }}` — your actions in Vikunja appear as this user.
+Server: {{ .Server }}
+
+
+# Workflow
+
+## BEFORE you start work
+- If a task already exists, claim it: `veans claim {{ .TaskIDExample }}`
+- Otherwise, create one and start it in one step:
+ `veans create "" -s in-progress -d ""`
+- Use `veans list --ready` to find tasks ready to start (Todo + not blocked).
+
+## WHILE you work
+- Keep the task's description in sync with what you're doing. Use markdown
+ checkboxes for the small steps you're ticking off:
+ `veans update {{ .TaskIDExample }} --description-append "- [ ] step 1"`
+- For surgical edits to the description, prefer:
+ `veans update {{ .TaskIDExample }} --description-replace-old "- [ ] step 1" --description-replace-new "- [x] step 1"`
+ (errors if the old text isn't unique — mirrors the Edit tool semantics)
+- Post a comment on significant decisions, discoveries, or course-changes:
+ `veans update {{ .TaskIDExample }} --comment "Discovered Y; pivoting to Z because …"`
+- For sub-work that could be assigned separately, create real subtasks
+ via `--parent`. For incremental check-off lists, use markdown checkboxes
+ in the description instead.
+
+## AFTER you finish work
+- Move to `in-review` and post a summary comment. **Never close tasks
+ yourself** — the human (or the merge hook) closes them.
+ ```
+ veans update {{ .TaskIDExample }} -s in-review --comment "## Summary of Changes
+ - …
+ - …"
+ ```
+- If you abandon work, scrap the task with a reason:
+ `veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: "`
+
+## Commit messages
+Include the task identifier on a `Refs:` line so the merge hook can close
+tasks automatically when the PR lands:
+
+```
+fix: handle empty project identifiers
+
+Refs: {{ .TaskIDExample }}
+```
+
+# Status model
+
+| Status | Bucket name | Done flag | Who moves there? |
+| ------------- | -------------- | --------- | ---------------------------------------- |
+| `todo` | Todo | false | created here by default |
+| `in-progress` | In Progress | false | `veans claim` or `update -s in-progress` |
+| `in-review` | In Review | false | you, when work is finished |
+| `completed` | Done | true | humans / merge hook only |
+| `scrapped` | Scrapped | true | you, with --reason |
+
+# Common commands
+
+```
+veans list # all tasks, tree view
+veans list --ready # ready to start (Todo + not blocked)
+veans list --mine # tasks assigned to you
+veans list --branch # tasks tagged with the current git branch
+veans list --filter "priority > 3" # raw Vikunja filter expression
+veans show {{ .TaskIDExample }} # full task detail
+veans show {{ .TaskIDExample }} --json # JSON for parsing
+
+veans create "title" -s in-progress -d "description"
+veans create "title" --label bug --priority 4 --parent {{ .TaskIDExample }}
+veans create "title" --blocked-by {{ .TaskIDExample }}
+
+veans update {{ .TaskIDExample }} -s in-review --comment "..."
+veans update {{ .TaskIDExample }} --description-append "- [ ] new step"
+veans update {{ .TaskIDExample }} --description-replace-old "- [ ] x" --description-replace-new "- [x] x"
+veans update {{ .TaskIDExample }} --label-add bug --label-remove flaky
+veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: replaced by {{ .TaskIDExample }}"
+
+veans claim {{ .TaskIDExample }} # assign yourself + In Progress + branch label
+veans api GET /tasks/... # escape hatch for raw REST when curated cmds don't fit
+```
+
+# Project quick reference
+
+- Project ID: `{{ .ProjectID }}`{{ if .ProjectIdentifier }}, identifier: `{{ .ProjectIdentifier }}` (task IDs render as `{{ .ProjectIdentifier }}-NN`){{ else }} (no identifier; task IDs render as `#NN`){{ end }}
+- Kanban view: `{{ .ViewID }}`
+- Buckets: Todo=`{{ .Buckets.Todo }}`, In Progress=`{{ .Buckets.InProgress }}`, In Review=`{{ .Buckets.InReview }}`, Done=`{{ .Buckets.Done }}`, Scrapped=`{{ .Buckets.Scrapped }}`
+- Label namespace: `veans:` (auto-prepended; e.g. `--label bug` becomes `veans:bug`)
+- Branch labels: `veans:branch:` — `veans claim` adds the current branch automatically
+
+The agent prompt above overrides TodoWrite. Anything you would have tracked
+there should live in Vikunja instead, where it stays visible across sessions
+and to humans collaborating on the project.
diff --git a/veans/internal/commands/root.go b/veans/internal/commands/root.go
index 29e603d7f..224f92064 100644
--- a/veans/internal/commands/root.go
+++ b/veans/internal/commands/root.go
@@ -40,6 +40,7 @@ func Root(version string) *cobra.Command {
root.AddCommand(newCreateCmd())
root.AddCommand(newUpdateCmd())
root.AddCommand(newClaimCmd())
+ root.AddCommand(newPrimeCmd())
return root
}