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 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.
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 (name register + conventions)
├── 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 carries the name register (data.names, ordered — the allocation order) and register conventions (nameRangelikeF-J, so a worker's initial identifies its role at a glance). Since theteamRolesplatform surface landed, you read and edit them throughteamRoles/createTeamRole/updateTeamRolerather than raw node writes — the surface owns the invariants (a minted name can never be removed from its register, no name in two of an App's registers, range validation) and, crucially, computes which names are still free against the App's full roster, which no client can see in its entirety. - The App's shared memory is resolvable.
App.sharedMemoryanswers "what is this App's team space?" — the memory the 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:
castWorkercreates the casting. Name allocation is an atomic claim: an explicit name either binds forever or refuses withWORKER_NAME_TAKEN; with no explicit name the role's register is walked first-to-last, skipping taken names. The uniqueness constraint itself is the allocation primitive — 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 shows the name that would be allocated and 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 sessions; the row — and its name — survive.deleteWorkerexists only for the never-used miscast: it refuses (WORKER_IN_USE) if any 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: Persona: 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¶
A session binds the worker and the human together with the embodiment fields:
workerRefat session start binds the session to a worker (by id — workers deliberately have no URN, so names never leak into addresses). 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 session bound to it is active (started, not ended, not expired). There is no engagement lease: liveness derives from the session lifecycle, and the stale-session reaper is the crash backstop.
Takeover is informed and deliberate, enforced server-side: starting
a 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 a session frees the worker.
The worklog is the join¶
A session and a work artifact are not one-to-one. A single PR can span
three sessions on two machines; a single 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 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: Persona: 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.
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).
Heartbeats are session start, any authorized update, and every
usage event attributed to the session — which is what the
X-Hadron-Session binding feeds. A crashed session therefore frees
its worker within the window; before that, takeover with force
or an explicit end still works.
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, or supersede it in one step with transferTo |
Shipped (hadron-server#1002). |
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 | Landing in slices (hadron-cli#428; team worker shipped in hadron-cli#431). |
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.