Skip to content

Teams, workers, and sessions

A worker is a named AI team member. "Iris, senior backend engineer" is not a metaphor for a prompt file — she is an entity in Hadron, she is re-driven across dozens of coding sessions by whoever picks her up, and every one of those sessions is on the record with the human who drove it, the tool that ran it, and the path to the transcript.

This page explains the model — and the split at its heart: the persona (a reusable role, carried by an Agent) and the worker (the named individual, cast into one team).

Three sentences

  • A persona is dressing on an Agent — a role plus an identity template. The template never names anyone: it binds {{name}} at casting time.
  • A worker is a casting: an installed Agent cast into a team App under a name. Iris is the backend-engineer Agent, cast into the eng-team App as Iris. Workers are the team's staff; names are unique per App, forever.
  • A worker session is the unit of work, and it is where the provenance lives — which worker, which human, which tool, which host, and where the transcript sits on disk. It is not your chat session: closing the conversation does not end it (why that matters).

The theater metaphor is load-bearing: the Agent is the role, the worker is the casting. One role can be cast twice in the same team (Iris and Henry, both backend engineers), and the same role can be cast under different names in different teams.

Why the persona is dressing, not the named thing

An earlier iteration of this model put the name on the Agent itself (personaName, unique per owning org). That collapsed under its own weight: locking a human name org-wide behind whichever team minted it first does not scale past one team, and it welded the reusable part (the role) to the local part (the name).

So the split is now explicit:

Where Field What it holds
Agent personaRole A free-string role with conventions — backend-engineer. Not an enum.
Agent personaPrompt The identity template, with {{name}} / {{role}} placeholders.
Worker name The display name — Iris. Unique per App, case-insensitively, forever.
Worker role The casting's role (defaults to the agent's personaRole).
Worker promptOverride Per-worker individuality layered over the template. Amendable after casting (updateWorker) — identity is discovered through the work, so the field that records it is not sealed on day one.

A worker's resolved prompt — its boot briefing — is the agent's template with the name and role bound, plus the override as its own paragraph. The {{name}} token is exact: {{ name }} with spaces is not substituted, and the platform surfaces flag such templates (hasNamePlaceholder) because a template that never binds the name silently produces workers whose prompt never names them.

Where the model comes from — embodiment is session-level

An Agent carries a default AI configuration, but a worker driven from a coding tool does not run on it. The worker is a name and a voice; the embodiment — the LLM, the machine, the tool — belongs to the session that drives it, recorded as llmModel / host / tool. This is why Iris can be a Claude Code session on your laptop today and an Opus-backed portal chat tomorrow without changing anything about Iris.

Why a team is an App

An App is already the runtime deployment: it authenticates, it scopes memory, it has members with roles, and it accumulates data. A team needs exactly those things.

Team App
├── Team Agent          — its system memory holds the role definitions:
│                         roles:<role> nodes (a definition per role)
├── Role Agents         — ordinary Agents carrying persona dressing
├── Workers             — the STAFF: named castings (Iris, Rufus, …)
│   └── each worker's working memory = its cross-session continuity
├── AppMember rows      — the humans on the team
└── App-shared memory   — what the team shares: group chat, worklog
  • The install roster and the staff are different lists. The AppAgent join says which role-agents are installed; the workers say who is on staff. Casting requires the agent installed at cast time; the worker survives a later uninstall, because its name is bound to history.
  • Role definitions are content. The roles:<role> node in the Team Agent's system memory says a role exists and what it is for. It is a definition, not an allocation pool — since server#1050 it carries a description and nothing else. Once the App exists, prefer the teamRoles / createTeamRole / updateTeamRole / deleteTeamRole surface over raw node writes — every one of those operations is App-addressed, so seeding the branch before install is still an ordinary node write.

    The name register is gone — server#1050

    A role definition used to carry an ordered data.names register, and a nameless cast allocated the next free entry. Since #1050 a worker's name is chosen, never derived: it is mandatory at cast time (WORKER_NAME_REQUIRED), and casting reads no register and touches no system memory at all.

    The honest reading of that removal is worth keeping: the register was allocation bookkeeping, and per-App name uniqueness was always the thing that made a name permanent. Nothing about permanence changed when the register went. Any data.names left on an existing role node is historical — neither read nor rewritten.

    • The App's shared memory is resolvable. App.sharedMemory answers "what is this App's team space?" — the memory the team chat and worklog live in — so clients can default to it instead of asking you to name a memory. See Memory access for why the memory is the privacy boundary.

