Two complementary articles: - ARTICLE-agent-task-management.md: CTO voice, ~2800 words, narrative style - ARTICLE-agent-task-consolidation.md: Technical reference, ~6000 words, full configs Both cover: beads (Go) vs br (Rust) vs Agent Mail vs Gitea with 12 labels, 1Password integration, SeaweedFS S3 storage, Docker Compose deployment, and the three API gotchas (label swap, assignees, dependencies). All commands verified against live Gitea 1.22.6. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22 KiB
We Replaced Three Agent Tools with Gitea and Twelve Labels
Date: February 18, 2026 Author: Alex, CTO @ Zestic AI Tags: ai-agents, task-management, gitea, devops, simplicity
The best infrastructure is the infrastructure you already run.
We spent eighteen months building, rewriting, and stitching together three separate tools for AI agent task management. A Go CLI. A Rust rewrite. An MCP coordination server. Each solved a real problem. Each introduced new ones. Then one afternoon, staring at a Gitea milestone progress bar, the obvious answer arrived: we already had everything we needed. We just had too many tools to see it.
This is the story of how we replaced bd, br, and mcp-agent-mail with a self-hosted Gitea instance, twelve scoped labels, and a handful of shell functions. No custom servers. No proprietary state stores. No binaries to distribute. Just an issue tracker doing what issue trackers do.
The Problem: Three Tools, One Job
Here is what our AI agent task management stack looked like before the consolidation:
Tool 1: beads (bd) -- A Go CLI that stored issues in SQLite and JSONL, committed to git inside a .beads/ directory. Priority P0-P4, types for bug/feature/task/epic/chore. The works. It had a daemon mode, RPC mode, git hooks, and a bd sync command that auto-committed changes to the repository.
Tool 2: beads_rust (br) -- The Rust rewrite. Same concept, fewer footguns. Version 0.1.13. The big change: br never executes git commands. You run br sync --flush-only to export to JSONL, then git add .beads/ && git commit yourself.
Tool 3: MCP Agent Mail (mcp-agent-mail) -- An MCP server for multi-agent coordination. Identities, inboxes, outboxes, searchable threads, advisory file reservations (leases). Runs on localhost:8765. Macros for session setup, thread preparation, file reservation cycles, contact handshakes.
Three tools. Three state stores. Three sets of failure modes. One team.
Why bd Auto-Commit Was a Mistake
The original beads CLI did something that sounds reasonable until you think about it for ten seconds: it auto-committed its state to git via bd sync.
Here is what happens when an AI agent is working on a task, writing code, staging changes, and the task tracker decides to git add .beads/ && git commit in the middle of that work: the agent's uncommitted changes either get swept into the beads commit or the beads commit races with the agent's own commit. We lost work. Not once -- multiple times.
The Rust rewrite fixed this by being "non-invasive." br never touches git. That was the right call, and it taught us an important principle: your task tracker has no business modifying the repository it lives in. Task state and code changes operate at different cadences. Coupling them through auto-commit is asking for data loss.
But br still had the fundamental problem of being a local-first tool. Data lived in .beads/ inside each repository. No cross-repo visibility. No web UI for humans. Every machine that needed to manage tasks required the br binary installed. For a single developer working on a single repo, this is fine. For a team running multiple AI agents across multiple repositories, it is untenable.
Agent Mail Solved Coordination, Then Became the Problem
Enter mcp-agent-mail. It solved a genuine gap: how do multiple AI agents coordinate work without clobbering each other?
The answer was a message bus with identity management and file reservations. Agent A reserves src/auth.rs, sends a message to Agent B saying "I am working on auth, do not touch this file," and Agent B acknowledges. Thread-based communication. Advisory locking. Cross-repo coordination through a central server.
It worked. But look at what an agent had to do before it could start actual work:
- Register an identity with the mail server
- Start a session
- Prepare a thread
- Reserve files it intended to modify
- Send a contact handshake to relevant agents
- Check inbox for acknowledgments
- Actually start working
Seven steps before writing a single line of code. That is not coordination -- that is ceremony.
And the mail server itself was another piece of infrastructure to run, monitor, and keep alive. Its state store was separate from the task tracker. You had task state in .beads/, coordination state in mcp-agent-mail, and code state in git. Three sources of truth that needed to stay synchronized. They didn't.
The Moment of Clarity
The realization was almost embarrassing in its simplicity.
We were already running a self-hosted Gitea instance at git.terraphim.cloud -- Gitea 1.22.6, Docker Compose, PostgreSQL, SeaweedFS for S3 storage, Caddy reverse proxy, the whole stack. We had deployed it for code hosting. It had an issue tracker. That issue tracker had labels, milestones, assignees, dependencies, comments, and a REST API.
Every feature we had built across three tools already existed in the software we were running for something else.
| What We Built | What Gitea Already Has |
|---|---|
br create / bd create |
POST /repos/{owner}/{repo}/issues |
br ready / br update --status |
Scoped workflow labels |
br list --status ready |
GET /issues?labels=workflow:ready |
br close |
PATCH /issues/{index} with {"state": "closed"} |
br dep add |
POST /issues/{index}/dependencies |
br stats |
Milestone progress (open/closed counts) |
| Agent Mail file reservations | Issue assignees (one owner per task) |
| Agent Mail threads | Issue comments |
| Agent Mail identities | Gitea user accounts |
.beads/ JSONL export |
Gitea API (queryable, no export needed) |
The mapping was nearly one-to-one. The features we thought we needed to build were just an issue tracker with labels.
The Implementation: Twelve Labels and Five Shell Functions
The entire implementation fits in three files. A 130-line setup script. A 480-line skill document. A 430-line integration test suite. That is it.
The Label Scheme
Twelve scoped labels across three dimensions. Workflow state, priority, and type. Each scope is mutually exclusive -- you cannot be both workflow:ready and workflow:in-progress at the same time.
Workflow (4 labels):
| Label | Color | Meaning |
|---|---|---|
workflow:ready |
Green | Available for an agent to claim |
workflow:in-progress |
Yellow | Being actively worked on |
workflow:blocked |
Red | Waiting on a dependency |
workflow:done |
Purple | Complete |
Priority (4 labels):
| Label | Color | Meaning |
|---|---|---|
priority:critical |
Dark red | Now |
priority:high |
Red | This cycle |
priority:medium |
Yellow | Normal |
priority:low |
Green | When we get to it |
Type (4 labels):
| Label | Color | Meaning |
|---|---|---|
type:feature |
Blue | New functionality |
type:bug |
Red | Something broke |
type:task |
Violet | General work item |
type:research |
Teal | Investigation / spike |
Setup is idempotent. Run setup-labels.sh as many times as you want -- it checks for existing labels before creating:
source ~/op_zesticai_non_prod.sh
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
./setup-labels.sh # defaults to terraphim/agent-tasks
./setup-labels.sh myorg myrepo # or specify your own
The Shell Helpers
Every operation is a function that takes positional arguments and returns JSON. No flags to remember, no binary to install. Copy the function into your agent's context and go.
The base API helper:
gitea_api() {
local method="$1" endpoint="$2" data="${3:-}"
local args=(-s -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json")
if [ -n "$data" ]; then
args+=(-X "$method" -d "$data")
else
args+=(-X "$method")
fi
curl "${args[@]}" "$GITEA_URL/api/v1$endpoint"
}
Create a project (milestones serve as project containers -- more on why below):
gitea_create_project "Sprint 2026-W08" "Weekly sprint" "2026-02-28T00:00:00Z"
Create a task and assign it to a milestone:
gitea_create_task "Implement auth module" "OAuth2 flow for API clients" 1 "4,14"
Claim a task (swap workflow label + assign):
gitea_claim 1 root
Close a task with a comment:
gitea_close_task 1 "Implemented and tested. See commit abc123."
Check project progress:
gitea_progress 1
# Returns: {"milestone":"Sprint 2026-W08","open":3,"closed":7,"total":10,"percent_complete":70}
An AI agent can learn the entire task management API from a single skill document. No SDK installation. No daemon to keep running. No state synchronization. The state lives in Gitea, which is already running, already backed up, already monitored.
Milestones as Projects
Gitea 1.22.6 has no project board REST API. This is not speculation -- we tested it. GET /api/v1/user/projects returns 404. The feature is tracked in Gitea issue #14299, targeted for version 1.26+.
This turned out to be a non-issue. Milestones give us everything a project container needs: a title, a description, a due date, automatic progress tracking (open vs. closed issue counts), and the ability to filter issues by milestone. You do not need kanban columns when you have workflow labels.
The Infrastructure: Already Running
The Gitea instance was deployed via Docker Compose with 1Password template injection. No new infrastructure was required for this migration. Zero.
Internet -> Caddy (HTTPS) -> Gitea (localhost:3000)
|
Docker Network (internal)
| | |
PostgreSQL SeaweedFS Prometheus
(S3)
Secrets are managed through 1Password's op inject:
op inject --in-file docker-compose.yml.template --out-file docker-compose.yml
docker compose up -d
The template contains op://TerraphimPlatform/... references that get resolved at deploy time. No plaintext secrets in git. No .env files to manage. Database password, S3 keys, API tokens -- all in one vault, all injected at runtime.
SeaweedFS provides S3-compatible storage for attachments, avatars, and Git LFS objects. Prometheus scrapes metrics from all services. Caddy handles TLS termination with automatic certificate renewal. The whole stack is six Docker containers and a Caddyfile.
For agent authentication, the token lives in 1Password:
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
One line. No token files. No environment variable sprawl. Every agent session starts with this, and the token never touches disk.
The Gotchas Nobody Tells You About
This is the section that justifies the article. Gitea's API is good, but it has sharp edges that cost us a day of debugging. Learn from our pain.
Gotcha 1: Scoped Label Exclusivity Is UI-Only
This was the single biggest surprise.
Gitea's scoped labels (created with exclusive: true) display mutual exclusivity in the web UI. Click workflow:in-progress and workflow:ready disappears. Beautiful. Exactly what we wanted.
But the API POST /repos/{owner}/{repo}/issues/{index}/labels endpoint just appends. It does not remove conflicting scoped labels. You can end up with both workflow:ready and workflow:in-progress on the same issue, which is nonsensical.
The fix is a swap pattern: fetch current labels, filter out the old scope, add the new label, and PUT the complete set atomically:
gitea_swap_workflow() {
local index="$1" new_label="$2" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
# Get the ID of the new workflow label
repo_labels=$(gitea_api GET "/repos/$owner/$repo/labels")
new_id=$(echo "$repo_labels" | jq -r --arg n "$new_label" \
'.[] | select(.name==$n) | .id')
# Get current issue labels, filtering out all workflow:* labels
issue=$(gitea_api GET "/repos/$owner/$repo/issues/$index")
kept_ids=$(echo "$issue" | jq \
'[.labels[] | select(.name | startswith("workflow:") | not) | .id]')
# Combine kept labels + new workflow label, PUT atomically
all_ids=$(echo "$kept_ids" | jq --argjson nid "$new_id" '. + [$nid]')
gitea_api PUT "/repos/$owner/$repo/issues/$index/labels" "{\"labels\": $all_ids}"
}
This pattern -- GET current state, compute desired state, PUT the complete set -- is the only reliable way to manage scoped labels via the API. We wrap every workflow transition in this function. Our integration tests verify that the old workflow label actually gets removed on every transition.
Gotcha 2: The Assignee Endpoint Does Not Exist
The Gitea docs suggest POST /repos/{owner}/{repo}/issues/{index}/assignees should work. In Gitea 1.22.6, it returns 404.
Use PATCH /repos/{owner}/{repo}/issues/{index} with {"assignees": ["username"]} instead. It is the general issue edit endpoint, not a dedicated assignee endpoint, but it works.
# Wrong (404 in Gitea 1.22.6):
gitea_api POST "/repos/$owner/$repo/issues/$index/assignees" '{"assignees": ["root"]}'
# Right:
gitea_api PATCH "/repos/$owner/$repo/issues/$index" '{"assignees": ["root"]}'
Gotcha 3: Issue Dependencies Use IssueMeta Format
If you try the obvious body format for adding a dependency:
{"depends_on_id": 2}
You get: "repository does not exist [id: 0]". Helpful error message, that.
The correct format is IssueMeta -- a full reference to the issue including owner and repo:
{"owner": "terraphim", "repo": "agent-tasks", "index": 2}
This is actually a better design than a bare ID. It supports cross-repository dependencies out of the box. But the error message when you get it wrong is genuinely misleading.
Testing: 26 Assertions, Zero Mocks
We do not mock API calls. Every test runs against the live Gitea instance. The test suite creates a timestamped repository (skill-test-{timestamp}), runs all ten test scenarios, validates 26 assertions, and cleans up the repo on exit -- even on failure, thanks to a bash trap.
./test-gitea-skill.sh
============================================
Gitea Skill Integration Tests
============================================
Instance: https://git.terraphim.cloud
User: root
Test repo: root/skill-test-1739884800
--- Test 1: Create test repository ---
[PASS] Repo name matches
[PASS] Repo has id
[PASS] Owner matches
--- Test 2: Create labels via setup-labels.sh ---
[PASS] Created 12 labels (expected >= 12)
[PASS] workflow:ready is exclusive
[PASS] workflow:in-progress is exclusive
...
--- Test 6: Claim task (workflow:in-progress) ---
[PASS] Issue has workflow:in-progress label
[PASS] workflow:ready removed by label swap
[PASS] Assignee set
...
============================================
Results: 26 passed, 0 failed (26 total)
============================================
Test 6 is the critical one. It verifies three things in sequence: the new workflow label is present, the old workflow label was removed by the swap pattern, and the assignee was set via PATCH. If any of these fail, we know the gotchas are back.
First run of this test suite against the live API: 23/26 passed, 3 failed. Those three failures were the label swap bug and the assignee endpoint. Fixed in an hour. Second run: 26/26.
That is what testing against real infrastructure gives you. Mocks would have passed on the first run and failed in production.
What We Lost and Why We Don't Miss It
Let's be honest about what the migration removed.
Gone: Local-first offline capability. The .beads/ directory worked without network access. Gitea requires connectivity. For our use case -- AI agents running on servers with reliable internet -- this is not a loss. If your agents work offline in a bunker, this trade-off may not suit you.
Gone: br sync JSONL export. The beads tools exported task data as JSONL files you could grep, pipe, and process with standard Unix tools. With Gitea, you query the API. For an AI agent that speaks HTTP natively, this is arguably better. For a human who wants to grep -c "status:done" .beads/issues.jsonl, it is different. We have not missed it.
Gone: Agent Mail file reservations. The advisory locking system in mcp-agent-mail prevented two agents from editing the same file simultaneously. We replaced this with a simpler convention: one agent per task, one task per file group. If Agent A is assigned to issue #3 and issue #3's description lists the files it will modify, Agent B checks the issue board before starting work. Is this as rigorous as advisory locks? No. Has it caused a conflict in practice? Also no.
Gone: Daemon mode and RPC. The original bd had a long-running daemon for performance. With Gitea, every operation is a stateless HTTP call. No daemon to crash. No RPC to debug. No port conflicts.
Gained: Web UI for humans. This was the biggest win we did not plan for. Stakeholders can now see task status in a browser. They can add comments, change priorities, and close issues without learning a CLI. The task board became a communication tool, not just an agent bookkeeping system.
Gained: Cross-repo visibility. All agent tasks live in terraphim/agent-tasks by default. One place to see everything. Override with GITEA_OWNER and GITEA_REPO environment variables when you need per-repo tracking.
Gained: Zero distribution burden. No binary to compile. No package to install. The agent skill is a markdown document with shell functions. Copy, paste, authenticate, work.
The br Command Mapping
For anyone migrating from br, here is the translation table:
| br Command | Gitea Equivalent | Notes |
|---|---|---|
br init |
./setup-labels.sh |
One-time per repo |
br create "Title" --type task |
gitea_create_task "Title" "Body" $MILESTONE_ID "$LABEL_IDS" |
Labels by ID |
br ready 1 |
gitea_ready 1 |
Swap pattern under the hood |
br update 1 --status in_progress |
gitea_claim 1 root |
Also sets assignee |
br close 1 |
gitea_close_task 1 "Done" |
Closes issue + sets workflow:done |
br list --status ready |
curl ... /issues?labels=workflow:ready |
Filter by label |
br dep add 3 2 |
POST /issues/3/dependencies {"owner","repo","index":2} |
IssueMeta format |
br stats |
gitea_progress $MILESTONE_ID |
Returns JSON with percentages |
br sync --flush-only |
Not needed | State already in Gitea |
The last row is the point. There is no sync step because there is nothing to sync. The source of truth is Gitea. There is no .beads/ directory to commit. There is no JSONL file to export. The state lives in the database of a server that is already running and already backed up.
What This Means for AI Agent Architecture
This migration crystallized something we had been circling for a while: AI agents do not need bespoke tooling. They need standard tooling with good APIs.
An AI agent that can make HTTP calls can use Gitea's REST API. It does not need a custom CLI. It does not need an MCP server wrapping another API. It does not need a local database synced to git. It needs curl, jq, and a token.
The twelve-label scheme gives agents a state machine they can reason about. workflow:ready means "pick this up." workflow:in-progress means "someone is on it." workflow:blocked means "wait." workflow:done means "move on." Any LLM can understand this. No specialized training, no tool-specific prompting.
Milestones give agents project context. "You are working on Sprint 2026-W08. Here are the open tasks. Here is what is blocked. Here is overall progress." All queryable via a single API call.
Issue comments give agents memory. Close a task with a comment explaining what you did, what you learned, what edge cases you hit. The next agent picks up the thread.
Dependencies give agents sequencing. "Do not start issue #5 until issue #3 is closed." The API enforces it.
This is not revolutionary. It is an issue tracker. That is exactly the point. The best agent infrastructure is boring infrastructure that works.
What's Next
Three things we are evaluating:
1. MCP integration. The official gitea-mcp server provides ~80 tools for repository management but lacks milestone and workflow label support. The forgejo-mcp alternative covers milestones and dependencies. We may adopt one of these as a complement to our shell helpers, but not as a replacement. The helpers handle the 20% of operations that no MCP server covers yet.
2. Gitea 1.26 upgrade. When the project board API ships, we will evaluate whether it adds value over our milestone + label approach. My bet: it will not. We don't need kanban columns when we have four workflow labels and a REST API.
3. Cross-instance agent coordination. Right now, all agents share one Gitea instance. As we scale, we may need agents on different instances to coordinate. Issue dependencies already support cross-repo references ({"owner": "...", "repo": "...", "index": N}). Cross-instance is a harder problem, but we are not solving problems we don't have yet.
The Bottom Line
We ran three tools because we assumed agent task management was a special problem that needed special solutions. It isn't.
An issue tracker with labels is a task management system. A milestone is a project. An assignee is an owner. A comment is a status update. A dependency is a dependency. We had been building elaborate machinery to replicate what a 2004-vintage Bugzilla could do.
Gitea, twelve labels, and five shell functions. The test suite passes. The agents are productive. Stakeholders can see the board. The infrastructure team is not maintaining three extra tools. The answer, as it so often is, was less code -- not more.
Repository: terraphim/gitea-infrastructure
Skill Document: .agents/skills/gitea/SKILL.md
Setup Script: .agents/skills/gitea/setup-labels.sh
Integration Tests: .agents/skills/gitea/test-gitea-skill.sh (10 tests, 26 assertions, all passing)
Gitea Version: 1.22.6
Stack: Docker Compose + PostgreSQL 15 + SeaweedFS + Caddy + 1Password + Prometheus