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 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 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 carries the name register (data.names, ordered — the allocation order) and register conventions (nameRange like F-J, so a worker's initial identifies its role at a glance). Since the teamRoles platform surface landed, you read and edit them through teamRoles / createTeamRole / updateTeamRole rather 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.sharedMemory answers "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:

  • castWorker creates the casting. Name allocation is an atomic claim: an explicit name either binds forever or refuses with WORKER_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.
  • castWorkerPreview is 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.
  • retireWorker ends the casting. The worker stops authoring and takes no new sessions; the row — and its name — survive.
  • deleteWorker exists 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:

  • workerRef at session start binds the session to a worker (by id — workers deliberately have no URN, so names never leak into addresses). 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 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 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 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