This is the whole receive side of osFoundry's agent network, as the public MCP endpoint declares it. The output is from tools/list on https://api.osfoundry.io/api/v1/external-mcp, which answers without a token:
wait_for_messages: required=[] readOnly=True openWorld=False scope=['osfoundry:read'] {"after_sequence": {"type": "integer", "minimum": 0}, "timeout_s": {"type": "integer", "minimum": 0, "maximum": 50}, "limit": {"type": "integer", "minimum": 1, "maximum": 50}} send_agent_message: required=['to', 'body', 'idempotency_key'] readOnly=False openWorld=True scope=['osfoundry:write'] {"to": {"type": "string"}, "body": {"type": "string", "maxLength": 20000}, "thread_root_id": {"type": "string"}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}} Three parameters and no subscription, webhook or socket. Claude Code and Codex are MCP clients. They make requests, and nothing can call into them. So when Codex wants to hear from Claude Code, it has to be the one asking, and wait_for_messages is built to make that asking cheap. The description says it returns messages "as soon as one arrives, or an empty list after timeout_s (max 50)", and that "without after_sequence it resumes where your last call stopped".
Below, each part of that contract gets taken apart, then rebuilt as a small Python server plus clients and measured. There's no claude or codex binary on this Mac, so both agents are simulated by threads calling the rebuilt server over HTTP. osFoundry's server code isn't public. The rebuild only copies the published contract: tool names, limits and descriptions. Everything is in code/ (realprobe.py, netsim.py, and their outputs).
What is a message "addressed to you"?
The description lists three routes: "by to, by @handle, or in a direct message". The handle comes from claim_connected_profile, whose schema pins it down:
"handle": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,30}$"} That's 2 to 31 characters, lowercase, digits and hyphens, and the description reserves maestro, direct, all, everyone, here and channel. The rebuild enforces the same rule:
claim alice-claude len=12 -> {'handle': 'alice-claude'} claim a len=1 -> {'error': 'invalid handle'} claim Alice len=5 -> {'error': 'invalid handle'} claim here len=4 -> {'error': 'invalid handle'} claim xxxxxxxxxxxx.. len=32 -> {'error': 'invalid handle'} claim xxxxxxxxxxxx.. len=31 -> {'handle': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'} For the @handle route, the osFoundry web client ships a copy of the server's matching rule, so the composer and the server read a draft the same way. It is bodyMentionsHandle in apps/web/src/screens/message/lib/agent-presence.ts:
new RegExp(`(?<![\\w.-])@${escapeRegExp(handle)}(?![\\w-])`, 'i') The same regex in Python, against bob-codex:
'@bob-codex is the migration done?' -> True 'ask @bob-codex.' -> True '(@bob-codex)' -> True '@Bob-Codex hi' -> True 'mail bob@bob-codex.dev' -> False '@bob-codex-2 hi' -> False '@bob-codex_x hi' -> False '@bob-codexy hi' -> False The lookbehind keeps an email address from counting as a mention. The lookahead keeps @bob-codex from matching @bob-codex-2, which matters when handles are minted with number suffixes. A trailing period is fine. Case doesn't matter.
Why does @handle in the body "not wake anyone"?
send_channel_message says so in as many words: "Writing @handle in the body does not wake anyone." Yet wait_for_messages delivers by @handle. Both are true because they talk about two kinds of agent.
An osFoundry agent is woken: posting starts a run on its owner's device, and that run costs money on someone's key. That only happens for handles in to, and only if the agent "is reachable and owned by the person who connected you or opted in to answer agents in that channel". An outside agent like Codex is never woken. It pulls. A body mention lands in its wait_for_messages result the next time it asks, and nothing is started on its behalf.
So to is the deliberate, checked address, and the result says "who was delivered and who was skipped and why". A body mention is something the reader may notice. If you want an answer, put the handle in to.
Why long-poll, and why 50 seconds?
The rebuild compares three ways for the receiving agent to ask. Each mode runs for 60 seconds. The same 10 messages are sent at fixed random times (seed 7). Latency runs from the server storing a message to the receiver holding it.
long-poll timeout_s=50 delivered=10/10 median= 1.0 ms max= 4.0 ms wait_for_messages calls in 60 s=10 short-poll every 2 s delivered=10/10 median= 535.5 ms max= 1717.4 ms wait_for_messages calls in 60 s=29 short-poll every 10 s delivered=10/10 median= 4593.4 ms max= 8495.5 ms wait_for_messages calls in 60 s=5 idle hour, no messages: long-poll 3600/50 = 72 calls; 2 s poll = 1800 calls; 10 s poll = 360 calls | Mode | Median latency | Calls in the 60 s run | Calls in an idle hour |
|---|---|---|---|
Long-poll, timeout_s 50 | 1.0 ms | 10 | 72 |
| Short-poll every 2 s | 535.5 ms | 29 | 1,800 |
| Short-poll every 10 s | 4,593.4 ms | 5 | 360 |
Short polling makes you trade latency against calls. Long polling gets the best of both. With a long-poll, calls follow the messages (10 messages, 10 calls), and an idle agent costs one call every 50 seconds.
Those numbers are local. The real endpoint's floor cost is the network. From this Mac, 20 unauthenticated tools/call requests to the real endpoint (each a fresh TLS connection, rejected with 401 before any work) took a median 740 ms, p90 1,203 ms. The responses came through CloudFront (via: … cloudfront.net, POP NRT12-P8) and carried x-ratelimit-limit: 2000. A 2-second poll would spend 1,800 of those requests an hour on nothing.
The 50 has to fit under the timeouts around it. A request that sits open has to outlast nothing in the path: not the CDN's wait for the origin, not a load balancer's idle timer, not the client's own tool timeout. Codex's MCP tool_timeout_sec defaults to 60 seconds. A 50-second cap keeps an empty answer arriving before any of those cut the request off, and an empty answer is a clean signal to ask again.
What happens to a message whose response gets lost?
"Without after_sequence it resumes where your last call stopped." That's convenient: an agent that forgets its cursor still doesn't see repeats. But the server can only know what it sent, not what arrived. In the rebuild, the server moves its stored cursor when it returns messages. Then every fifth response is thrown away on the client side, which is what a dropped connection or a CLI restart looks like:
no after_sequence (server resumes) dropped responses=4 processed=16/20 missing=['m4', 'm9', 'm14', 'm19'] after_sequence = last processed dropped responses=4 processed=20/20 missing=[] With the server's resume point, the four lost responses are four lost messages. With the client sending the last sequence it actually processed, the same four are delivered again on the next call. The published description doesn't say when osFoundry's server moves its cursor, and the rebuild doesn't answer that. It shows why the parameter exists. If losing a message matters to you, keep the highest sequence you've processed and pass it as after_sequence.
Why is idempotency_key required on sends?
The sender has the mirror image of that problem. A send_agent_message whose response is lost may or may not have been stored. Retrying without a key can produce a duplicate. Retrying with the same key can't:
same key x3 -> new messages: 1 fresh key x3 -> new messages: 3 The schema requires 8 to 128 characters, and the description asks for "a stable idempotency key for retries". The flip side shows up in the rebuild as well: a new question sent under an old key is treated as the old message. Derive the key from the content and the attempt, such as alice-claude-orders-0001, not from a counter that resets when the CLI restarts.
What do 200 idle agents cost the server?
The rebuild parks 200 agents in wait_for_messages with timeout_s 5, then sends one message to agent-137:
woken with a message: ['agent-137'] latency=46.8 ms; the other 199 returned empty at timeout_s=5 Only the right agent got the message. But latency went from 1 ms to 46.8 ms, because the rebuild uses one threading.Condition and notify_all(). Every parked request wakes up and rescans the log to find out it wasn't the one. It's fine at 200 and bad at 20,000. A real server wants one wait queue per recipient, so a send wakes only the requests that can use it.
If you wire this up yourself
- Keep a client-side cursor. Pass
after_sequencefrom the last message you finished handling, not the last one you received. - Put the handle in
towhen you need an answer. Treat a body @mention as a hint, and read the delivered/skipped list in the send result. - Pick a
timeout_sunder every timeout between you and the server, including your MCP client's tool timeout. 50 works under 60. - Make idempotency keys stable across retries and unique across questions.
- Remember that each empty
wait_for_messagesresult is also a model turn for an agent looping inside Claude Code or Codex. 72 a quiet hour is cheap in requests, but not free in tokens. Stop the loop when nobody needs the agent.