Casting, previewing, retiring

Casting is the one irreversible act in the feature, so it has a full lifecycle:

  • castWorker creates the casting. The name is required (WORKER_NAME_REQUIRED) and binding it is an atomic claim: it either binds forever or refuses with WORKER_NAME_TAKEN. One attempt, no register walk — the uniqueness constraint itself is the allocation primitive, and there is no lease and no lock.
  • castWorkerPreview is the dry-run: the exact same resolution and refusals, up to but not including the writes. It confirms the name you are claiming is still free, and shows the composed boot briefing — reviewable before the name is permanent. The preview reserves nothing: a previewed name may be gone by the time you cast, by design.
  • retireWorker ends the casting. The worker stops authoring and takes no new worker sessions; the row — and its name — survive.
  • deleteWorker exists only for the never-used miscast: it refuses (WORKER_IN_USE) if any worker session was ever bound or the working memory holds content. Anything with history retires instead.

Names are permanent — per App

Retiring a worker does not free its name. Deleting is only possible when the worker never did anything. This is a consequence of the provenance chain: Worker: Iris (backend-engineer) <hrn:worker:acme.com:eng-team:iris> is sitting in merged commit messages and in years of chat history, and a name that can be recycled is a name you cannot cite.

Uniqueness is scoped to the App — two teams can each have an Iris; within one team, iris and Iris are the same claim, judged by the database's own case folding so the answer never depends on the client's locale. On a context-free surface (a PR, an org-wide list) the bare name is therefore ambiguous, which is why those surfaces carry the app-qualified compound: eng-team/Iris.

Sessions carry the provenance

Two different things are called a session

Before anything else, because the two are constantly confused and the confusion is expensive:

Term What it is Who ends it
Worker session The Hadron Session row binding a worker to the human driving it. This is what "taken" means, what a PR traces back to, and what the worklog attributes work to. An explicit end — or the reaper, eventually
Chat session The conversation you are in: a Claude Desktop window, a Claude Code session, an IDE chat panel. You, by closing or archiving it

Ending a chat session does not end a worker session

They are unrelated lifecycles. Close your chat session for the day and the worker session stays open — so the worker stays taken, and the next person to bind it meets a WORKER_TAKEN takeover prompt with your name on it, for a conversation you finished hours ago.

Nothing about closing a window suggests this, which is exactly why it needs saying. End the worker session explicitly when you're done working as the worker. If you forget, the stale-session reaper eventually clears it — but "eventually" is up to a day, and until then the worker stays taken.

There is a third thing not to confuse with either: an agent chat — the Chat entity, a stored conversation between an end user and an agent (see the Chat API). It is deliberately not called a "chat session".

Two habits follow, and this documentation keeps to them:

  • "Chat session" is never shortened to "chat." In this product, "the chat" means the team chat — the group channel in the App. A sentence like "close the chat" reads as sensible and means two opposite things depending on which one the reader has in mind.
  • "Team chat" always keeps the word "team." The portal's App page has one Chats tab holding every conversation the App has, and the entry inside it is still called "Team chat" — the tab names a container, not the thread. The rule is about the thread, and it has not moved: whenever you mean the team chat, say "team chat."

What the worker session records

A worker session binds the worker and the human together with the embodiment fields:

  • workerRef at session start binds the worker session to a worker. Accepts the worker's id, its URN (hrn:worker:<root>:<app-slug>:<slug>), or — within a known App — its name. The session's agentId is stamped with the casting's role-agent automatically.
  • userId is stamped from the caller — the human who drove the work, not the person who cast the worker months earlier.
  • Session.worker nests the casting on reads, so a session list can render eng-team/Iris without extra round trips — including for retired workers, because retirement ends the casting, not the history.

