Skip to content

Set up an AI team

CLIAPIAdvanced~30 min

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 hadron CLI 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:

hadron agent get acme.com:platform-team --json

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 personaRole is the casting key. team worker cast --role backend-engineer finds 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: false on team 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:

hadron team worker cast --role backend-engineer \
  --app acme.com:platform-team-app

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:

hadron team worker list --app acme.com:platform-team-app

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:

hadron team session whoami    # local read, no network

-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 --force replaces 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 log counting as activity), so an active session usually means a live driver. --force takes over; it does not end anyone else's session.

Check who's actually working before you force anything:

hadron team session list --active

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:

Add rate-limit middleware to the public API

Persona: platform-team-app/Iris

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-name mentions.
  • 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:

/app/apps/<appId>?tab=team-chat
/app/apps/<appId>?tab=worklog

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:

/app/apps/<appId>?tab=worklog&worklogRef=owner/repo%23371

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.