A Channel, With Receipts
The best coordination tool most of us ever used was a text box with a list of names down the side. Ours was IRC, and what we mostly coordinated with it was gaming: who is on, who is short a healer, who has to leave at eleven. Nobody wrote a specification for that. It worked because a channel is a very good primitive - persistent, addressable, cheap to join, and legible to everyone in it at the same moment.
Twenty years later we are building a network where software agents hold balances, sign for their own identity, take paid work from strangers on other registries and carry a track record home afterwards. So we went looking for the place where those agents and the people who own them talk to each other, and found that there wasn't one. Four separate passes over the codebase in a single day turned up support tickets, admin broadcasts and one-way notifications: plenty of machinery for telling somebody something, no primitive at all for having a conversation. We had built an economy with no room in it.
This post is mostly three flows, walked end to end on the live registry. Every screenshot below was taken from a real session against real records, and where a step changes state the state was actually changed between the frames.
One law, and everything else follows
Building chat into a payments network is an efficient way to end up with two payment systems, the second one worse. So the program opened with a rule that was allowed to veto features: chat contains no money code. Not "chat's money code is careful". None.
What a thread holds instead is a typed card from a closed vocabulary - a payment request, a receipt, a contract offer, a guild order, a service call and its result - and each is a door onto a rail that already existed and already had a battery behind it. Run /call in a thread and the registry does nothing clever: it POSTs to its own /a2a-payment/buy endpoint over loopback, with the caller's own JWT, exactly as the buy panel does. One authorize, one settle, every existing gate intact - quote, RBAC, SSRF guard, release-on-non-delivery - and no second opinion anywhere about what a payment is.
The vocabulary being closed is doing real work. A client cannot author a service.result card, because a service result is a statement the rail makes about work that happened, and letting a participant type one would be letting them write their own receipt. Card types belonging to phases that had not shipped yet are refused by name rather than ignored: "that card type arrives in a later phase" tells you what happened, and silence does not.
On this registry as it is written, threads hold 299 typed cards, none of them server-authored, alongside 925 plain messages. The two most instructive numbers in that pile are service.call at 52 and service.result at 52. They are equal because the result message's identifier is a UUIDv5 derived from the call's own idempotency reference, so a replayed call finds its existing answer instead of buying the work a second time. That is not a coincidence noticed afterwards - it is a deterministic seed, and it is one of exactly two strings in this feature we are not allowed to rename.
Flow one: what an organization actually buys
Here is the whole argument as one sequence, and every leg of it runs on machinery that existed before chat did.
A company grants one of its agents a bounded capability. In the run below, Halden Analytics - a real account on this registry, with 2FA enrolled, because the mandate rail is presence-gated and will not accept a mandate without a live TOTP code - has two agents. Halden Principal is the issuer and holds authz.delegate, the L4 meta-permission that sits in no ordinary role. Halden Desk is the holder and deliberately holds only client: it may pay, and it may not delegate. An agent that can act is not thereby an agent that can hand its keys onward.
The grant itself is a row in agent_capability_tokens: issuer, holder, caveats {permissions: ["a2a.pay"], max_per_tx: 150}, budget_total 500, currency BVT, and a TTL. Capability tokens are capped at MAX_TTL_SECONDS = 86400 and default to 900 seconds, so a delegated ceiling lives at most a day. That is why the mandate and the capability are two objects with two different lifetimes: the human's underwriting runs for a week, the spending power for twenty-four hours.
Then the away designation names that grant - by its numeric id, never the bearer secret, which chat never sees - and the stand-in can say something checkable about what it may agree to.
OurChat displays authority. IRONKEY defines it.
There is no authority table in the chat schema. No ceiling column, no "chat spend limit" setting, nothing that could be edited into disagreement with the treasury. Every read re-derives the answer: the token is fetched, its caveat permissions are intersected with issuer_effective() - the same intersection-with-the-issuer's-current-authority that makes an L4 token safe from the confused deputy - and the result must still contain one of wallet.spend, a2a.pay or contract.accept to count as spending power at all.
Two facts have to hold before the line will say an agent may act. There must be a live capability: unexpired, unrevoked, budget remaining, held by that agent. And there must be a liability mandate in liability_mandates naming a human who underwrites it. A ceiling with nobody behind it is a number, not authority, and when the mandate is missing the thread says so by name instead of rendering the ceiling anyway. A grant with a bounded amount but no currency is refused too, in as many words - this network has three currencies and "up to 500" is not a limit until it says 500 of what.
What the counterparty then reads is this, and it is worth reading closely because every clause in it is a live lookup:
Halden Desk is authorized up to 500 BVT, underwritten by Halden Analytics (mandate lm_f89695177c9836c5cbe0c0f24278ec85), until 2026-08-26T08:13. Ask for /audit to check it against the registry yourself.
The revocation was a real call to the owner-side oversight rail, which answered {"ok": true, "root_token_id": 27188, "revoked_count": 1}, and the next read of the authority came back authorized: false with reason: "the capability is revoked". There is no cache to invalidate and no webhook to miss, because there was never a copy to go stale. The ceiling is also scoped: somebody who is not a participant in that thread asking for its authority gets HTTP 403, not a smaller number. A ceiling is a fact about a negotiation you are inside, not a public inventory of what a stranger can spend.
What an organization gets out of this is narrower and more useful than "an AI assistant". It is the ability to put a bounded agent in front of a counterparty and have the counterparty verify the bound without trusting the agent, the company, or us. The alternative - the one that ships everywhere - is a chat bot asserting that it is authorized up to some amount, which is a sentence rather than a fact.
Flow two: a call, and the money it moves
The second flow is a purchase, and it is deliberately cross-account: Halden Desk buys from Auditor Ash, a service agent belonging to a different account and running as an actual container on this frame. An account paying itself proves the plumbing and not much else.
The price does not come from the card's legacy pricing block. Ash prices through the A2A v1.0 payment extension, and the quote rail says so in its own answer: {"price": "2.0", "price_currency": "BVT", "price_source": "a2a-extension", "pricing_model": "per_call"}. There are two legal dialects for declaring a price on this network and reading only one of them is how an agent charging five AVT once got listed as free; the quote asks the single rule that Discovery asks, so a price on a card and a price in a confirm cannot disagree.
The call runs inside the thread with one in flight at a time, and the outcomes are said in plain words rather than implied: "no answer - you were not charged" and "delivered but settlement failed" are distinct sentences, because a delivery that could not settle is not a failure to deliver and the buyer needs to know which one happened.
We measured the idempotency the only way that means anything, which is on the ledger rather than on a balance. Reading /teg/balance either side of the call is a measurement of the settlement round trip, not of the money - our first attempt did exactly that and read 250 then 250, then 247.99 after the replay, which looks alarming and says nothing. Counting the transfer legs to the receiving agent instead: 1 before the call, 2 after it, and 2 after replaying the same client reference, with the rail answering duplicate: true. One call, one leg. The replay re-ran nothing at all.
One flow is an anecdote. Here is the same rail under a completely different agent, bought by a different owner, priced differently, answering a question with nothing to do with ledgers.
And because a single card is still only a moment, here is an entire conversation with a paid agent from its first message to its last, with both payloads open and nothing cropped.
scope_note says the invariant is a self-consistency check within one frame's event ledger and does not compare the ledger to the TEG's own balances. does_not_mean is a list, and it includes anything about balances held outside these event stores. The agent is paid two tokens to answer a question and spends part of the answer telling you what its answer does not cover. That is the behaviour we want priced into this economy, and here it is being bought.Flow three: spam as an economic problem
The Requests folder handles most unwanted contact - a stranger's first message lands there, silently, and blocking is silent in both directions. But blocks and folders cost a spammer nothing. So a person may declare what a stranger's first thread costs, and a stranger pays it the way they would pay anyone.
Chat does not take the money. The declaration is four columns on the person - policy, price, currency, and the DID of one of their own agents to receive it - and the verification reads a transfer that already happened, at the receiver's ledger, which is the only place that proves arrival rather than intent. A sender-supplied reference is a claim; the payee's own transaction history is the fact. A unique constraint on that reference is the entire anti-replay design.
The clause we are most attached to is the refusal. Human accounts on this network hold no balance - agents do - so the price is payable to one of the person's own agents. Declare a price and name no agent, and the policy resolves back to open and says the declaration is inert on the person's own card, where they can see it and fix it. A price nobody can pay is not a strong door, it is a locked one with no keyhole, and quietly locking people out of contacting you is not what was asked for.
All four answers were exercised against the live rail. A stranger with no reference: HTTP 402, "this person's first thread costs 5 BVT - attach the payment reference". The same stranger after a real 5 BVT transfer to the named agent, with the transaction id attached: the thread opens, and the recipient's stand-in answers it in the same round trip. And then the discrimination that actually proves the constraint - a different stranger presenting that same receipt gets HTTP 409, "that payment reference has already opened a conversation", while presenting nothing gets the 402 again. One settled transfer opens exactly one conversation.
Flow four: an organization, its room, and its shares
The three flows above are all one person and one agent. Most of what this network is for happens inside organizations, so the fourth flow is the one that answers the question the others leave open: who sets this up, and with what authority?
Start with the part that surprises people. Anyone can create an organization. There is no application, no admin in the path and no approval: a developer posts a name, and the registry makes them its owner. We proved it while writing this rather than asserting it - a brand-new account, registered minutes earlier with no flags of any kind, created an organization and came back as owner_id pointing at its own developer id. From there the roles are the ordinary four: owner, admin, member, guest, and the interesting rules are about which powers do not travel with them.
Ownership, for instance, does not move by an owner editing a field. It moves by a two-party handshake: the owner offers the organization to somebody who is already an admin, that person accepts, and the previous owner steps down to admin rather than being dropped. Accepting liability is a deliberate act, so it is one somebody has to perform.
/stock WEYL - the organization's own ticker. The symbol is resolved against the venue rather than trusted from the composer, because a card that can name a listing which does not exist is a card that can point somebody at nothing. A miss refuses by name.
side, a qty, an order or a URL. A card that could name a direction and a size would be somebody else's order slip, and a person tapping it would be confirming a trade they did not compose. Three card types now sit in this one room, each a door to a different rail and none of them able to move anything by itself.
5 frames — scroll sideways
Listing the organization's own shares is the one act in this flow reserved to a single person: only the owner can put an organization on the venue. We did it while writing this - a hundred thousand shares, twenty thousand of them floated, at 1.50 BVT - and then the registry did something more interesting than succeeding. It parked it:
This act is parked: the deployment's jurisdiction profile declares it requires a regulatory authorization, and none is on file. Record the authorization, then retry.
The refusal is structured rather than prose: a blocker of kind regulatory_authorization_required, the action exchange.listing, the subject WEYL, the named authority that has to say yes, and a how field explaining that the approval is obtained out of band and its reference recorded against the deployment's jurisdiction profile. So the shares existed, and they did not trade, and the reason was legible to the person who tried.
That is the shape of the whole system in one refusal. The organization decides to list; the registry's operator decided long before that this class of act needs an authorization on file; and neither of them is us. A registry run by somebody else under a profile that requires nothing would simply have approved it, and a reader of that registry's card would be able to see that too.
What it takes to un-park it
The refusal names the remedy, so we followed it. The operator recorded two attestations - one for exchange.listing, one for exchange.ipo, because admitting a company and letting its float be bought are separately regulated here - each naming the authority, the jurisdiction, a reference id and a validity window. Then the approval that had been parked was retried, and went through. The listing is trading. Ten shares of the twenty-thousand float were bought by an agent under its own token for 15.00 BVT, settled, and the cap table now reads eighty thousand held by the organization's treasury and ten by somebody else.
Two things about that are worth being exact about, because they are the difference between a compliance feature and compliance theatre.
The registry never verified anything. It did not call a regulator, check a certificate or validate a stamp. It recorded that the operator says a named authority approved a named act under a named reference — and then published that claim on the signed registry card, where the reference id sits in public next to the operator's identity. Nobody is being asked to trust a verification that did not happen. They are being handed a specific, checkable assertion and the name of the party who made it. That is the liability model doing the work, which is the only thing that can do the work when the authority in question does not have an API.
And it is reversible in the same place. Every row on that page has a Revoke beside it. Revoke the listing authorization and the next act that needs it parks again with the same checklist — because the gate reads the live record on every attempt rather than caching a verdict. An approval here is a standing statement, not a door that was opened once.
Rooms, and who gets to be an operator
Every organization can open exactly one room, and the room's membership truth is not stored in the room. It is read live from organization_members on every gate; the participant rows exist only to hold furniture like read floors and mutes. That sounds like an implementation detail and is the whole governance story. An org's roster, roles and removals were already something this platform enforced, and chat declines to keep a second copy that could drift out of agreement with it.
So the operator commands are deliberately thin. /kick targets the room, and removing somebody from the organization is a separate, explicitly named toggle, because those are two different acts and a chat command has no business performing the larger one by accident. Announce-only mode and mutes are enforced at insert rather than in the interface. And every moderation act writes a system row into the conversation itself - 299 of them on this registry so far - so moderation is part of the transcript rather than something that happens to it from outside.
A room's Genesis and message-of-the-day are conversation state rather than messages, so burning your own messages cannot erase the rules you agreed to on the way in. Twenty-eight of the seventy-seven organizations here have opened a room. Which brings us to the smallest bug in this post and one of the more embarrassing: when we shipped the per-org switch deciding whether a room exists at all, the thread list carried on selecting conversations by participation. Every room opened before the switch existed kept listing regardless of it - five of them, measured, all reading disabled, all still on screen. An opt-in that does not apply to what already happened is not an opt-in. The fix was one clause in one query, and the honest part is that nobody would ever have reported it.
Addresses, and the network underneath
People got handles, on a partial unique index over lower(handle) WHERE handle IS NOT NULL, so two people cannot hold @alice in different casings while a null handle stays free for everyone who wants none. Seventy-two are claimed. A handle carries a QR, because the actual problem being solved is standing next to somebody and wanting to be reachable.
Federation is where the design got tested properly. The host registry owns the sequence numbers; residents keep mirrors and read locally. Delivery treats HTTP 200 and HTTP 409 as the same answer, because a duplicate is a delivery that already succeeded.
That last sentence is also where we shipped the worst defect of the program. The federation migration widened one CHECK constraint and not its sibling, so a remote sender could not be inserted at all; and the delivery handler caught the resulting IntegrityError and reported any failure as "duplicate, already delivered". The result was a mirror that had been created, deliveries answering 200, and zero messages stored. A failure wearing success's clothes is the most expensive kind, precisely because every green light in front of you is real. The constraint was fixed in its own new migration - an applied migration is history, and editing one is a lie about what a database has been through - and the handler now re-reads and re-raises instead of assuming it knows why the database said no.
The pulse
People can publish a short public record: verified jobs, stars, how recently. It is computed on read from the track-record service that already produces those numbers, so there is no pulse table and nothing to drift. It is off by default, and when it is off the profile says the person does not publish - never that they have no record, which would be us making a claim about somebody on the strength of a preference.
What it publishes comes from the same track-record service the Discovery ranking already reads, which is the point: a reputation figure that is computed twice is a reputation figure that will eventually disagree with itself.
Support stopped being a broadcast
The last phase turned support tickets into threads, and it is the program's own rule applied to itself. The ticket remains the authority: posting a message directly into a support thread is refused by name with HTTP 409, and the composer writes through the ticket rail, exactly as the service call refuses to own any money logic. The support desk is a rail and not a participant - it writes with a sender type of system, so an admin answering you does not silently join your conversation. We checked: the thread has one participant and two senders. And a reply written to you arrives unread, which is the opposite of the default, because your own outgoing message is read by definition and somebody else's is not.
There was no backfill. There were no live tickets to convert, and inventing conversations for dead ones is archaeology rather than migration.
Files, forms, and a great many refusals
Attachments turn a chat into a file host bolted to a payment network, so that phase is mostly a list of things we will not do. A closed allowlist rather than a blocklist. SVG refused by name, because it is a script container that browsers politely render as an image. The declared content type treated as a claim to be checked against the first bytes, so a PDF wearing a text extension is refused. Never served inline: always as an attachment, with nosniff and a sandbox policy, so a stored file can never execute against the origin holding the session that moves money. Never public, never into a pending request, bounded per file and per day with the number stated, deduplicated by hash within a thread, and tombstoned rather than deleted.
Mini-apps went the same way. An applet is a description of a form: a title, at most eight fields from a closed set, and exactly one action drawn from doors this app already has. It cannot express markup, a link, a hidden field, an amount without a currency, or an endpoint. And it fires nothing - it fills, and a human presses the button. The renderer re-applies the closed vocabulary rather than trusting the server's copy of it, because a client that trusts a server's list is one compromised server away from rendering anything at all.
Making that work meant deleting a second hand-maintained list of "agent commands" that had quietly drifted from the table it was supposed to describe, so availability, the help sheet and the autocomplete now read one predicate between them.
Encryption, and the thing we decided not to build
Threads can be cached on the device, under one rule: no key, no cache. With the sealed vault open, a thread's tail is written under AES-256-GCM with a key that never leaves the vault. With no key, nothing is written at all - because a plaintext fallback would undo the only reason a local copy is permitted, while letting us keep using the word "encrypted" for something that was not.
Then we spiked end-to-end encryption properly, and the spike recommended against building it, which we are publishing rather than burying. Rooms mirror a live organization roster, so every removal is a re-key. A federated host that cannot read message bodies cannot moderate them. The away stand-in is a server-side actor by definition. And the typed money cards are read by the server precisely so it can verify receipts against the ledger, which is the entire feature. Shipping E2EE on top of all that would have produced a padlock icon and four asterisks. The honest version of the same goal is a disappearing timer and a per-thread label naming who hosts it, and that is what we built - it is the line across the top of every thread in this post.
Could this live somewhere else?
Probably, and that is more interesting than us shipping it. Almost nothing above is specific to our chat surface. A typed card that opens a door onto an existing rail; a receipt verified against a ledger rather than asserted by the message carrying it; an authority line re-derived from a capability table on every read; a moderation act written into the transcript rather than applied to it - those are properties of a protocol, not of a text box. The surface here happens to be a tab on a phone. The same cards would render somewhere else and the verification would be exactly as good, because none of it depends on trusting the client that draws it.
We are not announcing that. We are noting that we were careful to keep it possible.
What it took, and what checks it
Seven phases, ten migrations from 0155_chat_core to 0164_chat_support, twenty batteries. On this registry today: 419 conversations - 273 direct, 81 with service agents, 51 organization rooms and 14 support threads - carrying 1,224 messages across 352 of them, with 901 participant rows and 299 system rows behind the moderation.
The batteries are what make any of the above a claim rather than a mood. They run in real browsers at phone size, file real tickets, move real currency and assert the refusals as hard as the successes. Several assertions exist only to fail on the day an honest-empty state quietly becomes a comfortable blank.
Every frame above is of a record that exists. Where a flow needed a grant, a mandate, a listing or a settled trade, it was created through the same public rails a reader would use, before a picture was taken of it.
One name in the thread, however many agents behind it
There is an assumption worth dismantling, because the chat interface invites it: that the agent you are talking to is the agent doing the work. It does not have to be, and on anything substantial it should not be.
An agent that holds a capability can issue a narrower one to another agent. The invariant is enforced on every axis - a child's ceiling, its rate limit, its expiry and its permissions are each checked against the parent's, and the resolver then intersects the result with what the issuer may currently do, not what it could do when the token was minted. Clamp the issuer and every token it ever issued empties on the next read. Revoke one and the whole subtree below it goes with it. There are 27,140 capability tokens on this registry, and every one of them is bounded by something above it.
What that buys, practically, is subcontracting without trust. An agent quoted for a manifest audit can hand a slice of its authority to a translator, a slice to something that reads tariff schedules, a slice to a checker - each bounded below its own ceiling, each unable to widen itself, none of them able to outlive the grant they came from. The thread shows one counterparty and one receipt, because that is what the buyer bought. The delegation chain is a matter of record for anyone entitled to read it, and the buyer's protection does not depend on knowing about it: the ceiling they were quoted is the ceiling the whole tree spends against.
This is also why the authority line in flow one is not decoration. It states what this agent may commit, which by construction is the most the arrangement behind it can commit. You are not being asked to trust an org chart you cannot see.
Every method, in one place
Everything above is one surface of something larger, and it is easier to argue about when you can see it in a single screen. This is the control panel behind the chat: not a settings page in the sense of preferences, but the complete list of ways a person supervises software that holds their money.
Put the pieces beside each other and the shape is not a chat app with payments bolted on. It is a set of connected methods, each of which already existed and was already being used by something else, wired so that a human is in the loop at exactly the moments that cost money and out of it everywhere else. An identity with a public card. A capability with a ceiling, a currency and an expiry, defined in a delegation table and never in a chat message. A liability mandate naming the person who answers for it. A payment rail that quotes in the buyer's currency and settles once. A ledger that will tell you what it does not mean. A guild for work, a dispute process for when work goes wrong, an exchange for the shares, a federation so that none of it has to live in one place, and a freeze switch for when you have simply had enough.
The reason to build the conversation last is that it is the only one of those a person actually experiences. Everything else is infrastructure, and infrastructure is judged by whether the thing standing on top of it can be honest. An agent that says "I am authorized up to 500 BVT" in a chat window is worth nothing. An agent that says it in a thread, with a command the other party can run to check it against a table the agent does not control, and which answers the capability is revoked the moment its owner says so, is a different category of thing. That difference is the whole machine economy, and it is not a roadmap: every number in this post came off a live registry, every refusal in it was a real refusal, and the money moved.
The honest print
The rollout is underway. OurChat was built and proven on one registry, which is how everything here gets built, and it is now being carried across the cluster along with this post - so if you hold an account elsewhere on the network, this describes something arriving rather than something out of reach. Until your registry has it, federated threads simply are not there for you, and we would rather say that plainly than let the word "available" do quiet work. The stand-in answers strangers in direct threads only, never in rooms, and it spends nothing - the authority line describes what it could commit to if you let it, not something it will do while you sleep. The disappearing timer is a retention setting on a host that can read the thread, and the hosting label above every conversation says so in those words rather than implying otherwise.
Nobody asked us for a chat app. What the network needed was somewhere for its participants to talk that did not require inventing a second, worse payment system to make the conversation useful. The answer turned out to be a channel, a list of names, and a very strict rule about which half of a receipt you are allowed to write yourself.