Questo è l'intero lato di ricezione della rete di agenti di osFoundry, così come lo dichiara il suo endpoint MCP pubblico. L'output viene da tools/list su https://api.osfoundry.io/api/v1/external-mcp, che risponde senza 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}} 

Tre parametri e nessuna subscription, webhook o socket. Claude Code e Codex sono client MCP: fanno richieste, ma nessuno può chiamarli. Quindi se Codex vuole sapere qualcosa da Claude Code, deve essere lui a chiedere, e wait_for_messages è fatto apposta perché chiedere costi poco. La descrizione dice che restituisce i messaggi "appena ne arriva uno, oppure una lista vuota dopo timeout_s (max 50)", e che "senza after_sequence riprende da dove si è fermata l'ultima chiamata".

Di seguito ogni pezzo di quel contratto viene smontato, poi ricostruito come un piccolo server Python con i suoi client, e misurato. Su questo Mac non c'è nessun binario claude o codex, quindi entrambi gli agenti sono simulati da thread che chiamano il server ricostruito via HTTP. Il codice server di osFoundry non è pubblico. La ricostruzione copia solo il contratto pubblicato: nomi dei tool, limiti e descrizioni. Tutto è in code/ (realprobe.py, netsim.py e i loro output).

Cos'è un messaggio "indirizzato a te"?

La descrizione elenca tre strade: "tramite to, tramite @handle o in un messaggio diretto". L'handle arriva da claim_connected_profile, il cui schema lo definisce con precisione:

"handle": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,30}$"} 

Cioè da 2 a 31 caratteri, minuscole, cifre e trattini, e la descrizione riserva maestro, direct, all, everyone, here e channel. La ricostruzione applica la stessa regola:

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'} 

Per la strada dell'@handle, il client web di osFoundry include una copia della regola di matching del server, così editor e server leggono una bozza allo stesso modo. È bodyMentionsHandle in apps/web/src/screens/message/lib/agent-presence.ts:

new RegExp(`(?<![\\w.-])@${escapeRegExp(handle)}(?![\\w-])`, 'i') 

La stessa regex in Python, contro 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 

Il lookbehind evita che un indirizzo email conti come menzione. Il lookahead evita che @bob-codex corrisponda a @bob-codex-2, cosa importante quando gli handle vengono generati con suffissi numerici. Un punto finale va bene. Maiuscole e minuscole non contano.

Perché un @handle nel corpo "non sveglia nessuno"?

send_channel_message lo dice esplicitamente: "Scrivere @handle nel corpo non sveglia nessuno." Eppure wait_for_messages consegna per @handle. Sono vere entrambe perché parlano di due tipi di agente.

Un agente osFoundry viene svegliato: pubblicare avvia un'esecuzione sul dispositivo del suo proprietario, e quell'esecuzione costa soldi sulla chiave di qualcuno. Succede solo per gli handle in to, e solo se l'agente "è raggiungibile e appartiene alla persona che ti ha connesso, oppure ha accettato di rispondere agli agenti in quel canale". Un agente esterno come Codex non viene mai svegliato. È lui che va a prendere i messaggi. Una menzione nel corpo finisce nel suo risultato di wait_for_messages la prossima volta che chiede, e non viene avviato nulla per conto suo.

Quindi to è l'indirizzo voluto e verificato, e il risultato dice "chi ha ricevuto, chi è stato saltato e perché". Una menzione nel corpo è qualcosa che il lettore potrebbe notare. Se vuoi una risposta, metti l'handle in to.

Perché il long-poll, e perché 50 secondi?

La ricostruzione confronta tre modi in cui l'agente ricevente può chiedere. Ogni modalità gira per 60 secondi. Gli stessi 10 messaggi vengono inviati in istanti casuali fissi (seed 7). La latenza va dal momento in cui il server salva un messaggio a quando il destinatario ce l'ha in mano.

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 
Modalità Latenza mediana Chiamate nella prova da 60 s Chiamate in un'ora di inattività
Long-poll, timeout_s 50 1.0 ms 10 72
Short-poll ogni 2 s 535.5 ms 29 1,800
Short-poll ogni 10 s 4,593.4 ms 5 360

Con lo short polling devi scegliere tra latenza e numero di chiamate. Il long polling ti dà il meglio di entrambi. Le chiamate seguono i messaggi (10 messaggi, 10 chiamate), e un agente inattivo costa una chiamata ogni 50 secondi.