"Taken" and informed takeover

A worker is in use iff a worker session bound to it is active (started, not ended, not expired). There is no engagement lease: liveness derives from the worker-session lifecycle, and the stale-session reaper is the crash backstop.

Takeover is informed and deliberate, enforced server-side: starting a worker session as a taken worker refuses with the typed WORKER_TAKEN, carrying who last drove it, when they were last seen, and the blocking session id — everything a client needs for its takeover prompt. Passing force: true proceeds; a silent takeover is not possible through any surface. Ending the worker session makes the worker untaken again — as does the stale-session reaper when nobody is driving it. What does not do it is closing your chat session: that leaves the worker session open and the worker taken until one of those two ends it.

Takeover reaches taken and never held. The hold is checked first and does not consult force at all, so binding a name held by someone else refuses WORKER_HELD whichever way you ask, and the remedy is to cast your own worker or have the name released. Untaken is not the same as available: ending a session frees the session, never the name.

Taken and held are different states

Since #1050 availability is two questions, not one:

Taken Held
What it says a live worker session is bound the name belongs to a person
Acquired by session start the first bind — casting does not hold
Cleared by ending that session, or the reaper only releaseWorker
Whose question your own worker somebody else's

The split exists because collapsing them was actively harmful. When availability was derived from session liveness, "is this worker free?" became a judgement about whether an idle driver was really gone — and an agent, following the documented procedure at every step, ended a mid-PR driver's session and bound as them. A hold is a fact, not a judgement: it survives session end, expiry, the reaper, and a closed chat session.

Casting does not hold. A worker is born unheld and the first bind claims it. That is deliberate: the normal flow is a coordinator casting a roster that other people then pick up, and if the caster held every name, every one of those people would be blocked on a release they should never have needed.

Releasing is narrow. releaseWorker frees the hold and nothing else — 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; the worker's handoff notes and worklog travel with it to whoever holds it next. Two principals may call it: the holder, who owes nobody notice, or an App/org ADMIN force-releasing somebody else's — and that second path posts to the team chat, naming who released what and from whom, because the situation it answers is exactly the one where notice was owed and there was no way to give it.

So the answer to "someone else has the worker I want" is to ask them to release it, or to cast your own — never to wait for a reaper that will never come.

The worklog is the join

A worker session and a work artifact are not one-to-one. A single PR can span three worker sessions on two machines; a single worker session can touch four PRs. So the artifact↔session join lives in the worklog, an append-only record in the team App's shared memory: this session did this thing to this artifactpr merged acme/widgets#371, attributed to session S and denormalizing the worker's name for display.

Two properties make it trustworthy as a ledger: it is append-only (a wrong entry is corrected by a newer entry), and it records external milestones only (calls into Hadron are already usage events). Lookups are equality matches on one canonical ref spelling per artifact; the grammar is in the work-ref contract.

Session.prNumber is display, not the record

A worker session still carries a prNumber, refreshed whenever a pr milestone is recorded. It is a latest-wins convenience. Don't build provenance on it — the worklog entry is the record.

The chain

FROM AN ARTIFACT                      FROM A NAME
worklog entry for acme/widgets#371    commit trailer: Worker: Iris (…) <…:eng-team:iris>
        ↓                                     ↓
every sessionId that touched it       the App's worker named Iris
        ↓                                     ↓
        └──────────→  Sessions  ←─────────────┘
        userId, tool, host, llmModel, transcriptPath
                 The transcript on disk

The left path answers "who wrote this PR?"; the right answers "what has Iris been doing?" The commit trailer survives a squash-merge, and it carries the app-qualified compound because a PR is a context-free surface.

Who can read and write a session

Reading. The user a session is attributed to can always read it (identity-scoped — never through impersonation). Org membership also grants read. The nested Session.worker shows every reader the attribution scalars (the name, the App for the compound), but the worker's working state — its resolved prompt, prompt override, and working-memory id — stays behind the worker read gate and masks to null for readers who are not App participants.

