Skip to content

Set up an AI team

CLIAPIAdvanced~30 min

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

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

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 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.

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:

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

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

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

git worktree add -b feat/rate-limits ../api-iris

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:

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 --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 --force replaces the binding (ending the worker session the old binding named, best-effort). Note --force relabels 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. --force takes 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:

hadron team worker release Iris --app acme.com:platform-team-app

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:

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

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-retired the 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-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
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:

/app/apps/<appId>?tab=chats&chat=chats:team
/app/apps/<appId>?tab=worklog

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:

/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. 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.