ADR 0004 — `.team/` folder, first-run UX, and the management surface
- Status: accepted (implemented in v0.2.0)
- Date: 2026-04-25
- Author: Alireza
- Reviewers:
Context
Today, running teamctl in a real project means scaffolding a directory by
hand: team-compose.yaml, a projects/ tree, roles/*.md, an .env,
optional runtimes/ overrides, and a shadow state/ that the tool
generates. Examples in examples/ show the shape, but a first-time user
must read the docs, copy an example, and stitch it into their codebase.
Three friction points:
- No convention for “this repo has a teamctl team”. A reader looking at a repo can’t tell at a glance whether it has a team, where its files live, or what’s gitignored.
- First-time setup is a manual copy job. The
initstory does not exist yet; the docs say “copyexamples/hello-team”. - The management surface is thin.
teamctl statusis a 5-column table. Inspecting an agent’s mailbox, attaching to its tmux pane, tailing a single thread, switching between multiple teams on one host — none of these have first-class commands.
The goal of this ADR is to close those gaps with the same taste Docker and kubectl have: small, sharp, composable verbs that reward muscle memory and never require a wizard once you’ve done the thing once.
Decision
1. The .team/ convention
Every repository that wants a teamctl team carries a top-level .team/
directory, exactly the way every git repo carries a .git/. The layout:
my-repo/├── .team/│ ├── team-compose.yaml # global (broker, supervisor, hitl, ...)│ ├── projects/│ │ └── <id>.yaml│ ├── roles/│ │ └── <agent>.md│ ├── runtimes/ # optional overrides for the shipped runtimes│ │ └── codex.yaml│ ├── hooks/ # optional scripts referenced by rate-limit /│ │ └── on-publish.sh # HITL run-actions│ ├── .env # gitignored — secrets and chat-ids│ ├── .env.example # checked-in template│ ├── .gitignore # auto-generated; covers state/, .env│ └── state/ # gitignored — mailbox + rendered artifacts│ ├── mailbox.db│ ├── envs/│ ├── mcp/│ └── applied.json└── … rest of the repoDiscovery
teamctl walks up from the current working directory looking for the
nearest .team/team-compose.yaml. The first match wins. Identical to
git’s .git/ discovery; identical to Docker Compose’s behaviour with
modern compose files. Override with -C <path> or TEAMCTL_ROOT.
Why a dot-folder
- It signals “infrastructure” the same way
.git/does. A reader immediately understands this is operational state, not source. - One folder is easier to keep right than five. Today a teamctl
repo can scatter
runtimes/,roles/,projects/,team-compose.yamlacross the root. Consolidating under.team/makes ownership visible. - Tooling can ignore it by default. A docs-build or test-runner glob doesn’t accidentally pick up role prompts.
Backward compatibility
teamctl validate continues to accept a flat layout (today’s examples/
shape) so existing configurations don’t break. Discovery prefers .team/
when both are present; a WARN tells the user the flat layout is
deprecated and prints the exact git mv to migrate.
2. First-run UX
$ cd my-repo$ teamctl initinit is interactive but every prompt has a sane default and a
non-interactive equivalent. Flow:
- Pick a template:
solo— one manager, one dev, Claude Code only.multi-runtime— one manager + Codex worker + Gemini researcher.newsroom— head editor + writers + fact-checker (mirrorsexamples/newsletter-office/newsroom/).startup— founder + PM + eng-lead + IC + researcher.market-desk— chief + macro + equities + crypto + quant-risk.blank— emptyteam-compose.yaml+ an emptyprojects/main.yamlyou’ll fill in.
- Project id and human name — defaults derived from the repo name.
- Pick interfaces — CLI only (default), Telegram, both. Picking Telegram triggers the pairing flow in §4.
- Confirm preview — render the proposed
.team/tree, show diff, confirm. - Write files + run
teamctl validate+ (optionally)teamctl up.
Non-interactive equivalent for scripts:
teamctl init --template solo --interface cli --yesteamctl init --template newsroom --interface telegram --yes \ --telegram-token "$TG_TOKEN" --telegram-chat-id 12345678Either way, the result is a .team/ tree with a .env.example, a
.gitignore, and a one-line README.md inside .team/ pointing at the
docs.
3. Env vars and secrets
Single source of truth
.team/.env is the only place secrets live. It is gitignored. Auto-sourced
by teamctl up and every subcommand that needs it. A .env.example
checked in alongside lists every variable the compose tree references,
with placeholder values and one-line descriptions.
YAML never holds a secret
Every place in compose that “wants” a secret takes a *_env: field with
the env-var name, not the value:
interfaces: - type: telegram name: tg-main config: bot_token_env: TEAMCTL_TELEGRAM_TOKEN authorized_chat_ids_env: TEAMCTL_TELEGRAM_CHATSThe validator rejects raw token-shaped strings (AAH..., sk-...,
xoxb-..., etc.) in YAML with an error pointing at the line and
suggesting the *_env form.
teamctl env
$ teamctl envTEAMCTL_TELEGRAM_TOKEN set (****fk2A)TEAMCTL_TELEGRAM_CHATS set (12345678)PAGER_WEBHOOK UNSET used by rate_limits.hooks[pager]NEWSROOM_EMAIL_USER set (newsroom@example.com)NEWSROOM_EMAIL_PASS UNSET used by interfaces[head-editor-mail]Doctor mode:
$ teamctl env --doctor✗ TEAMCTL_TELEGRAM_CHATS is set to "0" — not a valid chat id✗ PAGER_WEBHOOK is unset and rate_limits.hooks[pager] is in default_on_hit2 issuesup invokes --doctor and refuses to start if anything fails.
4. Telegram bot pairing — safe by default
The riskiest setup step. We make it boring.
$ teamctl bot pair tg-main1. Open Telegram. Search for the bot you created via @BotFather. Paste its token here: token > 123456789:AAH-…
2. Now message the bot. Anything. e.g. "ping". Waiting … (60s timeout) ✓ Got message from chat 75473051 ("Alireza S.").
3. Confirm: bind tg-main to chat 75473051? [Y/n] y
✓ Wrote TEAMCTL_TELEGRAM_TOKEN and TEAMCTL_TELEGRAM_CHATS to .team/.env✓ Sent "teamctl bot paired" reply — confirm you received it. [Y/n] y✓ Done. Restart: teamctl restart botProperties:
- Token never typed in shell history.
bot pairreads token viaread -sstyle (terminal raw mode, no echo). - Chat id is auto-discovered from the user’s first message. No
manual lookup of
@userinfobot. - One-chat default. The pairing UI binds exactly one chat id. To
add more, edit
.team/.env. The bot adapter still rejects everyone else. - Verifies bidirectional. The bot must successfully reply to the user before pairing finalizes. If the chat is restricted, this fails loudly instead of silently.
- No group chats by default. The bot adapter rejects updates from
groups unless explicitly allowed via
allow_groups: true.
5. Inspection — Docker/kubectl-shaped verbs
| Command | Purpose |
|---|---|
teamctl ps | Wide table: project, agent, runtime, state, inbox depth, last-active, today USD. Replaces today’s status. |
teamctl ps -A | Across every .team/ registered as a context. |
teamctl ps <agent> | Single-row detail. |
teamctl top | Live-refreshing TUI of ps (think htop for agents). |
teamctl inspect <agent> | Full snapshot: rendered env, MCP config, role prompt path, last 20 messages, last 5 cost rows, today’s rate-limit hits. JSON via -o json. |
teamctl logs <agent> | Pane scrollback (last ~3000 lines). |
teamctl tail <agent> [-f] | Stream new messages addressed to / from this agent. -f follows. |
teamctl mail <agent> | Inbox table (id, sender, summary, age). |
teamctl mail <agent> --thread <id> | Thread view. |
teamctl mail --all | Every agent’s unread inbox depth + sample. |
teamctl history <agent> | Chronological message log; supports --since. |
teamctl approvals | Pending HITL requests (replaces teamctl pending). |
teamctl rate-limits | Recent hits, who, when, when-they-clear. |
teamctl bridge ls / log / open / close | (today’s; rename list → ls for consistency). |
teamctl budget | (today’s). |
Output: every list command supports -o json | yaml | wide
Same idiom as kubectl get -o. Defaults to a human-pretty table.
Filters
-l label=value for project filtering once we have a labels: map on
agents (out of scope for this ADR; flagged as a follow-up).
6. Attach and exec
teamctl attach <agent> # tmux attach (read-only by default)teamctl attach <agent> --rw # allow keyboard input — dangerous, off by defaultteamctl exec <agent> -- ls -la # run a command in the agent's CWD with its envteamctl shell <agent> # interactive shell in the agent's CWD with its envexec and shell are the difference between debugging and guessing.
A worker fails to find a file → teamctl shell dev1 and ls from
exactly the same place the agent is looking. Same env, same CWD.
attach defaults to read-only because muscle-memory typing in a tmux
pane is how 4 a.m. mistakes happen. --rw opt-in feels right.
7. Multiple teams on one machine — teamctl context
Borrowed from docker context and kubectl config. A “context” is a
named pointer to a .team/ root.
teamctl context ls # lists registered contextsteamctl context use newsroom # default context for subsequent commandsteamctl context add newsroom ~/work/newsroomteamctl context rm newsroomteamctl context currentteamctl up from inside a .team/-bearing repo registers that path as a
context automatically (named after the repo’s basename, deduped). The
CLI’s resolution order:
-C <path>flag.TEAMCTL_ROOTenv.TEAMCTL_CONTEXTenv (looks up the named context).- The current context as set by
teamctl context use. - Discovery from CWD.
8. Status badges in the prompt (optional, future)
Like kube-ps1 — teamctl status --short outputs (team:newsroom 4↑0!) so
shells can show “current context, agents up, pending approvals”. Not in
this ADR, but the design assumes someone will write it.
9. Errors that help
Every error tells you the next move.
✗ TEAMCTL_TELEGRAM_TOKEN is unset. Referenced from: .team/team-compose.yaml:42 (interfaces[tg-main]) Fix: teamctl bot pair tg-main Or: add TEAMCTL_TELEGRAM_TOKEN=… to .team/.env
✗ tmux session a-newsroom-head_editor not found. Fix: teamctl up Or, if up is failing: teamctl logs newsroom:head_editor (looks at last run)10. Naming — alignment pass
The current CLI grew organically. We rationalize on this rollout:
| Today | New | Reason |
|---|---|---|
teamctl status | teamctl ps | Docker familiarity. Keep status as alias. |
teamctl pending | teamctl approvals | Plural noun, consistent with bridges, rate-limits. Keep pending as alias. |
teamctl bridge list | teamctl bridge ls | ls reads faster. Keep list. |
teamctl send | teamctl mail send | Group send + read under mail. Keep send. |
No flags removed. No commands deleted. Aliases for the old shapes. A user with the old habit doesn’t have to retype anything. New users learn the new vocabulary.
Consequences
Wins
- A teamctl repo is visibly a teamctl repo, the way a git repo is visibly a git repo.
- First-time setup is one command (
teamctl init) plus optional bot pairing. No copy-paste of an example tree. - Inspection is verb-noun and predictable. New users can guess
commands; experienced users can pipe everything through
-o json. - Secrets have one home and one shape. Token-in-yaml is rejected at validate time, not in production.
Costs
- Migration noise. Every existing example needs
mv * .team/— easy, but it’s a churn commit. Mitigation: support both layouts during the deprecation window. teamctl initis a real piece of work — interactive prompting, template registry, jinja-ish substitution for project ids and chat names. Probably a quarter of the codebase by line count.- The
bot pairflow needs Telegram polling for ~60 s inteamctlitself, which means linking inteloxide(currently onlyteam-bothas it). Acceptable. attach --rwis a foot-gun even with the warning. We mitigate with a “type the agent name to confirm” prompt.
Anti-goals
- No daemon. No
teamctld. teamctl remains a stateless CLI over a SQLite mailbox + tmux. The moment we add a daemon, ops complexity doubles and we lose the “it’s just files” property. - No web UI in this ADR. A read-only TUI (
teamctl top) covers 80% of the dashboard need without a long-running process or auth surface. - No multi-tenant/RBAC. teamctl is for one human + their team(s). Multi-user is a different product.
Open questions
.team/vs.teamctl/?.teamis shorter and more humane;.teamctlis unambiguous. Lean.team. Override-able via a global config knobTEAMCTL_DIRNAME?bot pairas a teamctl subcommand or ateam-botsubcommand? Pairing fits with the bot, but users don’t think about which crate they’re calling. Lean:teamctl bot pair, withteamctlshelling out toteam-bot --pairunder the hood.- Init template registry — local-only or remote? Locally-shipped
templates are simple and reliable. A remote registry (
teamctl init --from github:Alireza29675/team-templates/newsroom) is much more powerful. Lean: ship a small set locally; document the remote path for v0.3. - Should
teamctl initadd a hook inpackage.json/Makefile/ the repo’s README to tell future readers how to start the team? Lean: yes, but only with--integrateopt-in. - Per-agent
labels:for filtering — kubectl-style. Out of scope here; flagged as a follow-up. teamctl logs --since 2h— consistent withkubectl logs? Probably yes. Easy to add once the message store has a time filter.- State directory: keep
.team/state/repo-local, or move to~/.local/share/teamctl/<context>/? Repo-local makes everything self-contained and trivially backup-able. Cross-context shells share nothing accidentally. Lean: repo-local stays.
Out of scope (intentionally)
- Multi-host orchestration.
- Web dashboard.
- Per-user RBAC, audit ACLs beyond what bridges + HITL already log.
- A formal plugin system. Hooks (
hooks/*.sh) cover the customization points we’ve actually needed so far.
Migration plan (when this is approved)
- Implement
.team/discovery alongside the flat layout. Both work;.team/preferred. - Build
teamctl initwith the template set above. - Move every example under
examples/*to a.team/-shaped layout. Update READMEs. - Implement
ps,mail,tail,inspect,attach,exec,shell,context,env [--doctor]. Preserve every existing command as an alias. - Implement
teamctl bot pairwith the polling flow. - Document the new shape in
docs/concepts/team-folder.mdand a “v0.2 migration guide”. - Cut v0.2.0. The flat-layout deprecation warning starts here.
- Remove flat-layout support in v1.0.
Estimated cost: ~7 days of focused work for a single implementer.
Review notes (please leave inline):
- [ ]
- [ ]
- [ ]