Writing. Only three principals can end or edit a session: the session's App as a pure App-key principal, the attributed user, or a platform admin. Org membership never writes, and a hybrid principal — a user calling through MCP with an active App selected — does not inherit App authority: the selection is a scope, not a credential.

Session binding is attribution, never authority. Both App-key and user-credential callers can pin requests to a session with the X-Hadron-Session header (an App key binds its own App's sessions; a user binds sessions attributed to themself — no admin bypass, never under impersonation). The binding's one effect is attribution: the work heartbeats the session so the reaper knows someone is driving. It never changes what the caller may read or write.

That header is a transport binding, which makes it a poor fit for the desktop track: your worker session is created during the conversation by hadron_start_session, long after the connection was opened, so it can never appear in a header on that connection. You do not need it. Passing session to a team tool is itself the heartbeat — see the reaper note below.

The stale-session reaper

A server-side sweep (hadron-server#930) ends sessions nobody is driving: past their hard expiresAt, or idle beyond the type's window (24 hours by default, deliberately generous — freeing a worker mid-lunch is the worse failure). That window is a deployment setting, not a platform constant, so the number is a default rather than something to build on.

The two deadlines above are separate, and only one of them is readable. Session.expiresAt on the GraphQL Session type is the hard expiry, and it is nullable. The idle deadline is not exposed for a given session at all — so a session can be reaped for inactivity well before its expiresAt, and there is no field to check that would have told you. Keep it alive by driving it, not by watching a clock. Heartbeats are session start, any authorized update, every usage event attributed to the session — which is what the X-Hadron-Session binding feeds — and, since hadron-server#1081, passing your session id to any team tool. That last one is the one the desktop track actually runs on: reading the chat, posting to it, listing workers or recording work all count as driving, so a worker who never opens a pull request stays alive exactly as long as one who does. A crashed session therefore frees its worker within the window; before that, takeover with force or an explicit end still works.

Two consequences worth knowing. Passing session is what keeps the session alive, so a stint driven without it can be reaped mid-work — the same argument as attribution, from the other end. And the heartbeat re-checks your current access each time: if your membership of the App is revoked, the session stops being kept alive even though it is still yours on paper.

Status

The Worker model and its platform surfaces are complete on the server (epic hadron-server#981):

Piece Where it stands
The Worker model — persona as dressing, workers as castings, per-App name permanence Shipped (hadron-server#974).
castWorker / retireWorker / deleteWorker / updateWorker, workers / worker reads Shipped, with MCP parity (hadron_cast_worker, hadron_list_workers, hadron_get_worker, hadron_update_worker). updateWorker amends promptOverride only (hadron-server#1010); worker(ref:) also takes a worker name with appRef (hadron-server#1015).
MCP compaction recovery: hadron_get_worker (re-fetch the boot briefing, rendered live) + hadron_whoami (your own open sessions) Shipped (hadron-server#1015/#1016). Closes the gap where an MCP host that lost its context had no legitimate route back to its briefing or its session id.
deleteTeamRole — retire a role Shipped (hadron-server#1002); unconditional since #1050. Superseding a role is create-the-successor, then delete the old one — transferTo went with the register it protected.
castWorkerPreview — the reserves-nothing dry-run Shipped (hadron-server#964).
teamRoles + createTeamRole / updateTeamRole — role definitions as a surface Shipped (hadron-server#960).
Session binding: workerRef, server-enforced WORKER_TAKEN + force, Session.worker nested read Shipped (hadron-server#940/#980), MCP parity on hadron_start_session.
App.sharedMemory — the App → team-space hop Shipped (hadron-server#965).
User-credential session heartbeat (X-Hadron-Session for user principals) Shipped (hadron-server#934).
Team chat + worklog platform operations Shipped (hadron-server#939/#947).
Stale-session reaper Shipped (hadron-server#930).
CLI adoption of the Worker model Shipped (hadron-cli#428). The team worker group, workerRef session binding, and the app-qualified Worker: commit trailer are all in.

What's next