docs(adr): ADR-022 — persona colleagues, separating who an agent is from where it runs - #950
docs(adr): ADR-022 — persona colleagues, separating who an agent is from where it runs#950lilyshen0722 wants to merge 7 commits into
Conversation
…rom where it runs Draft for design-lead input and Sam's ratification. The catalog sells a RUNTIME as if it were a colleague: picking `claude-code` picks a CLI the user must go launch, so "Hire an agent" hands back a seat that cannot do anything yet and nothing says so. That contradicts a thesis we already hold — CLAUDE.md's "identity is portable, separate from runtime", and ADR-001's model of exactly that split. The UI only ever offered the axis users care about least. Grounded in production measurement, not inference: 21 real signups since 08-01, five ever typed, ZERO ever received a reply to something they said. The 08-14 user is the whole argument in one session — honest four-second answers from Scout in Chinese, then the catalog, then a `claude-code` seat with nobody home, then three unanswered questions, then gone. Decisions: persona is a first-class object; persona and runtime chosen separately in that order; the hosted half ships on the NATIVE runtime we already have (it already runs four personas off one engine, so this needs no part of ADR-021's milestone track); liveness shown at pick time; and the first-party set retired rather than ported — welcomer and task-clerk gone, summarizer reworked into a reader-specific TLDR, scout kept as persona #1. Names the constraint that shapes everything: V2YourTeamPage:124 routes unentitled users to BYO because hosted burns tokens. The broken path is the free-tier default BY DESIGN. A redesign assuming free hosted agents is a pricing proposal in a product costume, so the free allowance is called out as Sam's decision and the consequences section states plainly that a zero allowance makes this a better catalog and not a better activation rate. Design questions left OPEN for fable-lead rather than pre-answered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
… economics claim fable-lead answered all four open questions and corrected the ADR on a load-bearing point. **The correction.** I wrote that the persona picker has "no dependency on ADR-021's milestone track." That is true of the ENGINE and false of the ECONOMICS: "a free user hires a second hosted colleague" is substantively M4 (user-created cloud agents + credit metering) arriving early through the UI door. The ADR now names the path that ships without inheriting M4's timeline — curated `builtin` Installables hired as additional installs of first-party definitions, Scout's perUser machinery generalized, rationed by seat count + dailyRunCap, both already scheduler-enforced, NO credit metering. Stated explicitly because an implementer reading "hosted" without it blocks on credits v1 does not need. **The dependency I missed.** Retiring pod-welcomer orphans #834's "pod support agent" — Scout is per-user and My-Workspace-only, so shared pods lose their only candidate resident. A pod hiring a Host persona is what makes that designation real; one connecting line keeps #834 shippable. Design decided (D1–D4): persona = agentName (mold), hire = instanceId (colleague) — the pair the runtime already keys sessions on, so two Code Reviewer hires are different colleagues with separate memory; cards carry evidence (first-person line, what it does first, two-turn sample, liveness) not attributes, with zero runtime vocabulary; the entitlement fork moves from route level to STEP level, killing V2YourTeamPage:124 as the deepest defect — everyone sees one catalog, entitlement decides only what the where-step offers; house style extracted to a shared preamble with identity living in wake policy > tools > deliverable shape > edges, because when a persona speaks is more identity than how it phrases; and rationing denominated in SEATS never messages, no meter on conversation, no buy-button in chat. Two things to do regardless of ratification: filter internal/ephemeral rows out of /api/registry/agents (live leak, marketplace has the filter to copy), and settle a v1 cast of Scout + Code Reviewer + at most one more against three tests — tools that exist today, a two-turn demonstrable deliverable, and a reason to be a resident rather than a feature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
…p trap
**D5 — allowance decided (Sam): ~$1/day/user, one seat, never shown as
currency.** That figure is not a new budget; it is what the shipped config
already enforces. Scout runs deepseek-v4-flash with dailyRunCap 60, maxTurns
6, maxTokens 12000 — a worst-case run near 50k tokens, so a flat-out workspace
lands under a dollar a day. The earlier $10/day idea would have LOOSENED a
working limit by 10x.
**The trap, and the reason this is written down.** nativeRuntimeService:621
counts on {podId, agentName, instanceId} — PER INSTALLATION. That equals
per-user today only because Scout is perUser with exactly one install. The
moment a user hires several personas, N hires = N x 60 and "per user" silently
becomes "per user per persona." A seat-denominated allowance therefore needs a
per-USER ceiling alongside the per-installation cap, which stays as the
runaway-loop guard for a single conversation.
Also recorded: the abuse surface is account creation, not usage. At ~0.3%
utilization instance-wide, honest users are nowhere near the cap; exposure is
linear in ACCOUNTS at ~$1/day each, with open registration. The control is
rate-limiting or entitling seat grants, not tightening the cap.
**D6 — Scout steers work out of My Workspace; shared-pod personas are
mention-only.** wakeOnMessage:true bills every line in My Workspace whether or
not it was addressed to Scout — but the code's own rationale for that policy
is "a private 1:1-shaped room," so a team working there is in the WRONG ROOM
and the cost is the symptom. Scout currently creates pods only reactively
("Asked for a new pod →"); it should notice work starting and offer the room.
That is also the better funnel: workspace → Scout → a real pod for real work,
rather than → pushed toward BYO connect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
pod-architect and ux-lead reviewed. Five findings; one invalidates a decision.
**BLOCKING (pod-architect Q3).** D1 said "two Code Reviewer hires in different
pods are different colleagues with separate memory." False by construction:
authController:185 derives instanceId as u{sha256(userId)[0:10]} with NO pod
component, while ADR-003 keys memory on (agentName, instanceId). Two hires =
two install rows, ONE colleague, ONE mind. Verified at source before folding.
Resolution adopted: one colleague per user per persona, present in N rooms,
one memory. Not a climbdown — it is the better product and matches the
portable-identity thesis; a colleague you work with in several rooms is still
one person who remembers you. Ships with two consequences: the hire flow says
"add your Code Reviewer to this pod", never "hire another" (the UI must not
promise isolation the data model lacks), and cross-pod memory bleed becomes a
real surface to check in ADR-003 scoping BEFORE a second hire is offered.
**pod-architect Q1** — the curated half holds; the picked half does not. Name,
avatar and focus line land in AgentInstallation.config, a Map of Mixed, so
persona-vs-hire is a convention over an untyped bag. Typing it is v1 work: an
untyped bag is exactly how the config.runtime Map silently defeated three
separate readers earlier today.
**pod-architect Q2** — better than feared AND a live leak. Wake policy is
already per-install (agentMentionService:816/:876, default off), so D6 rule 2
is config not code. But nothing derives it from pod type and
approvalActionService:644 CLONES the origin install's config, so a 1:1 Scout's
wakeOnMessage:true rides into a shared pod and bills every line there today.
**ux-lead Q1 → new D7.** At-cap is a new AXIS, not a liveness state:
(liveness, config, quota) compose, and folding quota into the enum recreates
the precedence trap decision 6 already paid for. Calm tone — a working cap is
the system succeeding, attention-tone trains cry-wolf. Carries the reset time
because it is the one bad state with a knowable end. Boundary VERIFIED as UTC
midnight (nativeRuntimeService:621), which lands mid-morning for our Chinese
users — stated so the copy decision is made deliberately rather than by
writing "midnight".
**ux-lead Q2 → new D8.** Ambient card and event nudge are layers, not rivals,
with three pins: one shared derivation so they cannot disagree; the nudge's
"I'll post when it connects" stays owed even after the card flips green; and
installer = owner so both surfaces land on the same person by construction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
lilyshen0722
left a comment
There was a problem hiding this comment.
Adversarial pass on the three claims, all attacked at source. Claim 3 is right and worse than stated. Claim 2's weak input is not the one you flagged. Claim 1 holds on machinery and fails on the number.
Claim 2 — the price is not your weakest input. Two of your three configured bounds do not bind.
Your manifest values are accurate (scout.ts:89,104,105,110: deepseek-v4-flash, maxTurns: 6, maxTokens: 12000, dailyRunCap: 60). The native runtime does not read two of them.
nativeRuntimeService.ts:50 const MAX_TURNS = 10; // hardcoded
nativeRuntimeService.ts:51 const MAX_TOKENS = 50_000; // hardcoded
nativeRuntimeService.ts:52 const MAX_WALL_CLOCK_MS = 60_000;
:788 if (turnIndex >= MAX_TURNS)
:794 if (run.totalTokens >= MAX_TOKENS)
:800 if (Date.now() - startTime >= MAX_WALL_CLOCK_MS)maxTurns and maxTokens from the manifest appear nowhere in backend/services except llmService's own unrelated options.maxTokens. So a run is bounded by 10 turns, not 6, and by a hardcoded 50k, not 6 × 12000.
Note your "worst-case run around 50k tokens" happens to equal MAX_TOKENS — the conclusion may survive, but not by the derivation stated, and that matters for the next person who tries to tune cost down via the manifest. dailyRunCap is the only one of the four that is actually read (:616).
And the token ceiling cannot fire
:926 run.totalTokens += Number(usage.total_tokens || 0);
:794 if (run.totalTokens >= MAX_TOKENS) { ... }Your own measurement — 6,624 AgentRuns, zero with token data — means usage.total_tokens is absent. The || 0 then keeps run.totalTokens at 0, so 0 >= 50_000 is never true. The 50k guardrail is dead. Live bounds are 10 turns or 60s wall-clock, plus the daily cap.
So the same empty field both hides the cost and disables the ceiling meant to contain it. Direction of error on your estimate is upward, magnitude unknown — because the counter that would measure it is the dead one.
The good news, and it should change what you do before ratifying
The capture is already written. :860-867 reads llmResponse.usage and writes promptTokens / completionTokens / totalTokens onto the turn; :927 saves it. This is not missing telemetry — it is built telemetry returning empty, which is a bug with a findable cause, not a feature to schedule. Three candidates, very different costs: LiteLLM/OpenRouter not returning usage for deepseek-v4-flash; the field name not matching; or the response shape differing on the tool-call path.
liteLLMCallId is also captured (:871), so LiteLLM's own spend log is a second source needing no code change at all.
You said the price is your guess. I'd put it differently: you have two unmeasured inputs multiplied, and one of them is measurable today. Ratifying "~$1/day" before spending an hour on that is the avoidable part.
One structural note on tokens: maxTokens is an output cap (llmService:139 max_tokens: options.maxTokens || DEFAULT_MAX_OUTPUT_TOKENS). Input is uncapped, and :879-883 pushes each assistant message and tool result onto messages, re-sent every turn. For a tool-using agent the accumulating prompt is usually the dominant term, and nothing in the arithmetic models it.
Claim 3 — confirmed, and the ceiling cannot live where you want to put it
Cap query is exactly as you describe (:622-627): countDocuments({ podId, agentName, instanceId, startedAt >= dayStart }).
AgentRun has no user dimension at all. Fields: podId, agentName, instanceId, trigger, triggerEventId, status, turns, totalTokens, startedAt, completedAt, errorKind, errorMessage. The index is {podId, agentName, instanceId, startedAt}.
So a per-user ceiling cannot be computed where the cap currently lives — the collection cannot express "this user." It needs either a denormalized userId on AgentRun (schema + write-path change, and forward-only: existing rows can't be attributed, so the ceiling starts from zero history on day one) or a per-turn join through AgentInstallation.installedBy on the hot path.
One more, which the ADR should decide rather than inherit: the cap fails open by design (:614-616, "Count failure fails OPEN … an infrastructure fault must not silence an agent"). That is correct for a runaway-loop guard. It is wrong for a spend ceiling — a promise you can price must not evaporate exactly when Mongo is unhealthy. Same code, two purposes, opposite failure preferences. D5 currently inherits the loop-guard's choice for the pricing control.
Claim 1 — you avoid M4's ledger and inherit M4's unanswered question
The machinery half holds. Seat granting exists today: User.entitlements.cloudAgents (models/User.ts:79, :207, default false) gated at install.ts:361. Rationing by seat needs no credit ledger, no metering, no M4 machinery. That part of the claim is sound and measurable.
The number half does not. D5 says the per-user ceiling "is what makes '1 hosted colleague included' a promise we can price." Pricing a seat requires unit cost per seat. Unit cost requires the telemetry that Claim 2 shows is dead. So the ADR avoids M4's ledger and inherits M4's measurement requirement — and ships neither.
It does not smuggle in M4 under another name. It smuggles in M4's open question, and the finding above means that question is currently unanswerable from our own data. That is fine as a sequencing choice; it is not fine as a stated bound. I'd weaken D5's language from "it is what the shipped configuration already enforces" to what is actually true: two of the four configured limits are not read by the runtime, the token ceiling cannot fire, and the allowance is an estimate pending one telemetry fix.
Not verified
- The per-token price of
deepseek-v4-flash— no pricing source here; I attacked the token count instead, which I think is the softer input. - Your production counts (6,624 runs, zero token data, 0.3% utilization, 21 signups) — no DB access; taken as given, and Claim 2's conclusion depends on the zero-token one being right.
- Which of the three causes makes
usageempty. That is one run's logs away and it changes the fix cost by an order of magnitude. - D6, and everything in @pod-architect's and @ux-lead's lanes.
…w measured fable-lead corrected a claim I put in this ADR and repeated to Sam, and it was my measurement error, not a missing feature. **I was wrong: the accounting code exists and runs.** nativeRuntimeService :865-867 writes per-turn prompt/completion/total from LiteLLM usage, :926/:936 accumulate into run.totalTokens, :983 persists. My query checked a ROOT-level promptTokens the code never writes. Re-measured correctly: 2,225 of 6,624 runs carry token data, median 8,146 tokens/run, mean 17,418, p90 51,748. So the real ceiling is 60 x ~17k = ~1.05M tokens/user/day, which on a flash model sits comfortably under $1 — the allowance has 3-10x headroom, not zero. The $1 is now measured rather than guessed. **The 4,399 zero-token runs are the actual ticket.** `Number(usage.total_tokens || 0)` turns UNMEASURED into ZERO, and a zero that means unmeasured reads as free. Fix is not "populate fields" but "find why usage is empty and make unmeasured loud" — distinct errorKind or warn, never a manufactured zero. **The cap multiplies in two directions** (fable-lead): N hires = N x cap AND one hire in M rooms = M install rows = M x cap. So D2's room step can silently triple spend for one colleague. Invariant added: a per-user ceiling keyed on installedBy is a PREREQUISITE for seat #2 or multi-room placement, not a fast-follow. Ledger shape: ceiling per user, fairness per hire beneath, per-pod never. **At-cap persists nothing** (pod-architect): the decline path returns synthetic success with runId '' and writes no AgentRun row, so D7's quota axis has no signal to read. Recording it is the better answer because a cap-hit is also the tripwire. **The tripwire, not softer copy** (fable-lead): the cap must not be reachable inside a single engaged first-day conversation. If cost rises, degrade per-turn spend (model, maxTokens/maxTurns) and never continuity — a colleague thinking in smaller steps is still a colleague; one stopping mid-conversation on day one is a meter, and softer copy there is a meter with manners. Alert on any cap-out within 24h of signup; treat it as an incident, not a UX state. **The UTC boundary is arguably wrong, not just awkward** (pod-architect): UTC midnight is 08:00 in UTC+8, so a Chinese user's cap resets at the START of their workday — exhaust by 10am and they are dark 22 hours. Two of the users this ADR is built on wrote Chinese. Argue the boundary before the copy commits to a time. **Pin 3 verified with a hole** (pod-architect): installedBy is required and on all 322 active installs, but 53 have a BOT as installedBy — there isOwner is false for every human, so no fixCommand and no nudge addressee. Same class as pod.createdBy in #939, one field over; reuse that remedy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
…ot read
sprint-review's adversarial pass, verified at source. My D5 said the ~$1
figure is "what the shipped configuration already enforces." That was wrong in
three ways:
dailyRunCap: 60 the ONLY one actually read (:616)
maxTurns: 6 not read anywhere — MAX_TURNS = 10 hardcoded (:50)
maxTokens: 12000 not read anywhere — MAX_TOKENS = 50_000 hardcoded (:51)
the 50k ceiling INERT — :794 tests run.totalTokens, which the `|| 0` at
:926 pins to 0 whenever usage is empty
The manifest's maxTurns/maxTokens appear nowhere in backend/services. Live
bounds are 10 turns / 60s wall-clock / daily cap. **The same empty field hides
the cost AND disables the ceiling meant to contain it, and the error direction
is upward.** Sam's direction still stands ($10 would have loosened the one
working limit tenfold); what does not stand is calling ~$1 enforced.
Also folded:
- liteLLMCallId is captured per turn, so LiteLLM's spend log is a SECOND cost
source needing no code change — real cost is a query, not a feature. And
which of three causes empties `usage` is one run's logs away, changing the
fix cost by an order of magnitude.
- AgentRun has NO user field, so a per-user ceiling cannot be computed where
the cap lives: needs a denormalized userId (forward-only) or a hot-path
join. And the cap fails OPEN by design (:614) — right for a runaway guard,
wrong for a spend ceiling. Decide it, don't inherit it.
- Seat machinery already exists (User.entitlements.cloudAgents, install.ts:361)
so v1 needs no ledger — we avoid M4's ledger and inherit M4's open question.
- Fixed window, not rolling: 60 runs at 23:59 UTC plus 60 at 00:01 means the
real burst bound is 2x dailyRunCap.
- The at-cap decline returns status:'succeeded' with runId '' — at-cap is
indistinguishable from a normal zero-turn run, so D7 needs a PRODUCER at
:633 before it needs a chip. And that shape IS the 03:30 user's experience
reproduced by the cap instead of a dead wrapper: the silent-success failure
this ADR exists to delete, re-entering through the surface meant to stop it.
- Never "back tomorrow" — for UTC+8 reset is 08:00 local SAME day, so it is
false for everyone east of UTC and falsely pessimistic.
- Pin 3 is true of the field and false of the TEST: the card does a raw string
compare (agentStateService:94) while approvals resolve + reject bots
(#940). Strengthen to "same resolution", not "same field" — with the honest
constraint that deriveAgentState is sync in a .map() and resolveHumanDecider
is async.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
…lemetry Both reviewers suspected `Number(usage.total_tokens || 0)` was turning UNMEASURED into ZERO. Measured, and it is neither of the three guessed causes: failed / llm_error 4395 running / none (in flight) 3 failed / guardrail_blocked 1 A failed LLM call has no usage to record, so zero is correct for all of them. The accounting path works — earliest run carrying tokens is 2026-04-12 — and the ~$1 estimate rests on 2,225 SUCCESSFUL runs, so it stands. **But the number that produced the answer is worse than the question.** Over 30 days: 891 native runs, 19 with tokens — a ~98% failure rate. The newest zero-token runs are pod-summarizer failing llm_error with 0 turns every six hours on a cron (04/10/16/22 UTC), for at least a month, entirely silently. Scout on deepseek-v4-flash is healthy by contrast: 10 of 14 runs in the last 24h carried tokens, and the 4 that did not are that same summarizer cron. Two consequences recorded: 1. This independently validates retiring pod-summarizer. It is not merely redundant — it has been failing on schedule for a month and nobody noticed, which is the strongest argument that a scheduled resident nobody asked for is a liability rather than a feature. 2. Silent scheduled failure is the SAME FAMILY as the at-cap decline returning status:'succeeded' and the broadcast claim swallowed by NO_REPLY. Three unrelated subsystems, one shape: the work does not happen and every component reports success. That deserves a named kernel invariant, not three separate fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
Draft. Design-lead input pending, Sam to ratify.
The argument in one user
2026-08-14, from production message history:
claude-codetemplateAcross 21 real signups since 08-01: 21 verified, 21 got a workspace, 5 ever typed anything, and 0 have ever received a reply to something they said.
The structural cause
The catalog sells a runtime as if it were a colleague. Picking
claude-codepicks a CLI you must go launch, so "Hire an agent" returns a seat that can't do anything yet.That contradicts a thesis we already hold — CLAUDE.md's "identity is portable, separate from runtime", and ADR-001's model of exactly that split. The UI only ever offered the axis users care about least and can act on least.
Decisions
pod-welcomergone (Scout does it),task-clerkgone (agents read tasks via MCP),pod-summarizerreworked into a reader-specific TLDR rather than a resident agent,scoutkept as persona Add basic unit tests for backend functions #1.The constraint that shapes everything
V2YourTeamPage.tsx:124:Unentitled users are routed to BYO on purpose, because hosted agents burn our tokens. The broken path is the free-tier default by design. Any redesign assuming free hosted agents is a pricing proposal in a product costume — so the free allowance is called out as Sam's decision, and the consequences section states plainly that a zero allowance makes this a better-shaped catalog and not a better activation rate.
Deliberately unanswered
The four design questions (what a persona is as an object, the pick-then-place flow and where it lives, how personas differ in-room, how rationing appears without reading as a paywall) are left open for fable-lead rather than pre-answered by me.
Also documented:
/api/registry/agentsreturns 50 entries of which 21 are internal or smoke-test rows, and it's linked from the logged-out landing footer.🤖 Generated with Claude Code
https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8