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>
45 KiB
From Three Agent Tools to One: Replacing beads, br, and Agent Mail with Gitea
Technical Architecture Report Date: 2026-02-18 Target System: Gitea 1.22.6 on git.terraphim.cloud Verified: All commands tested against live instance
1. Introduction
AI agent systems need task tracking, but installing and maintaining multiple specialized tools creates operational overhead. This article documents the consolidation of three agent task management tools into a single Gitea-based solution with verified working commands and configurations.
The migration eliminates three separate systems:
- beads (bd): Go-based CLI task tracker with problematic auto-commit behavior
- beads_rust (br): Rust rewrite that fixed the auto-commit issue but remained local-only
- mcp-agent-mail: Multi-agent coordination server for cross-agent communication
The replacement is a Gitea 1.22.6 instance with REST API integration, milestone-based project tracking, and scoped workflow labels. All configuration uses 1Password for secret management.
2. The Three Incumbents
2.1 beads (Go, "bd")
Architecture: CLI issue tracker using SQLite + JSONL storage in .beads/ directory, committed to git as source of truth.
Commands:
bd ready # List ready tasks
bd create # Create new task
bd update ID # Modify task
bd close ID # Complete task
bd sync # Sync to JSONL and commit to git
bd list # List tasks
bd dep add # Add dependency
bd stats # Show project statistics
Task properties:
- Priority: P0 (critical) through P4 (low)
- Type: bug, feature, task, epic, chore
- Status: draft, ready, in_progress, blocked, done
- Dependencies: Task can depend on other tasks
Critical flaw: bd sync executed git commands automatically. The synchronization step would:
- Export task state to
.beads/tasks.jsonl - Run
git add .beads/ - Run
git commit -m "sync beads" - Optionally push to remote
This created race conditions when AI agents made rapid commits. Two agents running bd sync simultaneously could corrupt the git history or create merge conflicts.
Additional features (later removed):
- Daemon mode for background synchronization
- RPC mode for multi-process access
- Git hooks for automatic sync on commit
- Priority-based task sorting with graph analysis
All of these features were removed in the Rust rewrite due to complexity and reliability concerns.
2.2 beads_rust (br, v0.1.13)
Architecture: Same concept as beads, rewritten in Rust with a critical behavioral change: br never executes git commands.
Commands (subset of original beads):
br ready # List ready tasks
br create "title" # Create task (simpler syntax)
br update ID # Modify task
br close ID # Complete task
br sync --flush-only # Export to JSONL only (no git)
br list # List tasks
br dep add # Add dependency
br stats # Show statistics
Key behavioral change: The br sync --flush-only command only exports state to .beads/tasks.jsonl. The user must manually run:
git add .beads/
git commit -m "update tasks"
git push
This eliminated race conditions but introduced a new problem: agents would forget to commit, leading to divergent state across machines.
Architecture decision: "Non-invasive by default." The tool manages task state but delegates all version control to the user.
Limitations:
- Local-first design: each repository has its own
.beads/directory - No cross-repository visibility (can't see tasks from other repos)
- No web UI (CLI only)
- Requires
brbinary installation on every machine - No centralized dashboard for monitoring agent progress
What was removed from beads (Go):
- Daemon mode (process management complexity)
- RPC server (network protocol overhead)
- Auto-commit on sync (source of race conditions)
- Git hooks (implicit behavior caused confusion)
The Rust rewrite prioritized reliability and simplicity over features.
2.3 MCP Agent Mail (mcp-agent-mail)
Architecture: MCP server providing multi-agent coordination through a message-passing abstraction.
Core concepts:
- Identities: Each agent has a unique identity (e.g., "agent-researcher", "agent-coder")
- Inbox/Outbox: Messages organized by recipient
- Threads: Conversations grouped by thread ID (convention:
br-123for task 123) - File reservations: Advisory locks to prevent concurrent file edits
Key operations:
# Send message
mcp__agent_mail__send_message(to="agent-coder", subject="Review needed", body="...", thread="br-42")
# Read inbox
mcp__agent_mail__get_inbox(agent="agent-coder", limit=10)
# Search threads
mcp__agent_mail__search_threads(query="authentication", agent="agent-coder")
# Reserve file (advisory lock)
mcp__agent_mail__reserve_file(path="/src/auth.rs", agent="agent-coder", duration=3600)
# Release file reservation
mcp__agent_mail__release_file(path="/src/auth.rs", agent="agent-coder")
Macro helpers (common workflows):
# Start agent work session
macro_start_session(agent="agent-coder")
# Returns: unread message count, active file reservations
# Prepare to work on a task thread
macro_prepare_thread(agent="agent-coder", thread="br-42")
# Returns: thread messages, related file reservations
# Check-reserve-work-release cycle
macro_file_reservation_cycle(agent="agent-coder", file="/src/auth.rs", thread="br-42")
# Checks for existing reservation, reserves if free, returns status
Product bus pattern: Central coordination repository where agents post status updates and coordinate work across multiple code repositories.
Limitations:
- Additional server process to run and monitor
- Separate state store (not integrated with version control)
- Coordination overhead (agents must check inbox before acting)
- No built-in web UI for human visibility
- Thread ID convention (br-123) tightly coupled to beads/br task IDs
Why it existed: br's local-first design couldn't coordinate work across multiple repositories. Agent Mail provided the missing cross-repo coordination layer.
3. The Gitea Alternative
3.1 Instance Details
URL: https://git.terraphim.cloud Version: Gitea 1.22.6 Database: PostgreSQL 15 Storage: SeaweedFS (S3-compatible)
3.2 Critical API Discovery
During implementation, we discovered that Gitea 1.22.6 has NO project board REST API.
Attempted request:
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.terraphim.cloud/api/v1/user/projects"
# Returns: HTTP 404
The Gitea UI has project boards, but they are not exposed via REST API in version 1.22.6. This required a design pivot: milestones serve as project containers instead.
Milestone API works and provides:
- Title, description, due date
- Progress tracking:
open_issuesandclosed_issuescounts - State: open or closed
- Full CRUD operations via REST API
3.3 Workflow State Machine via 12 Scoped Labels
Labels implement the task lifecycle. Each label has a scope prefix (e.g., workflow:, priority:, type:) and the exclusive: true flag, making labels within the same scope mutually exclusive in the Gitea UI.
Workflow labels (lifecycle states):
| Label | Color | Hex | Scope | Exclusive |
|---|---|---|---|---|
| workflow:ready | Green | #0e8a16 | workflow | true |
| workflow:in-progress | Yellow | #fbca04 | workflow | true |
| workflow:blocked | Red | #d93f0b | workflow | true |
| workflow:done | Purple | #6f42c1 | workflow | true |
Priority labels (urgency):
| Label | Color | Hex | Scope | Exclusive |
|---|---|---|---|---|
| priority:critical | Dark red | #b60205 | priority | true |
| priority:high | Red | #d93f0b | priority | true |
| priority:medium | Yellow | #fbca04 | priority | true |
| priority:low | Green | #0e8a16 | priority | true |
Type labels (task categorization):
| Label | Color | Hex | Scope | Exclusive |
|---|---|---|---|---|
| type:feature | Blue | #1d76db | type | true |
| type:bug | Red | #d93f0b | type | true |
| type:task | Violet | #5319e7 | type | true |
| type:research | Teal | #006b75 | type | true |
Label setup script (verified working):
#!/usr/bin/env bash
# setup-labels.sh
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://git.terraphim.cloud}"
GITEA_OWNER="${1:-${GITEA_OWNER:-terraphim}}"
GITEA_REPO="${2:-${GITEA_REPO:-agent-tasks}}"
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"
}
ensure_label() {
local name="$1" color="$2" description="$3" exclusive="${4:-false}"
local existing
existing=$(gitea_api GET "/repos/$GITEA_OWNER/$GITEA_REPO/labels" \
| jq -r --arg n "$name" '.[] | select(.name == $n) | .id')
if [ -n "$existing" ]; then
echo "{\"name\": \"$name\", \"id\": $existing, \"action\": \"exists\"}"
return 0
fi
local payload
payload=$(jq -n \
--arg name "$name" \
--arg color "$color" \
--arg desc "$description" \
--argjson excl "$exclusive" \
'{name: $name, color: $color, description: $desc, exclusive: $excl}')
local result
result=$(gitea_api POST "/repos/$GITEA_OWNER/$GITEA_REPO/labels" "$payload")
local label_id
label_id=$(echo "$result" | jq -r '.id // empty')
if [ -n "$label_id" ]; then
echo "{\"name\": \"$name\", \"id\": $label_id, \"action\": \"created\"}"
else
echo "{\"name\": \"$name\", \"action\": \"error\", \"detail\": $result}" >&2
return 1
fi
}
# Workflow labels
ensure_label "workflow:ready" "0e8a16" "Task is ready for an agent to pick up" true
ensure_label "workflow:in-progress" "fbca04" "Task is actively being worked on" true
ensure_label "workflow:blocked" "d93f0b" "Task is blocked by a dependency" true
ensure_label "workflow:done" "6f42c1" "Task is complete" true
# Priority labels
ensure_label "priority:critical" "b60205" "Must be done immediately" true
ensure_label "priority:high" "d93f0b" "Should be done this cycle" true
ensure_label "priority:medium" "fbca04" "Normal priority" true
ensure_label "priority:low" "0e8a16" "Nice to have" true
# Type labels
ensure_label "type:feature" "1d76db" "New functionality" true
ensure_label "type:bug" "d93f0b" "Defect fix" true
ensure_label "type:task" "5319e7" "General task" true
ensure_label "type:research" "006b75" "Investigation or spike" true
Run with:
source ~/op_zesticai_non_prod.sh
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
./setup-labels.sh
4. 1Password Integration
All secrets managed via 1Password. Never hardcode tokens.
4.1 1Password Items (TerraphimPlatform vault)
| Item | Purpose | Fields |
|---|---|---|
| git.terraphim.cloud-admin-token | Gitea API authentication | credential (token string) |
| gitea-postgres | PostgreSQL database | password |
| gitea-s3 | SeaweedFS S3 credentials | access-key, secret-key |
4.2 Token Retrieval (Verified Working)
# Load 1Password service account
source ~/op_zesticai_non_prod.sh
# Read Gitea admin token
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
# Verify authentication (VERIFIED 2026-02-18)
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.terraphim.cloud/api/v1/user" | jq '{login, is_admin}'
# Returns: {"login": "root", "is_admin": true}
4.3 Secret Injection for Docker Compose
Docker Compose template uses 1Password references:
# Inject secrets from 1Password into docker-compose.yml
op inject --in-file docker-compose.yml.template --out-file docker-compose.yml
# Start services with injected secrets
docker compose up -d
5. Gitea Infrastructure with SeaweedFS
5.1 Architecture Overview
┌─────────────────────────────────────────────────┐
│ Internet (HTTPS) │
└────────────────┬────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────┐
│ Caddy Reverse Proxy │
│ - TLS termination │
│ - git.terraphim.cloud -> localhost:3000 │
└────────────────┬────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────┐
│ Gitea 1.22.6 (Container: gitea) │
│ - HTTP: 127.0.0.1:3000 │
│ - SSH: 0.0.0.0:222 │
│ - Database: PostgreSQL 15 │
│ - Storage: SeaweedFS S3 │
└────────────────┬────────────────────────────────┘
│
┌────────┴────────┐
│ │
v v
┌──────────────┐ ┌─────────────────────────────┐
│ PostgreSQL │ │ SeaweedFS │
│ (Container) │ │ - master (9333) │
│ Port: 5432 │ │ - volume (8080) │
│ (internal) │ │ - filer (8888) │
└──────────────┘ │ - s3 (8333, Tailscale) │
└─────────────────────────────┘
5.2 Docker Compose Template (docker-compose.yml.template)
Critical: This file contains op:// references and must be processed with op inject before deployment.
version: "3"
networks:
gitea:
external: false
services:
server:
image: gitea/gitea:1.22.6
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
# Public URL settings
- GITEA__server__DOMAIN=git.terraphim.cloud
- GITEA__server__ROOT_URL=https://git.terraphim.cloud/
- GITEA__server__PROTOCOL=http
- GITEA__server__HTTP_PORT=3000
- GITEA__server__DISABLE_SSH=false
- GITEA__server__SSH_PORT=22
- GITEA__server__SSH_LISTEN_PORT=22
# Database
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=op://TerraphimPlatform/gitea-postgres/password
# Actions
- GITEA__actions__ENABLE=true
# LFS
- GITEA__repository__LFS_START_SERVER=true
- GITEA__repository__LFS_CONTENT_PATH=/data/lfs
# S3 storage (general)
- GITEA__storage__type=minio
- GITEA__storage__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key
- GITEA__storage__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key
- GITEA__storage__MINIO_BUCKET=gitea
- GITEA__storage__MINIO_LOCATION=us-east-1
- GITEA__storage__MINIO_ENDPOINT=http://s3storage:8333
- GITEA__storage__MINIO_INSECURE_SKIP_VERIFY=false
- GITEA__storage__MINIO_USE_SSL=false
- GITEA__storage__SERVE_DIRECT=true
# S3 storage for attachments
- GITEA__attachment__STORAGE_TYPE=minio
- GITEA__attachment__MINIO_ENDPOINT=s3storage:8333
- GITEA__attachment__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key
- GITEA__attachment__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key
- GITEA__attachment__MINIO_BUCKET=gitea
- GITEA__attachment__MINIO_USE_SSL=false
# S3 storage for avatars/pictures
- GITEA__picture__STORAGE_TYPE=minio
- GITEA__picture__MINIO_ENDPOINT=s3storage:8333
- GITEA__picture__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key
- GITEA__picture__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key
- GITEA__picture__MINIO_BUCKET=gitea
- GITEA__picture__MINIO_USE_SSL=false
# S3 storage for LFS
- GITEA__lfs__STORAGE_TYPE=minio
- GITEA__lfs__MINIO_ENDPOINT=s3storage:8333
- GITEA__lfs__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key
- GITEA__lfs__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key
- GITEA__lfs__MINIO_BUCKET=gitea-lfs
- GITEA__lfs__MINIO_USE_SSL=false
restart: always
networks:
- gitea
volumes:
- ./gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # HTTP (Caddy reverse proxy only)
- "222:22" # SSH (public)
depends_on:
- db
- s3
db:
image: postgres:15
restart: always
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=op://TerraphimPlatform/gitea-postgres/password
- POSTGRES_DB=gitea
networks:
- gitea
volumes:
- ./postgres:/var/lib/postgresql/data
master:
image: chrislusf/seaweedfs
command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9324"
networks:
- gitea
volume:
image: chrislusf/seaweedfs
command: 'volume -mserver="master:9333" -ip.bind=0.0.0.0 -port=8080 -metricsPort=9325'
depends_on:
- master
volumes:
- ./seaweedfs:/data
networks:
- gitea
filer:
image: chrislusf/seaweedfs
command: 'filer -master="master:9333" -ip.bind=0.0.0.0 -metricsPort=9326'
tty: true
stdin_open: true
depends_on:
- master
- volume
volumes:
- ./seaweedfs_filter:/data
networks:
- gitea
s3:
image: chrislusf/seaweedfs
container_name: s3storage
hostname: s3storage
ports:
# Bind to Tailscale interface only (not public internet)
- "100.106.66.7:8333:8333"
- "100.106.66.7:9327:9327"
command: 's3 -config=/etc/seaweedfs/s3.json -filer="filer:8888" -ip.bind=0.0.0.0 -metricsPort=9327'
depends_on:
- master
- volume
- filer
volumes:
- ./s3_config.json:/etc/seaweedfs/s3.json
networks:
- gitea
prometheus:
image: prom/prometheus:v2.21.0
ports:
# Bind to Tailscale interface only
- "100.106.66.7:9000:9090"
volumes:
- ./prometheus:/etc/prometheus
command: --web.enable-lifecycle --config.file=/etc/prometheus/prometheus.yml
depends_on:
- s3
networks:
- gitea
5.3 SeaweedFS S3 Configuration (s3_config.json.template)
{
"identities": [
{
"name": "anonymous",
"actions": ["Admin", "Read", "List", "Write", "Tagging"]
},
{
"name": "gitea",
"credentials": [
{
"accessKey": "op://TerraphimPlatform/gitea-s3/access-key",
"secretKey": "op://TerraphimPlatform/gitea-s3/secret-key"
}
],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
]
}
Process with:
op inject --in-file s3_config.json.template --out-file s3_config.json
5.4 Caddy Configuration
File: /etc/caddy/Caddyfile
git.terraphim.cloud {
reverse_proxy localhost:3000
log {
output file /var/log/caddy/gitea-access.log
format json
}
}
Reload Caddy:
sudo systemctl reload caddy
5.5 Deployment Steps
# 1. Navigate to infrastructure directory
cd /path/to/gitea-infrastructure
# 2. Load 1Password service account
source ~/op_zesticai_non_prod.sh
# 3. Inject secrets into configuration files
op inject --in-file docker-compose.yml.template --out-file docker-compose.yml
op inject --in-file s3_config.json.template --out-file s3_config.json
# 4. Start services
docker compose up -d
# 5. Verify services
docker compose ps
# Expected: gitea, db, master, volume, filer, s3storage, prometheus all "Up"
# 6. Verify Gitea responds
curl -s https://git.terraphim.cloud/api/v1/version | jq
# Expected: {"version": "1.22.6"}
6. Agent Skill: Shell Helpers
All helpers output JSON for agent parsing. All use 1Password for token retrieval.
6.1 Base API Helper
GITEA_URL="${GITEA_URL:-https://git.terraphim.cloud}"
GITEA_OWNER="${GITEA_OWNER:-terraphim}"
GITEA_REPO="${GITEA_REPO:-agent-tasks}"
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"
}
6.2 Label Swap Pattern (CRITICAL)
Problem: The Gitea UI enforces scoped label exclusivity (only one workflow:* label allowed per issue). But the API POST /repos/{owner}/{repo}/issues/{index}/labels endpoint just appends labels. If you POST workflow:in-progress to an issue that already has workflow:ready, you'll end up with BOTH labels, violating exclusivity.
Solution: Use PUT with full label replacement. Filter out all labels from the target scope, then add the new label.
gitea_swap_workflow() {
local index="$1" new_label="$2" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
local repo_labels issue_labels new_id kept_ids all_ids
# Get 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, filter out all workflow:* labels
issue_labels=$(gitea_api GET "/repos/$owner/$repo/issues/$index")
kept_ids=$(echo "$issue_labels" | jq '[.labels[] | select(.name | startswith("workflow:") | not) | .id]')
# Combine kept labels + new workflow label
all_ids=$(echo "$kept_ids" | jq --argjson nid "$new_id" '. + [$nid]')
# Replace all labels atomically (PUT, not POST)
gitea_api PUT "/repos/$owner/$repo/issues/$index/labels" "{\"labels\": $all_ids}"
}
6.3 Workflow Helpers
Mark task ready:
gitea_ready() {
local index="$1" owner="${2:-$GITEA_OWNER}" repo="${3:-$GITEA_REPO}"
gitea_swap_workflow "$index" "workflow:ready" "$owner" "$repo"
}
Claim task (mark in-progress, assign to user):
gitea_claim() {
local index="$1" user="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
gitea_swap_workflow "$index" "workflow:in-progress" "$owner" "$repo"
if [ -n "$user" ]; then
gitea_api PATCH "/repos/$owner/$repo/issues/$index" "{\"assignees\": [\"$user\"]}"
fi
}
Close task:
gitea_close_task() {
local index="$1" comment="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
gitea_swap_workflow "$index" "workflow:done" "$owner" "$repo"
gitea_api PATCH "/repos/$owner/$repo/issues/$index" '{"state": "closed"}'
if [ -n "$comment" ]; then
gitea_api POST "/repos/$owner/$repo/issues/$index/comments" \
"$(jq -n --arg b "$comment" '{body: $b}')"
fi
}
6.4 Project Management Helpers
Create project (milestone):
gitea_create_project() {
local title="$1" desc="${2:-}" due="${3:-}"
local owner="${4:-$GITEA_OWNER}" repo="${5:-$GITEA_REPO}"
local data
data=$(jq -n --arg t "$title" --arg d "$desc" --arg due "$due" \
'{title: $t, description: $d} + (if $due != "" then {due_on: $due} else {} end)')
gitea_api POST "/repos/$owner/$repo/milestones" "$data"
}
# Example:
gitea_create_project "Sprint 2026-W08" "Weekly sprint tasks" "2026-02-28T00:00:00Z"
Get project progress:
gitea_progress() {
local milestone="${1:-}" owner="${2:-$GITEA_OWNER}" repo="${3:-$GITEA_REPO}"
if [ -n "$milestone" ]; then
gitea_api GET "/repos/$owner/$repo/milestones/$milestone" | jq '{
milestone: .title,
open: .open_issues,
closed: .closed_issues,
total: (.open_issues + .closed_issues),
percent_complete: (if (.open_issues + .closed_issues) > 0
then (.closed_issues * 100 / (.open_issues + .closed_issues))
else 0 end),
due_on: .due_on,
state: .state
}'
else
gitea_api GET "/repos/$owner/$repo/milestones?state=open" | \
jq '[.[] | {id, title, open: .open_issues, closed: .closed_issues,
pct: (if (.open_issues + .closed_issues) > 0
then (.closed_issues * 100 / (.open_issues + .closed_issues))
else 0 end)}]'
fi
}
# Example:
gitea_progress 1 # Show progress for milestone ID 1
gitea_progress # List all open milestones with progress
Create task (issue):
gitea_create_task() {
local title="$1" body="$2" milestone="$3"
local labels="${4:-}" owner="${5:-$GITEA_OWNER}" repo="${6:-$GITEA_REPO}"
local data
data=$(jq -n --arg t "$title" --arg b "$body" --argjson m "$milestone" \
'{title: $t, body: $b, milestone: $m}')
if [ -n "$labels" ]; then
data=$(echo "$data" | jq --argjson l "[$labels]" '. + {labels: $l}')
fi
gitea_api POST "/repos/$owner/$repo/issues" "$data"
}
# Example:
# Create task in milestone 1 with workflow:ready (id=4) and type:task (id=14)
gitea_create_task "Implement auth" "Add OAuth2 flow" 1 "4,14"
7. The Three API Gotchas
These were discovered through live testing and cost significant debugging time.
7.1 Gotcha 1: Scoped Label Exclusivity is UI-Only
What we expected: POST to /repos/{owner}/{repo}/issues/{index}/labels with workflow:in-progress would replace workflow:ready automatically because both labels have exclusive: true and scope workflow.
What actually happens: The API just appends the label. The issue ends up with BOTH workflow:ready AND workflow:in-progress, violating the exclusivity constraint.
Why: Label exclusivity is enforced in the Gitea UI but NOT in the REST API. The API treats labels as a simple list.
Solution: Use PUT /repos/{owner}/{repo}/issues/{index}/labels with the full label set (see gitea_swap_workflow pattern in section 6.2).
Verified test:
# Issue initially has workflow:ready (id=4) and type:task (id=14)
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1" | \
jq '[.labels[].name]'
# ["workflow:ready", "type:task"]
# WRONG: POST appends workflow:in-progress (id=5)
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"labels": [5]}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/labels" | \
jq '[.labels[].name]'
# ["workflow:ready", "workflow:in-progress", "type:task"] <-- BOTH workflow labels!
# RIGHT: PUT replaces all labels
curl -s -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"labels": [5, 14]}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/labels" | \
jq '[.labels[].name]'
# ["workflow:in-progress", "type:task"] <-- workflow:ready removed
7.2 Gotcha 2: Assignee Endpoint Does Not Exist
What the design document specified:
gitea_api POST "/repos/$owner/$repo/issues/$index/assignees" \
"{\"assignees\": [\"$user\"]}"
What actually happens:
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"assignees": ["root"]}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/assignees"
# {"message":"Not Found","url":"https://git.terraphim.cloud/api/swagger"}
# HTTP 404
Why: Gitea 1.22.6 does not have a dedicated /assignees endpoint for issues. The GitHub API has this endpoint, but Gitea does not.
Solution: Use PATCH /repos/{owner}/{repo}/issues/{index} with the assignees field:
# CORRECT approach
curl -s -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"assignees": ["root"]}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1" | \
jq '.assignees[].login'
# "root"
Updated helper:
gitea_claim() {
local index="$1" user="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
gitea_swap_workflow "$index" "workflow:in-progress" "$owner" "$repo"
if [ -n "$user" ]; then
# Use PATCH on issue, not POST to /assignees
gitea_api PATCH "/repos/$owner/$repo/issues/$index" "{\"assignees\": [\"$user\"]}"
fi
}
7.3 Gotcha 3: Issue Dependencies Use IssueMeta Format
What we tried first (based on integer ID pattern from other APIs):
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"depends_on_id": 2}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies"
# {"message":"repository does not exist [id: 0]"}
Why this failed: The Gitea API uses IssueMeta format for cross-repo dependencies. The body must specify owner, repo, and index (issue number), not just an integer ID.
Correct format:
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"owner": "terraphim", "repo": "test", "index": 2}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies" | \
jq '{number, title}'
# {"number": 2, "title": "Setup authentication"}
This format supports cross-repository dependencies: issue 3 in terraphim/test can depend on issue 5 in terraphim/backend:
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"owner": "terraphim", "repo": "backend", "index": 5}' \
"https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies"
Helper function:
gitea_add_dependency() {
local issue="$1" depends_on_issue="$2"
local owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
local dep_owner="${5:-$owner}" dep_repo="${6:-$repo}"
gitea_api POST "/repos/$owner/$repo/issues/$issue/dependencies" \
"$(jq -n --arg o "$dep_owner" --arg r "$dep_repo" --argjson i "$depends_on_issue" \
'{owner: $o, repo: $r, index: $i}')"
}
# Example: Issue #3 depends on issue #2 (same repo)
gitea_add_dependency 3 2
# Example: Issue #3 in terraphim/frontend depends on issue #5 in terraphim/backend
gitea_add_dependency 3 5 terraphim frontend terraphim backend
8. Command Comparison Table
| Operation | bd (beads) | br (beads_rust) | Gitea |
|---|---|---|---|
| Find ready work | bd ready |
br ready |
GET /issues?labels=workflow:ready&state=open |
| Claim task | bd update ID --status in_progress |
br update ID --status in_progress |
gitea_claim ISSUE_NUMBER USERNAME |
| Complete task | bd close ID |
br close ID |
gitea_close_task ISSUE_NUMBER "Done" |
| Create task | bd create --title="..." --type=task |
br create "..." -t task |
gitea_create_task TITLE BODY MILESTONE_ID |
| Sync state | bd sync (auto-commits!) |
br sync --flush-only + git add + git commit |
N/A (state lives in Gitea server) |
| View progress | bd stats |
br stats |
gitea_progress MILESTONE_ID |
| Dependencies | bd dep add ID1 ID2 |
br dep add ID1 ID2 |
POST /issues/3/dependencies {"owner","repo","index":2} |
| Cross-repo visibility | None | None | Built-in (all repos on same instance) |
| Web UI | None | None | Built-in at git.terraphim.cloud |
| Time tracking | None | None | Built-in (stopwatch API) |
| Offline mode | Yes (local SQLite) | Yes (local SQLite) | No (requires network) |
9. What We Gained and Lost
9.1 Gained
Web UI for human visibility: No more "what are the agents doing?" Humans can visit https://git.terraphim.cloud/terraphim/agent-tasks/issues to see all tasks, filter by label, view progress, and read comments.
Cross-repository task visibility:
A single terraphim/agent-tasks repository serves as the central task tracker. All agents create issues here regardless of which code repository they're working in. br required separate .beads/ directories per repo with no coordination.
No binary installation:
All operations use curl and jq, which are already installed on most systems. No need to install br binary or keep it updated.
Built-in time tracking: Gitea has a stopwatch API for tracking time spent on tasks. Start/stop times are recorded per user.
Issue comments with markdown: Agents can post progress updates, link to commits, and collaborate via comments. Visible in the UI and accessible via API.
Issue dependencies with API support:
POST /issues/{index}/dependencies with IssueMeta format. Supports cross-repo dependencies (task in repo A can depend on task in repo B).
Milestone-based progress tracking:
Each milestone tracks open_issues and closed_issues counts. Percentage completion calculated automatically. Visible in UI with progress bar.
Labels visible in UI with color coding: Workflow state (ready/in-progress/blocked/done) immediately visible via label colors. Priority and type also color-coded.
Integration with existing git workflows:
Issues link to commits, pull requests, branches. Agents can close issues via commit messages (Fixes #42).
Audit trail: All issue updates logged with timestamps and user attribution. Who did what, when.
9.2 Lost
File reservations (Agent Mail feature): Agent Mail provided advisory locks to prevent concurrent file edits. Gitea has no equivalent. Workaround: use issue comments to declare intent ("Working on /src/auth.rs") or rely on git branch locking.
Agent-to-agent messaging:
Agent Mail had inbox/outbox for direct agent communication. Gitea replacement: use issue comments and @mentions to notify agents.
Graph-aware triage (bv's PageRank, betweenness centrality):
br had integration with bv tool for graph analysis of task dependencies. Gitea provides dependency tracking but no built-in graph algorithms. Would need external analysis tool querying Gitea API.
Local-first operation:
br worked without network access (SQLite + JSONL in .beads/). Gitea requires network connectivity to the git.terraphim.cloud instance.
Offline capability: br could create tasks, update status, and sync later when network available. Gitea requires real-time network access for all operations.
Automatic sync on git commit: bd (original beads) had git hooks that auto-synced task state on commit. Removed in br due to race conditions, not re-implemented in Gitea approach.
10. Integration Test Results
The test script test-gitea-skill.sh validates all operations against the live Gitea instance. No mocks. Creates a timestamped temporary repository, runs all tests, and cleans up.
10.1 Test Coverage
| Test # | Operation | Assertions | Status |
|---|---|---|---|
| 1 | Create test repository | 3 | PASS |
| 2 | Create labels (via setup-labels.sh) | 3 | PASS |
| 3 | Create milestone (project) | 2 | PASS |
| 4 | Create issue with milestone | 3 | PASS |
| 5 | Mark task ready | 1 | PASS |
| 6 | Claim task (in-progress + assignee) | 3 | PASS |
| 7 | Check progress (0% complete) | 3 | PASS |
| 8 | Close task (done + closed state) | 3 | PASS |
| 9 | Check progress (100% complete) | 3 | PASS |
| 10 | Delete test repository | 2 | PASS |
Total: 10 tests, 26 assertions, all passing.
10.2 Test Execution
# Prerequisites
source ~/op_zesticai_non_prod.sh
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
# Run tests
cd /path/to/gitea-infrastructure/.agents/skills/gitea
./test-gitea-skill.sh
Expected output:
============================================
Gitea Skill Integration Tests
============================================
=== Pre-flight Checks ===
Instance: https://git.terraphim.cloud
User: root
Test repo: root/skill-test-1708315200
--- 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 3: Create milestone (project) ---
[PASS] Milestone created with id=1
[PASS] Milestone has title
--- Test 4: Create issue (task) with milestone ---
[PASS] Issue created with number=#1
[PASS] Issue state is open
[PASS] Milestone assigned
--- Test 5: Mark task ready (workflow:ready) ---
[PASS] Issue has workflow:ready label
--- Test 6: Claim task (workflow:in-progress) ---
[PASS] Issue has workflow:in-progress label
[PASS] workflow:ready removed by label swap
[PASS] Assignee set
--- Test 7: Check project progress (0% complete) ---
[PASS] 1 open issue
[PASS] 0 closed issues
[PASS] Progress is 0%
--- Test 8: Close task ---
[PASS] Issue state is closed
[PASS] Issue has workflow:done label
[PASS] workflow:in-progress removed by label swap
--- Test 9: Check progress after close (100% complete) ---
[PASS] 0 open issues
[PASS] 1 closed issue
[PASS] Progress is 100%
--- Test 10: Delete test repository ---
[PASS] Repository deleted (HTTP 204)
[PASS] Repository confirmed deleted (HTTP 404)
============================================
Results: 26 passed, 0 failed (26 total)
============================================
Each test run creates a unique repository (e.g., skill-test-1708315200) to avoid conflicts with concurrent test runs.
10.3 Test Highlights
Test 2 (Create labels) validates:
setup-labels.shcreates exactly 12 labels- Labels have
exclusive: trueflag - Idempotent (re-running doesn't create duplicates)
Test 6 (Claim task) validates the label swap pattern:
- Sets
workflow:in-progresslabel - Removes
workflow:readylabel (exclusivity) - Sets assignee via PATCH (not POST /assignees)
Tests 7 and 9 (Progress tracking) validate:
- Milestone
open_issuescount updates when issue state changes - Milestone
closed_issuescount updates when issue closed - Percentage calculation:
(closed / (open + closed)) * 100
11. Conclusions
11.1 Consolidation Benefits
Replacing three tools (beads, br, mcp-agent-mail) with a single Gitea instance:
- Reduced operational complexity: One service to deploy, monitor, and maintain
- Human visibility: Web UI at git.terraphim.cloud shows all agent activity
- Cross-repo coordination: Single task repository for all agent work
- No installation required: All operations via curl and jq
- Better auditing: Full history of who did what, when, visible in UI and API
11.2 Tradeoffs
Gained centralization and visibility. Lost offline capability and graph analysis features. The tradeoff is acceptable for our use case because:
- Agents run on servers with reliable network connectivity
- Graph analysis (PageRank, betweenness) was rarely used
- File reservation feature replaced by issue comments declaring intent
- Web UI visibility outweighs local-first benefits
11.3 Implementation Complexity
Low. The entire migration required:
- 1 Docker Compose template (5.2)
- 1 SeaweedFS S3 config (5.3)
- 1 label setup script (3.3)
- 8 shell helper functions (6.2-6.4)
- 1 integration test script (10.1)
All commands verified working on 2026-02-18. No custom code, no plugins, no database migrations.
11.4 Reproducibility
This article is self-contained. Someone with:
- A Linux server
- Docker and Docker Compose
- 1Password CLI
- curl and jq
...can reproduce the entire setup by following sections 4 (1Password), 5 (Infrastructure), and 6 (Shell Helpers) in order.
11.5 Lessons Learned
API documentation is not always accurate. Gitea's Swagger docs show project board endpoints, but they return HTTP 404 in version 1.22.6. Always test against a live instance.
Label exclusivity is UI-only. Scoped labels with exclusive: true are enforced in the UI but not the API. Use PUT with full label replacement.
GitHub API compatibility is incomplete. Gitea aims for GitHub API compatibility but has gaps (e.g., no /issues/{index}/assignees endpoint). Test all endpoints.
1Password secret injection works well. The op inject pattern keeps secrets out of version control while maintaining reproducibility.
Appendix A: Quick Reference
A.1 Essential Environment Variables
source ~/op_zesticai_non_prod.sh
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
export GITEA_URL="https://git.terraphim.cloud"
export GITEA_OWNER="terraphim"
export GITEA_REPO="agent-tasks"
A.2 Common Operations
# List ready tasks
curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/issues?labels=workflow:ready&state=open" | \
jq '.[] | {number, title}'
# Claim task #5 as user root
gitea_claim 5 root
# Check progress for milestone #1
gitea_progress 1
# Close task #5 with comment
gitea_close_task 5 "Completed authentication module"
A.3 File Locations
| File | Path |
|---|---|
| Docker Compose template | /path/to/gitea-infrastructure/docker-compose.yml.template |
| S3 config template | /path/to/gitea-infrastructure/s3_config.json.template |
| Label setup script | /path/to/gitea-infrastructure/.agents/skills/gitea/setup-labels.sh |
| Integration tests | /path/to/gitea-infrastructure/.agents/skills/gitea/test-gitea-skill.sh |
| Agent skill document | /path/to/gitea-infrastructure/.agents/skills/gitea/SKILL.md |
| Caddy config | /etc/caddy/Caddyfile |
A.4 Service URLs
| Service | URL | Access |
|---|---|---|
| Gitea Web UI | https://git.terraphim.cloud | Public (HTTPS via Caddy) |
| Gitea SSH | git.terraphim.cloud:222 | Public |
| Gitea API | https://git.terraphim.cloud/api/v1 | Public (requires token) |
| SeaweedFS S3 | http://100.106.66.7:8333 | Tailscale only |
| Prometheus | http://100.106.66.7:9000 | Tailscale only |
A.5 1Password Items Reference
| Item Name | Vault | Field | Purpose |
|---|---|---|---|
| git.terraphim.cloud-admin-token | TerraphimPlatform | credential | Gitea API authentication |
| gitea-postgres | TerraphimPlatform | password | PostgreSQL database password |
| gitea-s3 | TerraphimPlatform | access-key | SeaweedFS S3 access key |
| gitea-s3 | TerraphimPlatform | secret-key | SeaweedFS S3 secret key |
Appendix B: API Endpoint Reference
All endpoints relative to https://git.terraphim.cloud/api/v1.
B.1 Authentication
# Verify token (returns user info)
GET /user
B.2 Milestones (Projects)
# Create milestone
POST /repos/{owner}/{repo}/milestones
Body: {"title": "Sprint 1", "description": "...", "due_on": "2026-03-01T00:00:00Z"}
# List milestones
GET /repos/{owner}/{repo}/milestones?state=open
# Get milestone
GET /repos/{owner}/{repo}/milestones/{id}
# Update milestone
PATCH /repos/{owner}/{repo}/milestones/{id}
Body: {"title": "...", "state": "closed"}
# Delete milestone
DELETE /repos/{owner}/{repo}/milestones/{id}
B.3 Issues (Tasks)
# Create issue
POST /repos/{owner}/{repo}/issues
Body: {"title": "...", "body": "...", "milestone": 1, "labels": [4, 14]}
# List issues
GET /repos/{owner}/{repo}/issues?state=open&labels=workflow:ready&milestone=Sprint+1
# Get issue
GET /repos/{owner}/{repo}/issues/{index}
# Update issue
PATCH /repos/{owner}/{repo}/issues/{index}
Body: {"state": "closed", "assignees": ["root"]}
# Add comment
POST /repos/{owner}/{repo}/issues/{index}/comments
Body: {"body": "Progress update..."}
B.4 Labels
# Create label
POST /repos/{owner}/{repo}/labels
Body: {"name": "workflow:ready", "color": "0e8a16", "description": "...", "exclusive": true}
# List labels
GET /repos/{owner}/{repo}/labels
# Replace issue labels (atomic swap)
PUT /repos/{owner}/{repo}/issues/{index}/labels
Body: {"labels": [4, 5, 14]}
B.5 Dependencies
# Add dependency (issue A depends on issue B)
POST /repos/{owner}/{repo}/issues/{index}/dependencies
Body: {"owner": "terraphim", "repo": "agent-tasks", "index": 2}
# List dependencies (what blocks this issue?)
GET /repos/{owner}/{repo}/issues/{index}/dependencies
# List blocks (what does this issue block?)
GET /repos/{owner}/{repo}/issues/{index}/blocks
# Remove dependency
DELETE /repos/{owner}/{repo}/issues/{index}/dependencies
Body: {"owner": "terraphim", "repo": "agent-tasks", "index": 2}
End of Article
All commands verified working as of 2026-02-18 against Gitea 1.22.6 running at git.terraphim.cloud.