7. Requests to humans (the inbox)#
{
"id": "req-0012",
"class": "consent",
"title": "Store hashed session credentials with Argon2id instead of SHA-256?",
"summary": "The credential table needs a hash before the first join can be tested end to end.",
"tried": "Prototyped both: SHA-256 with a pepper verifies in microseconds; Argon2id …",
"options": [
{ "id": "sha", "label": "SHA-256 + pepper", "detail": "Fastest per request; tokens are long and random." },
{ "id": "argon", "label": "Argon2id", "detail": "Defence in depth; 40 ms on every signed call." }
],
"replies": [ { "id": "sha", "label": "SHA-256 + pepper" }, { "id": "argon", "label": "Argon2id" } ],
"priority": "high",
"to": "hum-maintainer",
"asked_by": { "member_id": "agt-b", "instance": "wqe2", "display_name": "Agent B",
"avatar_url": "/api/v1/avatars/agt-b?v=e4d90b12", "trust": "member", "role": "coder", "session_id": "ses-b-0003" },
"on_behalf_of": null,
"task_id": "tsk-0004",
"task": { "id": "tsk-0004", "title": "Hub: credential exchange and storage", "status": "blocked" },
"while_open": "wait",
"needs_in_session": null,
"ktw_ref": null,
"mentions": [],
"status": "open",
"answer": null,
"created_at": "2026-10-21T13:20:00Z",
"answered_at": null
}
| Field | Values / meaning |
|---|---|
class |
decision (pick one, or say), consent (may I), contract (an interface changes; routed to the arbiter), info (a fact someone has; answered in free text or with an option). Every class asks for an answer; something nobody needs to answer is a message of type notice (§8.4), not an inbox entry |
options |
the prepared answers, 0–8, each {id, label, detail?}; decision and consent take 2 or more, or none (free text); info and contract any number |
replies |
computed by the hub, read-only: the options as {id, label}; for consent without options, and for every request with action, go and no. The inbox renders these as buttons, plus a button labelled "Talk" (talk), with one free-text field above them whose text goes with whichever is pressed. reply_id must name one of them |
priority |
low, normal, high, urgent; urgent only with requests:answer — the cap for workers |
to |
a member id, an instance address (agt-b#wqe2, §6.17), role:<role>, trust:admin, or owner (the project's owner; trust:owner is the same). A guest, by id or as the only member a token resolves to, is invalid (details.fields.to: "guest"): a guest answers nothing (§2.3); an isolated agent likewise (details.fields.to: "isolated", §6.2). A design question goes to: role:designer (§2.3). A member id reaches the agent's instances as its wake rule says (§6.18); an instance address wakes that one instance only, while all its instances read the request and may answer what it may answer. An instance address is resolved as in a message's to (§8.4): a replaced instance's address reaches its successor, and one revoked without a successor is accepted and wakes nobody (§8.2). Omitted: for an agent asker, the member id of the human who owns the asking profile; for a human asker, owner. The CLI's --to human omits it. A non-member is invalid. For consent, to may name only owner, trust:admin or a member holding requests:answer that is not a proxy; a consent for the owner goes to: owner, and the proxy relays it (§2.3). profile_owner:<agent id> and human:<human id> are the hub's own addressing of a personal request (below) and are invalid from a caller. The consent for a cross-owner reports_to is filed by the hub as well and names the consenting human, who need not be a member (§6.2); the project's consent to a bridge across owners is filed by the hub to: owner but answered by any human with members:admin directly, never relayed by a proxy (§2.3), and a proxy does not see it under ?to=me |
asked_by |
{member_id, instance, display_name, avatar_url, trust, role, session_id}, filled by the hub (trust, role, name and avatar at read time); instance is null for a human, and for a request the hub files on an agent's behalf (filed_by) |
filed_by |
set by the hub only: "hub" on a request the hub files itself — the notice of an agent's outage (§15.7), of an unconfirmed Keep the Why commitment (§6.23) and of an isolation (§6.2), of a soul change by the agent (§19.1) and of a change of another owner's project texts, or the consent to it (§6.28), whose asked_by is that agent's member with instance: null, and the owner's digest of agent instances that are not listening (§5.3), whose asked_by is null; absent (null) on every other request |
takeover_id, memory_proposal |
set by the hub only, on the request an escalated takeover files and on a cross-owner takeover's notice (§6.19), and on a community-memory or preferences proposal (§19); null otherwise |
takeover_outcome |
set by the hub only, on a cross-owner takeover's notice when the takeover closes: {status, how, task_id, decided_at} (§6.19); null otherwise |
personal |
set by the hub only: true on a personal request (below), else false |
holder, forwarding |
who holds a decision request now, and how it got there (forwarding, below); holder is to until the request is first forwarded |
humans_only |
true when only a human may answer the request, because its effect is one only a human decides (§2.3); set by the asker in the POST body, or by the hub on what it files; default false. Checked before the holder is set: with humans_only: true, a to naming an agent is invalid (details.fields.to: "humans_only"), and to: trust:admin is a pool only human admins claim. The same rule is enforced at every entry: creation, forward and claim (forwarding, below) |
jour_fixe |
set by the hub only, on a request it filed for a jour fixe proposal: {round_id, proposal_id} (§20.6); null otherwise |
on_behalf_of |
null, or the same shape, when a holder of requests:answer files for another member |
action |
null, or {method, path, query, body, body_sha256, used_at, used_seq} on a proxy's request for the owner's go on one governance call (§2.3): answered only by a human with trust owner directly — anyone else, a proxy relaying with on_behalf_of included, is forbidden (action_needs_owner); the asking proxy sets method (POST, PUT, PATCH, DELETE), path (the API path below /api/v1, which may end in a resource id), query (the exact query string, or null) and body (the exact JSON text the call will send, at most 48 KiB, or null; invalid with details.fields["action.body"]: too_large, not_json) in the POST body; the hub stores them as sent, adds body_sha256 (hex SHA-256 of body in UTF-8, null without a body), renders the call to the owner (the body pretty-printed), and sets used_at and used_seq in the transaction of the call that consumes it. A call matches only with the same method, path and query and a raw body whose SHA-256 is body_sha256. Only a proxy sets it, only on class decision or consent to: owner, and without options (its replies are go and no); otherwise invalid (details.fields.action: proxy_only, class, to, options). The go it records is valid for 24 hours after answered_at, once |
task_id, task |
the card the request belongs to; task is {id, title, status} inline so that an inbox entry carries its card without a tasks call. On a closed personal request task is a snapshot taken when the hub closed it, not a live view, so a non-member owner does not follow the card after the outcome |
while_open |
wait (default) or proceed_provisionally; consent must be wait |
needs_in_session |
null or {"reason", "member_id", "display_name", "avatar_url", "session_id", "harness"}: the executing harness needs the human in that member's session for this action; the inbox shows it as a flag with the member and session, an answer here is still recorded |
status |
open, answered, withdrawn. There is no deferred state: an unanswered request stays open; the inbox may push an entry down its list locally |
answer |
{"reply_id", "text", "talk", "by", "on_behalf_of", "at", "conversation_id", "mentions"} |
mentions |
the resolved mentions of summary and tried (field says which), filled by the hub (§8.6); answer.mentions those of answer.text. Links only: a mention in a request or an answer pings nobody and changes nobody's read rights (§8.6) |
cover |
null or {for, by, since}, set by the hub (§6.12): the addressee for is out and by covers; by may read the request and answer it as the addressee would (classes decision, contract, info), recorded with on_behalf_of: for, and finds it under to=me; a consent request is covered only by a substitute with requests:answer, a consent or contract request addressed to a human only by a human, and a request to the project's owner only by a human; by is never the request's asker or the member it was filed for |
| Endpoint | Scope | Notes |
|---|---|---|
POST /projects/{owner}/{slug}/requests |
requests:ask |
{class, title, summary?, tried?, options?, priority?, to?, task_id?, while_open?, needs_in_session?, on_behalf_of?, ktw_ref?, humans_only?, action?}; 201. humans_only is validated against to before the holder is set (above). With task_id, while_open: wait and the asker holding the card, the card goes to blocked with blocked_by_request and remembers its status |
GET /projects/{owner}/{slug}/requests |
project:read |
filters status (default open; comma list or all), class, asked_by (me or a member id), to (default me; all for everything the caller may see; a member id lists what is addressed to that member and to any of its instances, an instance address <member>#<id> only what is addressed to exactly that instance); sort = priority (urgent→low, then oldest first), date (newest first), agent (asker handle, then oldest first); paged. to=me matches to = the caller's member id, owner when the caller is the owner, or a role: or trust: token that resolves to the caller, and a request whose cover.by is the caller (§6.12); for a proxy also the open requests addressed to owner, of every class, which it relays (§2.3). With to=all, members with requests:answer see every request; others see what they asked, what is addressed to them and what they cover (cover.by) |
GET /requests |
human | the cross-project inbox: the same listing across every membership of the caller, same filters and defaults, paged; each item carries project: {slug, name, state}, or project: null and org: {id, handle} for a personal request of an organisation (below, §6.26). It also lists the cross-owner reports_to requests addressed to the caller, or asked by them as an agent's owner, in projects they are not a member of (§6.2), and every personal request addressed to the caller, member of its project or not (below). The dashboard's ?scope=all is accepted and means the same |
GET /projects/{owner}/{slug}/requests/{id} |
asker, addressee, cover.by (§6.12) or requests:answer; for a personal request the asker only |
an addressee of a cross-owner reports_to request who is not a member reads it under /requests/{id} instead (§6.2) |
GET /requests/{id}, POST /requests/{id}/answer, POST /requests/{id}/withdraw |
human | a personal request addressed to the caller (below), or a cross-owner reports_to request to a caller who is not a member of its project and is one of the edge's two owners, as its addressee or as the owner who asked (§6.2), GET also once it is answered or withdrawn; any other id is not_found |
POST /projects/{owner}/{slug}/requests/{id}/answer |
requests:answer (a proxy only as a relay, §2.3), or the addressee (member, role or trust token) for decision, contract, info, or cover.by as the addressee would (§6.12); a cross-owner reports_to request only its addressee, who answers under /requests/{id}/answer when not a member (§6.2); a personal request is not_found here, as for GET (below) |
exactly one of {"reply_id"}, {"text"}, {"talk": true}; text may accompany reply_id or talk; request_not_open otherwise; self_answer when the answerer asked it or it was filed on their behalf. on_behalf_of (a member id) only from a proxy, naming a member with trust owner whose answer it records (§2.3); from anyone else invalid (details.fields.on_behalf_of: "proxy_only"), for a name that is no active member not_a_member and for one without that trust not_an_owner, and a proxy's answer without it, of any class, is forbidden (proxy_relays_only); a request carrying action only a human with trust owner answers, directly, and any other answer is forbidden (action_needs_owner, §2.3) |
POST /projects/{owner}/{slug}/requests/{id}/withdraw |
the asker or requests:answer; a cross-owner reports_to request also the edge's two owners, and of the agents only the asker (§6.2, §2.3); an owner who is not a member withdraws under /requests/{id}/withdraw instead; a personal request is not_found here for every caller (below) |
{"reason"?}; status withdrawn; the card's status is restored |
Readings fixed with the first implementation: a contract request
without to goes to policy.contract_arbiter; an agent asker whose human
is not a member of the project defaults to to owner; urgent from a
member without requests:answer is invalid
(details.fields.priority: "urgent_needs_requests_answer"), never lowered
silently; trust:owner is the same as owner; needs_in_session is sent
as {reason, member_id, session_id?} and the hub fills display_name, avatar_url,
harness and, when not given, the live session_id. A card stays
blocked while any while_open: wait request of its holder on it is
open; blocked_by_request names the oldest open one, and the remembered
status is restored only when the last of them is answered or withdrawn.
On answer the hub records by (the answering member) and on_behalf_of
— the owner a proxy names when it records the owner's answer (§2.3);
otherwise the addressee's member id when by is not the addressee (an
admin answering what was addressed to the owner or to someone else;
cover.for when the covering member answers), null
when the addressee answered in its own right or the request was addressed
to a role or trust token (there is no single addressee; by says who answered), so who answered on whose
behalf is always visible — sets answered_at, clears
blocked_by_request on the card and restores the status the card had
before the block (in_progress, or review when it was asked after a
report), and emits request_answered to the asking instance (readable
by its whole agent) and, when set, the
on_behalf_of member. talk: true means "neither — let's talk": it is
accepted only from a human who owns the asking agent; conversation_id
is then the asker's member id (§9) and the agent reads the conversation
next; from anyone else it is invalid. A text beside talk is the
human's note on it ("let's talk, about the second point"): it is stored
as answer.text and travels with request_answered like any answer's
text; the hub posts nothing into the conversation for it. A text
beside reply_id is the same kind of note on the chosen reply.
The inbox. For every open request it shows one free-text field
above the reply buttons. Each reply button and the talk button sends
the field's text along when it holds any ({reply_id, text},
{talk: true, text}). A request without replies offers the field
with a send button ({text}) beside talk. The talk button is
labelled "Talk" in every language; it is offered only where talk is
accepted (above).
Personal requests. Some requests concern one human as the owner
of an agent profile, not the project: the notice of a soul change
(§19.1), the notice of or consent to a change of another owner's
project texts (§6.28), the
notice of a cross-owner takeover (§6.19), the notice of the agent's
outage (§15.7), of its unconfirmed Keep the Why commitment (§6.23)
and of its isolation in a project (§6.2).
The hub files them — no
caller can create one or set personal — with to:
"profile_owner:<agent id>", an addressing allowed only here, which
resolves to the human who owns that profile, whether or not that human
is a member of the project. A proposal for a human's preferences
(§19.4) concerns that human in person and is a personal request too,
addressed to: "human:<human id>", likewise allowed only here. So are
the requests an organisation files (§6.26): an invitation, and an
admin's transfer into the organisation; and the owner's digest of agent
instances that are not listening (§5.3), which concerns every project of
the human's agents at once and carries project: null. A personal request that
concerns an organisation and no project — an invitation — carries
project: null and org: {id, handle}, and the inbox shows it under
the organisation; a consent to a transfer into an organisation carries
both its project and org: {id, handle}, the receiving organisation;
every other personal request carries org: null.
Below, "the owner" of a personal request is the human it is addressed
to. A personal request is in the owner's personal inbox, not in the
project's request list:
GET /projects/{owner}/{slug}/requestsnever lists it, whatevertoand whatever the caller's scopes;GET …/requests/{id}answers it to the asker andnot_foundto everyone else in the project, owner, admins and proxies included.POST …/requests/{id}/answeron the project route is the same:not_foundfor everyone but the asker, and for the askerself_answer(409) — except a hub-filed request (below), which isnot_foundthere for every caller, the agent named inasked_byincluded — so the project route never tells anyone else that a personal request exists.- The owner reads it in
GET /requests(the cross-project inbox, withpersonal: true) and byGET /requests/{id}, and answers it withPOST /requests/{id}/answer(the body of the project route) — membership in the project not required. Nobody else answers it. - Withdrawing. Only the hub — when what it was filed for ends, as
a takeover notice when its takeover closes (§6.19); an outage notice
when the placement recovers, is stopped or deleted, the project is
archived or deleted, or the profile's owner changes (§15.7); a Keep
the Why notice when the instance confirms, is removed or replaced,
the membership ends, the agent profile is deleted,
policy.keep_the_whybecomesoptional,memory.providerbecomesnone, or the profile's owner changes (§6.23); after an owner change of an open outage or a still outstanding commitment the hub files a new notice to the new owner — and the human it is addressed to (POST /requests/{id}/withdraw{"reason"?}) withdraw a personal request. Nobody else does: the asking agent, any other agent, a holder ofrequests:answerand the project owner getnot_foundonPOST /projects/{owner}/{slug}/requests/{id}/withdraw, as on every project route for it. - Filed by the hub (
filed_by: "hub", above). No instance asked it, so its events (below) have the addressed owner asmember:when a member, and nobody else — never the agent named inasked_by, none of its instances — and wake nobody.GET …/requests/{id}on the project route isnot_foundfor every caller, the agent included, and so arePOST …/requests/{id}/answerand…/withdrawthere, since it has no asker who reads it; nobody can tell from the project routes that it exists. - Its events carry ids and status only:
request_created,request_answeredandrequest_withdrawnname{id, personal: true, status}and, on a takeover notice,takeover_id— no title, no summary, no answer and no reason — with the asking instance as audience (except a hub-filed request, above: the owner only) and the owner asmember:only when a member. Owners and admins, who read every event (§8.2), learn that a personal request exists and how far it is, not what it says; the asker reads it in full throughGET, answer included — except a hub-filed request, which has no asker who reads it (above). - The notifications of humans count and list it for the owner exactly
once, member of the project or not: every state change of it writes
an account record of the owner (#160, §19.1), and the record — never
the event's audience — makes the
inboxitem.
Delivery to the agent is by reference and by content (a personal
request excepted, above, whose events carry ids only): the
request_answered event carries the whole request as GET returns it,
answer included, so an agent that lost the request from its context
(compaction) acts on the event alone; wait prints it as it is, the
resume packet lists it under answered_since_cursor. Nothing is re-typed
by a relay.
Forwarding and claiming a decision request. The recipient of a
decision request may pass it on: to another member of the admin team
with trust admin (§2.3), or to the admins as a group, where the first admin who claims
it answers it. The jour fixe (§20.6) files its proposals this way to
the owner, but forwarding is open to every decision request. A request
with action goes only to a human with trust owner; a forward to the
admins, to an agent or to anyone else is invalid
(details.fields.to: "action_needs_owner").
A request always has exactly one current holder. It starts as to
(a member, a role or trust token, or owner). After the first forward it is a
member id, or trust:admin while the request is open to the admins and
nobody has claimed it. When the holder leaves the project, the request
goes back to the admins (holder: trust:admin). A request of an owner's
round (§20.7) and a proxy's call (§2.3) go back to their addressee
instead; one of an owner's round is withdrawn when its addressee has left
too.
POST /projects/{owner}/{slug}/requests/{id}/forward {"to": "<member id>" | "trust:admin", "reason"?: "…"}
POST /projects/{owner}/{slug}/requests/{id}/claim
- Forward. The current holder may forward the request. For a
holder token, that is any member the token resolves to; for
trust:admin, it is whoever claimed the request.tois a member the tokentrust:adminresolves to (§2.3), other than the holder, the request's original addressee, ortrust:admin. Anything else isinvalid(details.fields.to:not_admin,self,not_a_member). Onlydecisionrequests are forwarded (invalid,details.fields.class: "not_forwardable"). A request that is not open isrequest_not_open. Anyone who is not the holder isforbidden(details.reason: "not_request_holder"). - Claim. While the holder is
trust:admin, any member that token resolves to, human or agent, claims the request withPOST …/claim. The first claim wins and becomes the holder. A later claim isrequest_claimed(409,details.holder), and anyone else — a proxy included — isforbidden(details.reason: "not_admin"). - Agents. An agent becomes a holder only if it is an admin agent (§2.3), by a
forward to it or by its own claim. For a request with
humans_only: truethe hub enforces the rule at every entry: - creation: a
tonaming an agent isinvalid(details.fields.to: "humans_only"); - forward: it goes only to a human or to
trust:admin, and a forward to an agent isforbidden(humans_only); - claim: in the
trust:adminpool only human admins claim it, and an agent's claim isforbidden(humans_only).
An agent's answer on such a request is forbidden (humans_only).
The §2.3 rule holds throughout: an agent never holds or answers a
request whose effect only a human decides.
- Answering. Once a request has been forwarded, only its current
holder answers it. Anyone else, the original addressee and other
holders of requests:answer included, gets forbidden
(not_request_holder). self_answer still applies: nobody holds or
answers a request it asked, so a forward to the asker is invalid
(self). An answer records by as always, with on_behalf_of set to
the original addressee.
- Recorded on the request. forwarding lists every step as {action:
"forwarded" | "opened" | "claimed", from, to, by, reason, at}, oldest
first, and holder names the current one. GET …/requests?to=me
lists a request for its current holder, and for every admin while the
holder is trust:admin. It no longer lists it for an earlier holder.
- Events. request_forwarded and request_claimed (§8.2) go to the
asker, the old and the new holder, and the original addressee. A
forward to a member wakes it by its agent's rule (§6.18). An opening to
trust:admin wakes no agent, since it would wake every admin agent at
once. Admins find it in their resume and inbox, and humans get the
inbox notification (§18.1). A claim wakes nobody.
- Personal requests are never forwarded: both endpoints answer
not_found for them, as every project route does.