Set up an AI team¶
The payoff is provenance. When a pull request merges and someone asks "who wrote this, and why", you can go from the commit trailer to the worker, to its sessions, to the transcript on disk. Getting that is what this page is for; naming your AI coworkers is how it is done, not why.
A team is 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 to the human who drove it.
You probably do not have to run any of this yourself
This page is the commands. Most people get a team by asking a coding agent for one — see Set up an AI team with your agent, which covers what to ask for and how to check each step worked. Come back here when you want to run it yourself, or to check what your agent did.
Read the model first if you have not. Teams, workers, and sessions explains why a persona is dressing on an Agent, why a worker is a casting, and why a name is permanent — and two of the steps below cannot be undone, so it is worth the ten minutes. 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 — worker sessions bind to a git worktree, and you need one worktree per worker (see Step 5).
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, and it is that branch which marks this Agent as the team's Team
Agent.
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. A role is a
definition, not an allocation pool: it says the role exists, what it is
for, and — optionally — which repositories it works in. It allocates
nothing (substitute the memory URN you just read):
hadron node add -m acme.com:platform-team-system \
--loc roles:backend-engineer \
--name "backend-engineer" \
--description "Owns the API layer and the data model"
The loc is a single atom — roles:<role>.
Keep that slug identical to the --persona-role you give the role agent in
Step 2, but understand what the match is:
casting does not read this node. The role string you pass at cast time
selects an installed agent by its personaRole directly, and a cast that
names its agent explicitly can use a role with no definition at all — cast
lists are ergonomics, never a gate. Keeping the two in step is a convention
that keeps the roster legible, not a lookup the platform performs.
There is no name register — names are chosen at cast time
Until server#1050 a role definition carried an ordered data.names
register, and a cast with no --name allocated the next free entry.
That is gone. Casting reads no system memory at all: you name the
worker yourself (Step 4), a nameless cast
refuses WORKER_NAME_REQUIRED, and per-App name uniqueness — never the
register — is what has always made a name permanent.
If you have an existing team, its data.names is left where it is:
the server neither reads it nor rewrites it, so it survives as a record
of what was allocated. You do not need to clean it up.
Agreeing names in advance is still worth doing — a name can never be re-minted (Step 4 explains why), so two people picking the same good one means one of them loses it. But that is now your team's convention to keep, not something the platform enforces.
Role definitions are a platform surface
Since hadron-server#960 the server owns this branch: teamRoles is the
read, and createTeamRole / updateTeamRole / deleteTeamRole are the
writes. Since #1050 the delete is unconditional, and the register
invariants, the expectedNames compare-and-swap and deleteTeamRole's
transferTo all went with the register they existed to protect.
Both write paths carry a description; updateTeamRole also carries
repos, the role's repository affinity (hadron-server#1024). Affinity is
an update-only field — createTeamRole deliberately does not take it, so
create-then-update is the path. Omitting repos preserves it, []
clears it, and an explicit null is treated as omitted rather than as
a clear. It is API-only today: the CLI has no flag for it yet
(hadron-cli#456).
Note the read needs the App, so it only answers once you have installed the team (Step 3):
hadron api 'query($a: ID!) {
teamRoles(appRef: $a) {
total
items { role loc description repos hasNamePlaceholder roleAgent { name } }
}
}' -F a=acme.com:platform-team-app
Setting it is the same escape hatch:
hadron api 'mutation($a: ID!, $r: String!, $repos: [String!]) {
updateTeamRole(appRef: $a, role: $r, repos: $repos) { role repos }
}' -F a=acme.com:platform-team-app -F r=backend-engineer \
-F repos='["acme/api","acme/schema"]'
Affinity is a soft signal, never a gate: it is what lets a client warn that a worker is about to work in a repo its role does not own. An empty list means "never warn" rather than "unconfigured", so a role with no affinity is a deliberate answer, not a gap.
hadron team role needs v0.10.0 or later
The CLI caught up with #1050 in
hadron-cli#496,
shipped in v0.10.0. On v0.9.0 or earlier, team role list and get
still ask the server for TeamRole.register, so both refuse with "the
server rejected a query this hadron build sends", and team role create
still requires the --names register it can no longer write.
Check with hadron version and upgrade. The GraphQL calls above are the
fallback if you cannot. Step 4 has the same
version floor for team worker cast.
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.
team worker cast needs v0.10.0 or later
On v0.9.0 or earlier both commands below fail. That cast still sends
teamAgentRef, which #1050 removed, so the dry run and the real cast both
refuse with "the server rejected a query this hadron build sends" — the
same hadron-cli#496
version floor as team role. Check with hadron version; the API calls
below are the fallback if you cannot upgrade.
Rehearse through the API:
hadron api 'query($a: ID!, $r: String!, $n: String!) {
castWorkerPreview(appRef: $a, role: $r, name: $n) {
name role agentName prompt hasNamePlaceholder
}
}' -F a=acme.com:platform-team-app -F r=backend-engineer -F n=Iris
Then cast for real:
hadron api 'mutation($a: ID!, $r: String!, $n: String!) {
castWorker(appRef: $a, role: $r, name: $n) { id name role prompt }
}' -F a=acme.com:platform-team-app -F r=backend-engineer -F n=Iris
Note the preview is a Query returning its own type, so it takes its own selection set rather than a Worker's fields. Everything the rest of this step says about names, refusals and permanence is server behaviour, and applies to either surface.
hadron team worker cast --role backend-engineer --name Iris --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 confirms the name
you are claiming is still free, alongside 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:
--name is required (WORKER_NAME_REQUIRED without it): a name is
permanent within the App, so it is chosen, never derived. The claim is
one attempt — WORKER_NAME_TAKEN if that name is already used, including
by a retired worker, since retirement never frees a name. The server
then binds the template, provisions the worker's working memory, and
prints the boot briefing. Add --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 worker session¶
This is the loop each developer repeats.
One git worktree per worker
Before the first session start, get the layout right — this is a setup
decision, and by the time it announces itself you have already arranged your
machine the wrong way.
Never run two workers, or a worker and an unbound agent, in the same checkout.
One checkout means one index and one working tree. Two agents in it are
editing shared mutable state, and neither can see the other: whichever
commits with git add -A or git commit -a silently absorbs the other's
in-flight edits into its own commit. Nothing in git status distinguishes
"my edit" from "somebody else's edit made ninety seconds ago", and no lock or
warning exists.
Give each worker its own worktree:
Separate index, separate working tree, shared object store — the sessions stop being able to collide. The CLI already stores the binding under the worktree's resolved git dir, so linked worktrees each get their own; the tooling supports this today, it just doesn't teach it.
Why this matters more than tidiness: binding a worker exists so a merged PR traces back to the session that produced it. A swept file lands in the wrong PR, under the wrong worker, and severs that link with no signal — the one thing the provenance chain is for.
What it looks like when it happens, so you recognise it instead of filing
a data-loss report: files vanishing from git status mid-task, and edits you
did not make appearing in your diff. Nothing is usually lost — check
git log origin/main and git log -S '<a phrase from your edit>' before
concluding otherwise.
If you must share a checkout, never git add -A / git commit -a — stage
the paths you actually touched, by name — and re-run git status
immediately before committing, not once at the start.
Start the worker session 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 worker 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 worker session survive your coding agent losing its context — or your chat session ending:
-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 --handoff-file ./handoff.md
--handoff is the one that matters, and it is not --summary. A handoff is
the continuity record the next driver actually reads: what landed, what is
open, what is blocked, what not to repeat. The server files it in the worker's
own memory and hands it back at the next bind, unasked — and because handoffs
follow the name, the next driver may be a colleague rather than you.
--summary is a different field and the next driver never sees it: a
display-only label on the session row. If you are writing one thing for whoever
comes next, write --handoff.
hadron team session end --handoff "PR #412 open awaiting review; do not re-run the migration."
hadron team session end --handoff-file ./handoff.md # a paragraph, without shell quoting
hadron team session end --handoff - # from stdin
Omitting it entirely is the normal way to end without a record. An explicitly
empty handoff is refused (exit 2) — somebody meant to write something. The
write happens before the session ends, and a failed write refuses the
end (HANDOFF_WRITE_FAILED) rather than ending anyway: a still-bound worker
is recoverable, an ended session whose handoff evaporated is not.
--handoff needs v0.11.0 or later
It shipped in v0.11.0
(hadron-cli#505).
On v0.10.0 and earlier session end has only --summary, so the CLI
track cannot write a continuity record at all and the next driver of that
worker inherits nothing. Check with hadron version and upgrade.
If you are stuck on an older CLI, leave what the next driver needs where
they will find it — a worklog record (session log) or a team-chat post.
The MCP track is unaffected: hadron_end_session has taken a handoff
argument since hadron-server#1029.
session end clears taken — it does not release the name
Closing your editor, archiving your chat session, or letting the coding
agent stop does not end the worker session. The worker stays taken, and
the next person to pick it up gets a WORKER_TAKEN prompt with your name on
it for work you finished yesterday.
And ending the session is not the same as giving the worker up. Since
server#1050 the name is held by you until an explicit
releaseWorker — a hold that survives session end, expiry and the reaper.
If a colleague wants to drive Iris, either you release her or they cast
their own worker; "wait for the reaper" is not the answer it used to be. The stale-session
reaper
clears it after about a day; until then the worker stays taken. See the two kinds of
session.
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 unavailable — taken, or held¶
Two different things stop you binding a worker, and since server#1050
they have different answers. Getting them confused is what once let an
agent end a mid-PR driver's session and bind as them, following the
documented procedure at every step.
| Taken | Held | |
|---|---|---|
| Means | a live worker session is bound | the name belongs to a person |
| Set by | session start |
the first bind — casting does not hold |
| Cleared by | session end, or the reaper after the idle window |
only an explicit release |
| Your move | it is a question about your own worker | ask the holder, or cast your own |
Held is the one people get wrong. A hold survives session end, expiry and the reaper — that is the point of it. "Wait for the reaper and it'll free up" is true of taken and false of held, so if the worker you want belongs to someone else, waiting achieves nothing.
Casting does not create a hold. A worker is born unheld and the first bind claims it — which is what makes the normal flow work, where a coordinator casts a roster that other people then pick up. If the caster held every name, everyone would be blocked on a release they should never have needed.
For a taken worker, session start --as refuses with WORKER_TAKEN,
shows 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 that refusal, with different fixes:
- This worktree is already bound — end that worker session first, or
--forcereplaces the binding (ending the worker session the old binding named, best-effort). Note--forcerelabels the binding; it does not separate two agents sharing one checkout — for that you need a worktree each. - The worker has an active session elsewhere — someone (possibly
you, on another machine) is driving it, or a session crashed without
ending and is still bound to it. The reaper auto-expires idle worker sessions
(24 hours by default — a deployment setting, not a platform constant), and
any team tool or command that drives the session counts as activity, not
just
session log: reading or posting to the chat, listing workers and recording work all keep it alive (the stale-session reaper). So an active worker session usually means a live driver — or someone who closed their chat session without ending it.--forcetakes over; it does not end anyone else's session.
--force reaches taken and never held — this is enforced, not etiquette.
The hold is checked before the flag is even consulted, so session start --as
on a name held by somebody else refuses WORKER_HELD (exit 5) whether or not
--force rides along, and says so: "a held name is not freed by a worker
session ending, and this cannot be forced — cast your own worker instead."
The refusal names the holder, so you know who to ask.
It refuses every principal but the holder, including an App key — an App
key is not a person and holds nothing, so it is never the right one. Reach for
--force when the stale binding is yours; for somebody else's worker the
answer is a release or a worker of your own, and the platform will not let it
be anything else.
Releasing a name¶
A held name is freed by a release — and by nothing else:
worker get shows who holds a name. The line is omitted rather than dashed
when there is no visible hold, and there is deliberately no held boolean: a
hold you cannot see and a name nobody holds look identical from outside, and a
boolean would answer "no" to a reader who merely lacks visibility.
Needs v0.10.0; no MCP tool yet
hadron team worker release shipped in v0.10.0
(hadron-cli#495).
On an earlier CLI, or from an MCP-only host — where there is still no
release tool (hadron-server#1060) —
go through the API:
hadron api 'mutation($w: ID!, $h: ID!) {
releaseWorker(workerRef: $w, expectedHolderUserId: $h) { name heldByUserId }
}' -F w=<worker-id-or-urn> -F h=<the-holder-you-read>
Pass expectedHolderUserId. It asserts the hold you are ending is the
one you looked at, so a hold claimed between your read and your write
refuses WORKER_HOLD_STALE instead of being force-released silently —
which is the outcome this whole section is about. Use
expectUnheld: true when you believe nobody holds it. Omitting both is
still accepted and still races.
Two principals may call it, and they are different acts:
- The holder, releasing their own name. They owe nobody notice.
- An App or org ADMIN, force-releasing somebody else's — the path that
exists so a departed colleague's names are not held forever. An admin
release posts to the team chat, naming who released what and from
whom, because the situation it answers is precisely the one where notice
was owed and there was no way to give it. The CLI prompts before this one
unless you pass
--yes. The chat post is best-effort server-side: an unreachable chat never blocks the release, so the receipt says a notice was posted rather than proving one arrived.
Releasing is narrow, and deliberately so. It does not retire the worker, does not free the name for a different casting, and does not touch history — the name stays permanently allocated to this casting, and the worker's working memory and handoff notes travel with the name to whoever holds it next. That transfer is the point — and the reason nothing private belongs in a worker's memory.
It is idempotent, but it will not tell you a name was free. A hold you may not
see and no hold at all are indistinguishable from outside, so releasing reports
no visible hold rather than "unheld" — do not read that as permission, or
you will meet WORKER_HELD at the next session start.
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:
Add rate-limit middleware to the public API
Worker: Iris (backend-engineer) <hrn:worker:acme.com:platform-team-app:iris>
Use a trailer rather than a branch name or a PR title, because a
trailer survives a squash-merge. Worker: follows git's Token: Value
convention, mirroring Co-Authored-By, so git interpret-trailers
reads it — provided it sits in the same trailing block as your other
trailers. A blank line between them puts Worker: in an ordinary
paragraph and git stops seeing it; git interpret-trailers --parse on
your own message is the check.
This replaces the older Persona: <app>/<Name> form
That spelling named a concept that was renamed — a persona is dressing on an Agent; the named identity is a Worker — and it omitted the org root, so two orgs with an App of the same slug produced identical trailers for different people. Trailers already merged are history and are not rewritten. 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 — every name chosen at cast
time, and
team worker list --include-retiredthe roster of what is already gone. - 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 |
|---|---|
| Chats | Every conversation the App has, behind one selector: the team chat, the agent chat, and any 1:1 chats. The team chat is the thread the workers coordinate in — the same messages hadron team chat read returns, each post showing whether a worker or a person wrote it, its seq, and any @mentions. You can post to the team chat from here; a browser has no worker session, so your post is attributed to you, not to a worker. Other chats are read-only in the portal. |
| 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. |
| Worker agents | The workers cast into this App, with their roles and working memories. Retired workers included — their names stay claimed. Its ?tab= value is still staff — see below. |
| Memory | The installed agents' memories, including the Team Agent's roles: definitions. |
Every tab takes the same ?tab= parameter — chats, worklog, staff,
memory, settings (an unrecognised value just opens the App's default tab).
The Chats tab takes a second parameter, ?chat=, so you can link to one
conversation rather than to the tab:
The older ?tab=team-chat and ?tab=chat links still work — they open the
Chats tab with that conversation selected — so anything already pasted into an
issue keeps resolving.
The Worker agents tab is still ?tab=staff
The label changed; the value deliberately did not. A tab's ?tab= value is
an address — those links are shared in issues and messages, and since
they now survive the URN resolver there are more of them in circulation, not
fewer. Renaming the value would break every one of them, and it would break
them silently: an unrecognised ?tab= just opens the default tab, so the
page still renders and the reader concludes the feature moved.
So a mismatch between what a tab is called and what its parameter says is the intended state here, not a typo in this table.
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. Pick another; team worker list --include-retired is the roster of what's gone — without that flag a retired worker holding the name doesn't appear, and the refusal looks unexplained. |
WORKER_NAME_REQUIRED from a cast |
A name is never derived — pass one. There is no register to fall back to (step 1). |
"the server rejected a query this hadron build sends" from team role or team worker cast |
The CLI/server skew from #1050, not a mistake of yours. Use the GraphQL calls in step 1 and step 4 if you cannot upgrade to hadron v0.10.0 or later, which is where hadron-cli#496 shipped (hadron version). |
WORKER_AGENT_AMBIGUOUS |
Two installed agents carry the same personaRole. Pass --agent to pick one. |
WORKER_AGENT_NOT_INSTALLED from an explicit --agent |
The agent exists but is not on this App's roster, and casting requires it installed at cast time. Add it to the existing App — hadron app agent add <app> <agent> — then cast; you do not need to rebuild anything. Installing takes a narrower permission than casting, so if you are a plain App member you can hit this and be unable to clear it yourself — ask the App's owner or an org CONTRIBUTOR+ to install it. Distinct from WORKER_AGENT_NOT_FOUND, which means no installed agent matched the --role you gave. |
SESSION_NOT_WORKER_BOUND |
The session was started without a worker, and you are doing something only a worker can do — writing a handoff at session end, or posting to the team chat as a worker. The work is not lost: end without a handoff, or post as yourself. Bind next time with --as <worker>. |
session start --as refuses with WORKER_TAKEN |
The worker has an active worker session. See if the worker is unavailable. |
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.
- Recover after a context compaction — for teammates driven from Claude Desktop, where the session id and briefing live only in the conversation.
- Session access — who may read and write a session.
- Building an agent — the general agent/app setup this builds on.