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 systemPrompt The prompt template, with {{name}} / {{role}} placeholders. One field serves the agent's own chats and every worker's briefing.
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.

Until server#1438, the template was a separate personaPrompt field beside the agent's systemPrompt. There is now only systemPrompt.

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 — and nothing else
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, nothing cleans up after you. A worker session has no expiry, and going quiet does not end one (hadron-server#1114). The takeover prompt does stop: once the idle window passes the worker stops reading as live, so WORKER_TAKEN no longer fires. Be careful what you read into that — it clears the taken gate and nothing else. The name stays held by you, so a colleague still meets WORKER_HELD however long they wait, and force does not reach it. What you get back is your own ability to re-bind without a takeover prompt.

The session, meanwhile, stays open indefinitely with its handoff unwritten, and that is the part nobody gets back — see when a session goes quiet.

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 live — not ended, and driven inside its type's idle window. There is no engagement lease, and note that an open session is not automatically a live one: since hadron-server#1114 liveness is derived at read time rather than expressed by ending anything, so a session nobody has touched for a day stops blocking a bind while remaining open.

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. Two things make the worker untaken again: ending the worker session, or simply nobody driving it for the length of the idle window. What does not do it is closing your chat session — that leaves the worker session open, and until the window passes the worker taken.

So a WORKER_TAKEN refusal is now stronger evidence than it used to be. It no longer fires on a session somebody merely forgot to close; it fires on one that has been driven recently. That is worth weighing before you reach for force.

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 idle window passing 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, going idle, 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 it out. A hold has no timeout, and nothing on the platform expires one.

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, which is what makes it read as being driven. 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 note below.

When a session goes quiet

Going quiet no longer ends a session (hadron-server#1114). Silence is not evidence of abandonment — an agent you use once a year is not abandoned in month two — so the platform stopped inferring one from the other. What a quiet session loses is not its life but its liveness: how it is described.

Worker.hasLiveSession is derived at read time, never stored. It is two conjuncts: the session has not ended, and it was driven inside its type's idle window (24 hours by default). That window is a deployment setting, not a platform constant, so the number is a default rather than something to build on. Nothing is written to express the answer, so a session that goes quiet for a year and comes back is simply described as idle in the meantime, and is still there when its driver returns.

Neither deadline is readable per session. Session.expiresAt on the GraphQL Session type is the hard expiry and is nullable — null for anything startSession created. The idle window is not exposed for a given session at all, so there is no field that would tell you when your worker stops reading as live. Keep it live 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 reads as live exactly as long as one who does.

Ending is an act, never an inference. Two things end a session: an explicit end, or a hard expiresAt promised at session start. The reaper (hadron-server#930) is still there and still runs, but since #1114 hard expiry is the only rule it acts on. And startSession deliberately leaves expiresAt null — so for a worker session, nothing but an explicit end will ever close it. (Chatbot sessions differ: startAgentSession stamps the expiry from the App's sessionTimeoutSeconds, which is a promise made up front rather than a guess about silence.)

What this costs you if you forget. Less recovers than you might hope. Once the idle window passes, hasLiveSession goes false, and that is the same predicate session start refuses WORKER_TAKEN on — so the takeover prompt stops. That is the whole of what changes. Since #1050 that gate only ever guarded a name already yours, so what you recover is the ability to re-bind yourself without force. The name does not free itself: it stays held until you release it, and nobody else gets past WORKER_HELD by waiting. And the handoff does not recover. The session stays open forever with its closing note unwritten, and the next driver is told the stint ended without one — shown the previous handoff as history rather than as the current state of play. So they are not misled; they are simply missing the stint that mattered most. That asymmetry is the whole reason to end deliberately, with a handoff: what you lose by forgetting is invisible from the outside, because the outcome that is visible — the takeover prompt going quiet — looks the same either way.

Two consequences worth knowing. Passing session is what keeps a session reading as live, so a stint driven without it can look abandoned 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).
Session reaper Shipped (hadron-server#930), then narrowed to hard expiry alone (hadron-server#1114). It no longer ends a session for inactivity; liveness is derived at read time instead, so a quiet session is described as idle rather than closed. startSession sets no expiry, so nothing auto-ends a worker session.
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