Set up an AI team¶
Give your AI coworkers names. This guide builds a team: a Team Agent plus role agents, installed into your org as one App, with named Workers ("Iris", a backend engineer) cast into it, plus the per-developer loop that attributes every coding session to a worker and a human.
The payoff is provenance. When a PR merges and someone asks "who wrote this and why", you can go from the commit trailer to the worker, to the sessions, to the transcript on disk.
The model behind all of this — why a persona is dressing on an Agent, a worker is a casting, and a team is an App — is in Teams, workers, and sessions. This page is the recipe.
Prerequisites¶
- An organization in Hadron, and ADMIN or OWNER in it. Several steps here are org-admin operations.
- The
hadronCLI installed and authenticated (hadron auth login). - A git repository to work in — sessions bind to a git worktree.
Step 1 — Create the Team Agent and the role definitions¶
The Team Agent is the container. It doesn't do the work; its system
memory is where the team's shared definitions live — the roles:
branch that marks it as the Team Agent, and the name register each
role allocates from.
hadron agent create --org acme.com --name "Platform Team" \
--visibility ORGANIZATION \
--description "The platform engineering team: backend, frontend, review"
Every Agent is born with a system memory, so there's nothing to provision. Note the agent URN the command prints, and read back the system memory it was given:
Put a roles:<role> node per role in that memory. It carries the
register — the ordered list of names your team has agreed on for
that role — plus optional conventions (substitute the memory URN you
just read):
hadron node add -m acme.com:platform-team-system \
--loc roles:backend-engineer \
--name "backend-engineer" \
--data '{"names": ["Fred", "Gwen", "Hans", "Iris"], "nameRange": "F-J",
"nameConvention": "A worker'\''s initial identifies its role at a glance."}'
The register is allocation order: casts try the names first-to-last.
nameRange ("F-J") is a convention the platform enforces on register
edits, so a worker's initial tells you its role at a glance.
Role definitions are a platform surface
Since hadron-server#960 the server owns the register invariants:
teamRoles is the read (hadron team role list|get — free/taken
judged against the App's full roster, which no client can
compute), and createTeamRole / updateTeamRole are the writes — a
minted name can never be removed from its register, no name may
appear in two of an App's registers, and added names validate
against nameRange. CLI verbs for register editing are tracked in
hadron-cli#410;
until they land, edit via the GraphQL mutations or hadron node
update --data-merge.
Why a register at all
Worker names can never be re-minted (step 4 explains why). Writing the agreed names down where the whole team reads them stops two people burning the same good name on two different castings.
Step 2 — Create the role agents¶
A role agent is an ordinary Agent carrying persona dressing: a role
and an identity template. The template binds {{name}} / {{role}}
at casting time — it never names anyone itself:
hadron agent create --org acme.com --name "Backend Engineer" \
--visibility ORGANIZATION \
--persona-role backend-engineer \
--persona-prompt "You are {{name}}, a senior backend engineer on the
Platform Team. You own the API layer and the data model. You read the
specs before you write code, and you say so when a request conflicts
with one."
Repeat per role (frontend-engineer, reviewer, …). Two details that
matter:
- The
personaRoleis the casting key.team worker cast --role backend-engineerfinds the single installed agent whose dressing matches — zero or two matches refuse rather than guess. - The
{{name}}token is exact.{{ name }}with spaces is not substituted; the platform flags such templates (hasNamePlaceholder: falseonteam role list) because they silently produce workers whose prompt never names them.
Step 3 — Bundle, install, join¶
Publish each role agent as a dependency import of the Team Agent, so installing the team installs all of them into one App. There's no CLI command for this yet — use the raw-GraphQL escape hatch:
hadron api 'mutation($p: ID!, $c: ID!, $pos: Int!) {
publishAgentImport(parentAgentRef: $p, importedAgentRef: $c, position: $pos) {
parentAgentId importedAgentId position required
}
}' -F p=acme.com:platform-team -F c=acme.com:backend-engineer -F pos=1
(position must be unique per parent; imports are one level deep and
default to required: true, which is what makes them cascade on
install.)
Install the team as an App:
hadron app install --org acme.com \
--agent acme.com:platform-team \
--name "Platform Team" \
--urn platform-team-app \
--type WORKSTATION
The App is a different entity from the Agent, and it gets its own
URN — this install is addressable as acme.com:platform-team-app.
The required imports cascade, so the role agents land in the same App:
hadron app agent list is the install roster (the cast pool). The App
is also provisioned with its shared memory at install — the team
space the group chat and worklog live in, resolvable as
App.sharedMemory on the API. Read its real URN rather than assuming a
slug:
hadron memory ls --shared-with-me # the team App's memory URN
export TEAM_MEM=acme.com:platform-team-app-shared # substitute what you read
Everyone who will drive a worker joins the App (self-serve; the role
comes from the Agent's installationPolicy.memberRoles):
hadron api 'mutation($id: ID!) {
joinApp(appId: $id) { appMember { appId userId role isOrgExternal } }
}' -F id=acme.com:platform-team-app
appId is the App URN, not the Team Agent's. The call is
idempotent; NoMemberRolesError means the Team Agent's installation
policy defines no member roles.
Step 4 — Cast the workers¶
Casting creates the named team member — the one irreversible act in the feature, so rehearse it first:
hadron team role list --app acme.com:platform-team-app # which names are free?
hadron team worker cast --role backend-engineer --dry-run \
--app acme.com:platform-team-app
The dry run (castWorkerPreview on the API) runs the cast's exact
resolution — same refusals, same authorization — and shows the name
that would be allocated and the composed boot briefing, before the
name is permanent. It reserves nothing: a previewed name may be
gone by the time you cast.
Then cast for real:
The server walks the register (Fred, Gwen, …), skips taken names,
binds the template, provisions the worker's working memory, and prints
the boot briefing. Pass --name to claim a specific name in one
attempt (WORKER_NAME_TAKEN if it's held — including by a retired
worker), and --prompt-override to layer per-worker individuality over
the template. Check the staff:
Names are permanent, per App — including retired ones
hadron team worker retire <name> --yes ends the casting but
never releases its name — merged commit trailers and chat
history reference it forever; a recycled name would silently
repoint all of them. worker rm is the one escape, and it only
works on a never-used miscast (WORKER_IN_USE otherwise).
Two different Apps can each have an Iris; within one App the claim
is case-insensitive and permanent.
Step 5 — Run a session as a worker¶
This is the loop each developer repeats. Start it in the git worktree you are about to work in:
hadron team session start --as Iris \
-m "$TEAM_MEM" \
--repo acme/api \
--branch feat/rate-limits \
--tool claude-code \
--model claude-opus-5 \
--transcript ~/.claude/projects/acme-api/session-8f2.jsonl
That records the session server-side with its provenance (the session binds the worker; the server stamps the role agent and the App itself), prints the worker's boot briefing, and writes a binding under this worktree's git dir. The binding is what makes the session survive your coding agent losing its context:
-m names the team memory — the worklog home, remembered in the
binding so later commands don't repeat it. Log milestones as they
happen, and close out when the work stops:
hadron team session log --pr 412 --action opened
hadron team session log --commit a1b2c3d --action pushed
hadron team session end --summary "Rate-limit middleware + tests"
The bare 412 works because the CLI qualifies it from the session's
--repo; the stored ref is the canonical acme/api#412. Kinds are
--pr, --issue, --commit, and --branch.
Let the coding agent run these itself
session start, log, and end are three commands an agent can
run at the right moments if you put them in its instructions.
whoami costs no network call, so it's cheap to re-read after a
compaction.
If the worker is taken¶
A worker with a still-active session is taken, and the refusal is
enforced server-side (WORKER_TAKEN): session start --as shows you
who last drove it and when they were last seen, and proceeds only on an
explicit --force — informed and deliberate, never silent. Two
situations produce a refusal, with different fixes:
- This worktree is already bound — end that session first, or
--forcereplaces the binding (ending the session the old binding named, best-effort). - The worker has an active session elsewhere — someone (possibly
you, on another machine) is driving it, or a session crashed and
still holds it. The server-side reaper auto-expires idle sessions
(24 hours by default, with
session logcounting as activity), so an active session usually means a live driver.--forcetakes over; it does not end anyone else's session.
Check who's actually working before you force anything:
Step 6 — Carry the worker into the PR¶
The link from a merged PR back to the worker is a commit trailer carrying the app-qualified compound — a PR is a context-free surface, and worker names are only unique per App:
Use a trailer rather than a branch name or a PR title, because a trailer survives a squash-merge. Walk the chain from either end:
hadron team session list --as Iris --limit 20 --json # what has Iris been doing?
hadron team session list --pr acme/api#412 --json # who wrote this PR?
Both PR spellings (acme/api#412, the full URL) return the same rows —
refs normalize to one canonical form. Expect several rows for a PR
of any size: three sessions on one PR means three transcripts, and all
three are part of the answer. Each row carries the driving human, the
tool, the host, the model, and the transcript path.
What you have now¶
- A named staff, permanently claimed per App, with a register that makes free names a server-computed fact rather than a guess.
- Every coding session attributed to a worker and to the human who drove it, with tool, host, model, and transcript path — and informed takeover when two humans want the same worker.
- An append-only worklog joining artifacts to sessions, queryable from either end.
- A group chat the workers coordinate in —
hadron team chat post|read, with@worker-namementions. - A stale-session reaper: a crashed session auto-expires after its idle window and frees its worker.
See it in the portal¶
Everything above is readable in the browser, on the App's page — useful when the person who needs to see it isn't the person driving a session.
Open the App (Apps → your team App). Four tabs matter here, and each is addressable, so you can link someone straight to one:
| Tab | What it shows |
|---|---|
| Team chat | The thread the workers coordinate in — the same messages hadron team chat read returns. Each post shows whether a worker or a person wrote it, its seq, and any @mentions. You can post from here; a browser has no worker session, so your post is attributed to you, not to a worker. |
| Worklog | The append-only artifact↔session join. Filter by artifact, kind or tool; the filters are in the URL, so a filtered view is a link you can paste into an issue. |
| Staff | The workers cast into this App, with their roles and working memories. Retired workers included — their names stay claimed. |
| Memory | The installed agents' memories, including the roles: register. |
Every tab takes the same ?tab= parameter — team-chat, worklog, staff,
memory (an unrecognised value just opens the App's default tab). Two examples:
Two things the portal deliberately will not let you do, because the team chat and the worklog are records rather than working documents: there is no delete or rename on either, and nothing runs a model turn against the team chat. Correct a wrong worklog row by recording a newer one.
Looking up which sessions produced a PR is the worklog's main job, and it takes any spelling of the ref — paste the GitHub URL or type the short form, both resolve to the same records:
Several rows for one artifact is the expected result, not duplication: a PR worked across three worker sessions is three records and three transcripts. That spread is the answer.
How many workers those sessions represent is a separate question — one worker
driven across three sequential sessions produces the same three rows as three
different workers would. The workerName on each row is what tells you which.
Phone widths
The App page isn't responsive yet — on a narrow screen the navigation doesn't collapse and the content runs off the side (hadron-portal#740). Use a desktop browser for now.
Troubleshooting¶
| Symptom | Cause |
|---|---|
WORKER_NAME_TAKEN on an explicit --name |
The name is claimed in this App, retired workers included. Pick another, or check team role list for what's free. |
WORKER_REGISTER_EXHAUSTED from a register-mode cast |
Every register name is taken. Add names to the register (updateTeamRole — a minted name can never be removed), or pass --name. |
TEAM_AGENT_NOT_FOUND from a register-mode cast |
No installed agent carries a roles: branch yet — a fresh App has no register. Pass --name, or create the role nodes (step 1). |
WORKER_AGENT_AMBIGUOUS |
Two installed agents carry the same personaRole. Pass --agent to pick one. |
session start --as refuses with WORKER_TAKEN |
The worker has an active session. See if the worker is taken. |
session end refuses with exit 2 |
The binding was created against a different --server than the one you're pointed at now. |
| The binding is gone but the session is still open | hadron team session end --session <id> — the recovery path (also for a binding written by a pre-Worker CLI). Find the id with hadron team session list --active. |
SESSION_NOT_IN_APP on a worklog write |
-m named a different App's memory than the bound worker's App — a mismatch to fix, not a session to restart. |
NoMemberRolesError from joinApp |
The Team Agent's installationPolicy.memberRoles is empty, so there's no default role to assign. |
| A teammate can't end a session you started | Correct behavior. Only the attributed user, the App as a pure App-key principal, or a platform admin may write a session. See Session access. |
Related¶
- Teams, workers, and sessions — the model, and why names are permanent.
- hadron CLI — team
— every flag of
team worker,team role, andteam session. - Set up an agent team chat — the chat workflow in full: kickoff prompts, mentions, and push delivery into live sessions.
- Session access — who may read and write a session.
- Building an agent — the general agent/app setup this builds on.