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-engineerAgent, cast into theeng-teamApp asIris. 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
AppAgentjoin 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 — sinceserver#1050it carries a description and nothing else. Once the App exists, prefer theteamRoles/createTeamRole/updateTeamRole/deleteTeamRolesurface 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.namesregister, and a nameless cast allocated the next free entry. Since#1050a 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.namesleft on an existing role node is historical — neither read nor rewritten.- The App's shared memory is resolvable.
App.sharedMemoryanswers "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.
- The App's shared memory is resolvable.
Casting, previewing, retiring¶
Casting is the one irreversible act in the feature, so it has a full lifecycle:
castWorkercreates the casting. The name is required (WORKER_NAME_REQUIRED) and binding it is an atomic claim: it either binds forever or refuses withWORKER_NAME_TAKEN. One attempt, no register walk — the uniqueness constraint itself is the allocation primitive, and there is no lease and no lock.castWorkerPreviewis 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.retireWorkerends the casting. The worker stops authoring and takes no new worker sessions; the row — and its name — survive.deleteWorkerexists 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:
workerRefat 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'sagentIdis stamped with the casting's role-agent automatically.userIdis stamped from the caller — the human who drove the work, not the person who cast the worker months earlier.Session.workernests the casting on reads, so a session list can rendereng-team/Iriswithout 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 artifact — pr 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¶
- Set up an AI team — build the team App, cast workers, and run an attributed coding session.
- Entity architecture — where Agents, Apps, Workers, and sessions sit relative to everything else.
- Set up an agent team chat — the chat workflow workers coordinate over.
- Authorization — the roles, grants, and guardrails the session rules sit inside.