Humans in the Org Chart
A Role in Crewlet is a seat in the org chart, held by either an AI agent (the default) or a human teammate. Human seats participate in the full hierarchy (they can manage agents, lead units, appear in rosters, and be escalation targets), but they are never run: no seat lease, no mailbox, no LLM, no learning rows of their own.
The design follows one observation: agents already collaborate through human-native surfaces (Slack, Jira, Confluence, GitHub). A human teammate doesn’t need an engine runtime — they need to exist in the model so agents know who they are, how to reach them, and what to expect when they do. Agents reach humans exactly as they reach each other: their own colleague-surface tools, with an @-mention. The engine never sends as itself — there is no system bot, no engine→human notification channel.
Declaring a Human Seat
Section titled “Declaring a Human Seat”units: - name: Core Engineering type: team lead: Sarah Chen # a human can lead an AI team roles: - name: Sarah Chen kind: human goal: "Keep the team unblocked and own final calls" backstory: "20 years in infrastructure" responsibilities: - "Approvals and vendor decisions" contact: # how agents mention & reach her slack_user_id: U0123456789 mattermost_user_id: sarah.chen # Mattermost username, not an ID atlassian_account_id: 5b10ac8d-... # one ID covers Jira + Confluence github_login: sarahchen gitlab_username: sarahchen crewlet_operator_id: sarah # her api.auth.tokens[] id (Tier A) availability: "CET business hours; replies within ~4h" - name: Engineer # AI agent, unchanged goal: "Implement features and ship quality code"Human seat fields
Section titled “Human seat fields”| Field | Required | Description |
|---|---|---|
kind: human | yes | Marks the seat as human |
contact.slack_user_id | one identity | Slack member ID (U…) — <@…> mentions and the channel an agent DMs on escalation |
contact.mattermost_user_id | one identity | Mattermost username: the name an agent writes as a literal @username mention, and the account it opens a DM channel with. Not the opaque 26-character user ID. Stored as written, so write it in the case Mattermost shows |
contact.atlassian_account_id | one identity | Atlassian Cloud account ID. One ID covers Jira assignments, Confluence <ri:user> mentions and webhook sender attribution on both |
contact.github_login | one identity | GitHub username: review requests, sender attribution. Lowercased |
contact.gitlab_username | one identity | GitLab username: assignment, review and mention routing, sender attribution. Lowercased |
contact.crewlet_operator_id | one identity | One of Tier A’s api.auth.tokens[].id, which is always lowercase. Binds that credential to this seat, so a person writing through the dashboard, the REST API or the operator tool server acts as themselves — the item they file carries their name and wakes their colleagues. An attribution, never an address: the engine never sends as itself, so this id is left out of rosters and lookup_colleague, and a seat carrying only this one is reached through their dashboard queue rather than by an @-mention. Leaving a token unbound is ordinary — an operator outside the org chart, a pipeline — and its writes carry the token’s own id as the author, with author kind operator, rather than being refused. One id can never be bound: anonymous, the attribution a disabled api.auth guard stamps on every caller. Binding it would make whoever reaches an unguarded engine this person, so the literal is refused naming this field, and a ${VAR} that resolves to it binds nobody |
email | no | Indexed so a notification addressed to the address resolves to the seat. Not a delivery channel: no agent has an email tool by default |
availability | no | Free text rendered into a lead’s roster (timezone, hours, response expectations) |
A human seat needs at least one contact identity — that is how
agents mention and reach them, and how inbound webhooks attribute their
activity by name. A seat with no contact would be inert (visible in the
chart but unreachable), so it’s rejected at validation.
And no two seats may claim one identity. Each of these fields is an
external account, and an account belongs to one person. A duplicate is
rejected at validation because it does not fail loudly on its own: inbound
routing keys a map on the identity, so the last seat in the chart takes it,
while every walk of the chart answers the first. One of the two people
silently stops receiving their own mail, with both entries looking perfectly
ordinary — and with crewlet_operator_id the two directions disagree outright,
so a token opens one person’s dashboard while their wakes go to another seat.
The comparison ignores case and surrounding whitespace, because the lookups do.
crewlet_operator_id satisfies that requirement on its own, and the seat is
still reachable: their queue is the dashboard, not a chat mention. The roster
an agent reads says so explicitly rather than telling it to @-mention somebody
it cannot — a message addressed to a handle that resolves to nobody reads to
everyone else as work handed over.
The queue is #/inbox, one click from the landing screen (Home). Opening it
with an API token resolves that token’s id against every seat’s
crewlet_operator_id and shows the person it names: first what is waiting on
their decision — the questions agents put to them, the coding runs parked on a
question to them, a seat stopped on its budget — then their notices by the
company’s day, each with the one reason of eighteen that routed it. The row
they open fills the pane beside the list, with the answer to it right there. A
token bound to no seat is not an error — it is an operator outside the org
chart — and the screen says so rather than showing somebody else’s queue or an
empty one, naming the line of company configuration that would give it a
person. #/me is the same person’s own work, and it is absent for the same
reason when the token names nobody.
Read and snooze marks are the person’s own, and they are written as that
person: by their assistant over /operator/mcp, or from the dashboard over
/operator/act, which admits a token bound to a seat and nobody else. Every
write in this engine is attributed to somebody, and a button in a browser
writes as the person whose token it holds — never as “the dashboard”, which is
nobody. A token bound to no seat has no inbox to mark and is refused there;
what the screen shows is what the engine recorded.
Answering an agent’s decision. When an agent asks you to choose, the
question arrives with its options, the one it recommends and why, and the
evidence it cites. Each option is a card; pressing one sends it as your
answer, and “Reply with instructions” answers in your own words instead. The
pane tells you what happens next: “<asker> is
woken with your answer and posts it to #<channel>” when the agent
promised to report the outcome in a channel, or “<asker> continues from
your answer” when it did not. The first is enforced, not hoped for: the
agent’s turn is held open until it has posted on that chat surface. It is also
why your own asks cannot carry that promise — the engine keeps it by
holding the asker’s turn, and a person has none, so an ask you put through the
dashboard or your assistant with an inform is refused; post the outcome
yourself.
Your own writes count as yours — and the record still names the token.
A work item you file through your assistant is attributed to the credential
you filed it with, with author kind operator, never to your seat handle.
That is deliberate and it stays: a tracker whose author field is chosen by the
writer is not an audit trail, and there is no way to ask the operator tool
server to act as a seat. So the item records sarah as its reporter, while her
colleagues assign work to sarah-chen.
Both of those are her. Every question that answers “mine” — My work’s sections, the inbox, her own record — matches the seat handle or the operator id bound to it, and reports the answer under the seat.
Everything that asks who the caller is rather than who wrote it resolves to the seat for the same reason: the watch her create leaves on the item, the project it is filed into when she names none, and the lead relation that decides which work she may point at another team. An address is not an attribution — nothing routes to a credential, so a token left in a watcher set is a colleague nobody can reach. It is also how the wake for a change knows not to come back to her: the record carries her seat beside the token that authored it, so work she files through her assistant wakes her colleagues and not her.
Bind the token and your own work is on your own screen; leave it unbound and
you are an operator outside the chart, writing under the token’s own id with author kind
operator, which is an ordinary state and not an error.
Your own marks and pins are the person’s, and the record still names the
token. Whose state a document holds and who wrote it are two different
questions with two different answers, and the person tools answer both. When
Sarah’s assistant marks her inbox read, pins a view or re-orders her queue, the
record it writes is sarah-chen’s — the seat her token is bound to — while
the history row it leaves names sarah with author kind operator. The
attribution rule above is untouched: it answers who did this, and it stays
the credential. The subject answers whose inbox is this, and that is the
person.
Keyed on the credential, as it was, a bound founder accumulated a second record
called founder: everything their assistant marked was invisible on #/inbox,
which asks under the seat, and #/me’s queue came back empty. An unbound
token is unchanged — it writes its own record under its own id, which is the
ordinary state of an operator outside the chart — and records written before a
company bound its token are still read, the seat’s being preferred and the
credential’s the fallback.
One change that concerned you under both names is one notice, under the
stronger of the two reasons — the same rule that already gives one handle one
reason. And whoever reads somebody else’s day — an operator, or an agent seat
reading its lead’s inbox or state with work_inbox or get_person — is
handed that person’s two names from the chart, never the credential in
anybody’s hand.
The binding is written on the seat, not on the token. Tier A is the root of
trust and may never read Tier B — it holds the keys to the secret store — so a
seat: field on an api.auth.tokens[] entry would have the trusted tier
depending on the untrusted one. Naming the token id from the company document
inverts that: Tier A keeps a bare list of credentials, and the org chart says
which of them is a person.
Every contact field accepts either a literal ID or exactly one
whole-value ${VAR} reference, for example
atlassian_account_id: "${ATLASSIAN_FOUNDER_ACCOUNT_ID}" in a shipped
example config, where the real ID is instance-specific. Values are
whitespace-stripped when the organization is normalized. A literal
github_login or gitlab_username is lowercased there; a reference is
stored verbatim (never case-mangled) and its resolved value is
lowercased instead. A reference whose variable is unset counts as a
declared identity for validation, but the identity is omitted wherever it
is consumed until the variable resolves, so the raw ${VAR} text is never
emitted. The count of unresolved identities is logged on every apply
(parties_indexed, field unresolved). A value that merely embeds a
${VAR} inside a longer string ("acme-${SUFFIX}") is rejected at
validation, because substituting part of it would register a wrong
identity that matches nobody.
Every consumer resolves a reference the way every other ${VAR} in the
company is resolved: the secret store first, then the
environment the node runs with. Sender attribution, the lead’s roster,
lookup_colleague and a2a_ask, the dashboard’s colleague search and access
screen, and the crewlet_operator_id binding that makes a token a person all
read that one chain, so an identity whose value exists only in the secret store
resolves in every one of them or in none — never attributing a person’s
activity while leaving them out of the roster an agent addresses them from.
Human seats keep the descriptive identity fields (goal, backstory,
responsibilities). They are the routing context rendered into an
agent lead’s roster, so work goes to the person who owns it. They also keep
the hierarchy fields (manages, unit lead). Every runtime-only field is
rejected at validation time, and the refusal names each one as it is
written — a refusal of one field is placed at that key, where the dashboard
marks it: llm and every per-phase llm_* chain, sandbox,
token_budget, workers, learning_enabled, schedules, placement (a
human seat is never claimed, so there is no claim to constrain),
integrations.slack and integrations.mattermost (a seat’s own chat app),
project and space (the tracker project and knowledge container a seat
owns, written at the top of the seat rather than under integrations:),
mcp_env and behavioral_guidelines. A seat’s own GitHub App
(integrations.github) is refused on a human seat as well, because a person
acts on GitHub as their own contact.github_login. That refusal is an
admission rule: a
revision being applied that carries the block still runs, and the next write
that keeps it is refused. The reverse holds too: contact or availability
on an agent seat is refused with a hint to set kind: human.
A unit’s mcp_env is shared with its direct agent members only. A
human member inherits none of it, so a human seat can sit in, and lead, a
unit whose agents share tool credentials; only an mcp_env written on the
human seat itself is refused.
The dashboard draws the same rule rather than restating it. A human seat’s page omits every row a human seat cannot carry — the model chain, the token budget, the tool credentials, the turn and token tiles — instead of drawing their fallbacks: “default provider” is a MODEL for a seat that runs none, and a configured-settings panel asserting one is a panel claiming this company configured something validation would have refused.
Handles are validated for format ([a-z0-9][a-z0-9-]*) and org-wide
uniqueness. They are the canonical seat identity, and an agent and a human
sharing one would misattribute the person’s activity to the agent.
How Agents Know Humans Exist
Section titled “How Agents Know Humans Exist”- Identity prompt:
Reports to: Sarah Chen (human); human direct reports carry the same marker. - Lead roster: a human member renders with its handle and a human teammate marker, its background, goal and responsibilities, its resolved contact IDs, its availability, and hand-off guidance (assign in the PM tool and mention; no engine turn expected).
## Human colleaguescontract block: appears in the executor prompt only when the org contains human seats. Reach humans on external surfaces, never througha2a_ask; they reply asynchronously, so leave full context and end the turn; their reply re-triggers you.lookup_colleague: resolves agents and humans, by handle, role name or a human’s contact ID. A match renders the seat’s name, handle,kindand its resolved contact IDs; a human match adds that a person is reached with a mention and answers asynchronously, and thata2a_askwill not reach them. Rows in an ambiguous result carry each candidate’s kind. It does not render goal, background, responsibilities or availability, so a report learns what its human lead owns from the lead’s own messages rather than from this tool (the roster renders only downward).- Sender attribution: an inbound notification names its actor through
the party registry, so a Jira comment from Sarah renders as
Sarah Chen (sarah-chen, human colleague)instead of an opaque account ID. Counterparty profiles accrue for humans like anyone else (they are keyed by handle).
The Interaction Loop
Section titled “The Interaction Loop”Agent to human and back needs no new machinery: it is the existing inbound notification pipeline.
Two consequences:
- The engine never pushes to humans. A seat’s inbox exists to wake
an agent into a turn, and a human has no turn to wake. An inbound
notification whose recipient resolves to a human seat is skipped at
info level (
notification_skipped, reasonhuman seat) rather than warned about as undeliverable: the person is already notified natively by the tool where the work lives (a Jira assignment emails the assignee; a Slack mention pings them). A schedule never fires into a human seat either (see the table below). - Agents must never wait. The turn model is already asynchronous: the prompts and tool errors steer the LLM to leave state on the surface and end the turn.
Escalation (reaching a human)
Section titled “Escalation (reaching a human)”A human seat is the natural terminus of an escalation chain, and an agent reaches one exactly as it reaches any colleague — with its own colleague-surface tools during Execute, never via the engine:
- Chat (Slack / Mattermost) — the agent DMs the human’s member ID (or username) with its own chat tool, mention-prefixed. The engine never names that tool: the deployed MCP server’s names are not knowable here, so the prompts describe the capability and the LLM picks the match from its catalogue (see Tool Capabilities). The human’s reply lands on the agent’s own bot identity and re-enters through the normal inbound pipeline, so the answer goes back to the agent that asked.
- Jira / Confluence / GitHub — the agent comments / requests review with its own tools and the mention markup; the target is the artifact (issue / page / PR), the human is mentioned in the body.
- A2A is not a human surface. Humans have no inbox, so
a2a_askagainst a human returns a failed result telling the agent to mention the person on a shared surface instead, and the A2A service itself refuses any target that is not an agent seat in the org (a2a.ErrNotAnAgent), so a typo or a human handle fails visibly instead of opening a channel nothing answers. The question the guard asks is whether the target is an agent seat in the org, not whether it is running in the asking process: a colleague owned by another node is a normal A2A target, because the wake lands on its inbox and that node consumes it.
When a turn only discovers it needs a human at review, the reviewer
returns self_iterate with a note, and the executor’s next round makes
the mention. Once the mention has gone out the reviewer ends the turn
done instead — the human’s reply is what re-triggers the agent, so no
further round of that turn can produce it. If the agent genuinely can’t reach the human (it has no
chat tool, or the human has no contact ID), that surfaces as a config gap
to fix: give the agent the tool, or route the work through a colleague who
has it. The engine never manufactures a sender to bridge the gap: there is
no “Crewlet” DM and no engine-side fallback. Escalation is ordinary
colleague-tool use, so a report reaches a human exactly the way it reaches
an agent.
The Founder Seat
Section titled “The Founder Seat”The recommended way to put yourself in the company: a root-level human seat managing the top agent(s). There is no dedicated founder concept in the engine — the org chart is the model, and the founder is simply the top of it (the same reasoning behind having no dedicated escalate tool: the manager handoff IS escalation).
roles: - name: Jane Founder kind: human goal: "Own direction; final call on what ships" responsibilities: ["Approvals", "Unblock the CEO"] manages: [CEO] # top agents only — lead inheritance does the rest contact: slack_user_id: U0FOUNDER atlassian_account_id: 5b10ac8d-... github_login: janedoe gitlab_username: janedoeWhat this buys, with no further config:
- Top agents’ prompts read
Reports to: Jane Founder (human), so manager handoffs from your most senior agents terminate at a person instead ofNone (top-level). When the CEO is stuck it DMs you on Slack or mentions you on Jira with its own tools, and your reply re-triggers it. - Your Slack, Jira and GitHub activity is attributed by name, so agents know when the founder is speaking.
- DACI: the Approver is “the driver’s manager”, so you become the approver of last resort for top-level decisions behaviorally.
Two boundaries to keep in mind:
- The seat is the colleague hat, not the operator hat. Config
ownership (
PUT /config, API auth tokens, the dashboard) stays an API-auth concern: the seat makes agents know you; the token makes the engine obey you. Different hats, deliberately separate. - Scope
managesto the top roles. A founder managing every unit by name lists every seat in those units, and root seats are searched first when a seat’s manager is resolved, so the founder becomes the primary manager and escalation terminus of every one of them, even where a unit lead also auto-manages the seat (see Unit Lead). Manage the CEO (or the unit leads) and let lead inheritance handle the rest;availabilitysets response expectations.
See examples/nimbus.company.yaml for a complete working org with a
founder seat above the agent CEO. That company’s only surface is chat, so
the founder seat there carries a single contact identity
(mattermost_user_id); add one per surface you connect.
What Humans Never Do
Section titled “What Humans Never Do”| Subsystem | Behavior |
|---|---|
| Seat placement | Never claimed: only agent seats enter the placement sweep, so a human seat has no lease, no mailbox and no per-role MCP children, and a placement block on one is refused |
| Inbox | None. A notification resolved to a human seat is skipped (notification_skipped, reason human seat) |
| Engine notifications | None. The engine never sends as itself; agents reach humans with their own tools |
| Scheduler | target: each fans out to agent members only; an enabled target: lead schedule under a (possibly inherited) human lead is a config error; human seats cannot define role schedules |
| A2A channels | Not addressable; a2a_ask returns guidance and the A2A service refuses the target |
| Learning | No diary, no episodes, no synthesized skills (counterparty profiles about them still accrue) |
GET /agents | Excluded. They appear in GET /org with "kind": "human", and the dashboard org chart badges them human |
Hot Reload
Section titled “Hot Reload”A seat’s kind is ordinary configuration, applied like any other change (see Organization Model: Hot Reload):
humantoagent: the seat joins the next epoch’s seat list, so a node claims it, creates its mailbox and starts its per-role MCP children the way it would for a newly added seat.agenttohuman: the seat leaves the seat list, so the node holding it releases it, and its mailbox is retired after the grace period described in Seat Ownership: The removed seat. An agent’s id is derived from the company name and its handle (org.DeriveAgentID), so flipping the seat back toagentlater reattaches its diary, episodes and onboarding marker.- Contact and availability edits take effect with the next epoch: every apply builds a new party registry and reconciles the human contact IDs into it.
Identity Resolution (party registry)
Section titled “Identity Resolution (party registry)”notify.Registry answers “who is this?” for one epoch of the company. It
indexes every party, agent and human seats alike, and resolves one by
handle (ByHandle), exact role name (ByRole), derived agent id
(ByAgentID, which never matches a human seat because a human has no agent
id), email (ByEmail, where a plus-address naming a handle wins over a
seat’s declared address), or an external ID on a surface (ByExternalID).
Each Party carries a Human flag, and the notification spine reads it to
skip a human recipient rather than wake it.
The seat indexes are built from the organization and never change: an apply builds a new registry rather than editing the one a running turn may be reading. The external-identity map is the part written at runtime, under a lock, and it holds two kinds of entry:
- Human contact IDs come from
contactand are reconciled into each new registry (ReconcileHumanContacts). A pair the previous reconciliation registered and the current organization no longer declares (a contact edit, a removed seat, a kind flip) is withdrawn, and an ID already held by a different seat is never taken over: the conflict is logged ashuman_contact_id_conflictnaming both seats. - Agent identities are registered by the integrations: the code host’s and the tracker’s are derived from each seat’s credentials and rebuilt on every apply, while the chat transports’ bot IDs, resolved against the live server at connect, are carried across into the new registry.
External-ID resolution is plain index lookups, because it runs on every
inbound notification for sender attribution. On each surface it consults
two namespaces, the surface’s own (a human’s member ID under slack) and
the companion bot namespace (an agent’s bot user under slack_bot), so
agent and human senders are annotated alike.
Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.