Questi numeri sono locali. Sull'endpoint reale il costo minimo è la rete. Da questo Mac, 20 richieste tools/call non autenticate all'endpoint reale (ognuna con una nuova connessione TLS, rifiutata con 401 prima di qualsiasi lavoro) hanno impiegato una mediana di 740 ms, p90 1,203 ms. Le risposte passavano da CloudFront (via: … cloudfront.net, POP NRT12-P8) e riportavano x-ratelimit-limit: 2000. Un poll ogni 2 secondi sprecherebbe 1,800 di quelle richieste all'ora per niente.

Il 50 deve stare sotto i timeout che lo circondano. Una richiesta che resta aperta non deve sopravvivere a niente lungo il percorso: né all'attesa del CDN verso l'origin, né al timer di inattività di un load balancer, né al timeout dei tool del client stesso. In Codex, tool_timeout_sec di MCP vale 60 secondi di default. Un tetto di 50 secondi fa arrivare la risposta vuota prima che uno di questi tagli la richiesta, e una risposta vuota è un segnale pulito per chiedere di nuovo.

Che fine fa un messaggio la cui risposta va persa?

"Senza after_sequence riprende da dove si è fermata l'ultima chiamata." Comodo: un agente che dimentica il proprio cursore comunque non vede duplicati. Ma il server può sapere solo cosa ha inviato, non cosa è arrivato. Nella ricostruzione il server sposta il cursore salvato quando restituisce messaggi. Poi, lato client, una risposta su cinque viene scartata, che è esattamente l'aspetto di una connessione caduta o di un riavvio della CLI:

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=[] 

Con il punto di ripresa del server, quattro risposte perse sono quattro messaggi persi. Se il client invia l'ultima sequenza che ha davvero elaborato, quelle stesse quattro vengono riconsegnate alla chiamata successiva. La descrizione pubblicata non dice quando il server di osFoundry sposta il cursore, e la ricostruzione non lo chiarisce. Mostra però perché il parametro esiste. Se perdere un messaggio per te è un problema, tieni la sequenza più alta che hai elaborato e passala come after_sequence.

Perché idempotency_key è obbligatorio negli invii?

Chi invia ha il problema speculare. Un send_agent_message la cui risposta va persa potrebbe essere stato salvato oppure no. Ritentare senza chiave può creare un duplicato. Ritentare con la stessa chiave no:

same key x3 -> new messages: 1 fresh key x3 -> new messages: 3 

Lo schema richiede da 8 a 128 caratteri, e la descrizione chiede "una chiave di idempotenza stabile per i retry". Anche il rovescio della medaglia si vede nella ricostruzione: una domanda nuova inviata con una chiave vecchia viene trattata come il messaggio vecchio. Ricava la chiave dal contenuto e dal tentativo, per esempio alice-claude-orders-0001, non da un contatore che si azzera quando la CLI si riavvia.

Quanto costano al server 200 agenti inattivi?

La ricostruzione parcheggia 200 agenti in wait_for_messages con timeout_s 5, poi invia un messaggio ad agent-137:

woken with a message: ['agent-137'] latency=46.8 ms; the other 199 returned empty at timeout_s=5 

Solo l'agente giusto ha ricevuto il messaggio. Ma la latenza è passata da 1 ms a 46.8 ms, perché la ricostruzione usa un'unica threading.Condition e notify_all(). Ogni richiesta in attesa si sveglia e riscansiona il log per scoprire che non era lei. A 200 va bene, a 20,000 no. Un server vero ha bisogno di una coda di attesa per destinatario, così un invio sveglia solo le richieste che possono usarlo.

Se lo colleghi da solo

  • Tieni un cursore lato client. Passa after_sequence dell'ultimo messaggio che hai finito di gestire, non dell'ultimo che hai ricevuto.
  • Metti l'handle in to quando ti serve una risposta. Tratta una @menzione nel corpo come un indizio e leggi la lista consegnati/saltati nel risultato dell'invio.
  • Scegli un timeout_s sotto ogni timeout tra te e il server, compreso il timeout dei tool del tuo client MCP. 50 funziona sotto 60.
  • Rendi le chiavi di idempotenza stabili tra i retry e uniche tra le domande.
  • Ricorda che ogni risultato vuoto di wait_for_messages è anche un turno del modello per un agente che gira in loop dentro Claude Code o Codex. 72 in un'ora tranquilla costano poco in richieste, ma non sono gratis in token. Ferma il loop quando nessuno ha bisogno dell'agente.