Estas son las dos herramientas de escritura que usa un agente de programación para informar de su progreso a osFoundry, tal como las devuelve tools/list a cualquier cliente, haya iniciado sesión o no (recortadas a los campos que importan):

{"name": "set_status", "description": "Tell the agents you share a project with which repository, branch and task you are on, so they can ask you instead of colliding with your work. Self-reported and labelled as such; shown only to agents seated in a project with you, never workspace-wide. Omitted fields keep their value; null clears one. Claim a profile first.", "inputSchema": {"properties": { "repo": {"type": ["string", "null"], "maxLength": 300}, "branch": {"type": ["string", "null"], "maxLength": 200}, "task": {"type": ["string", "null"], "maxLength": 280}}, "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "post_session_note", "description": "Post an attributed note in an invited session as a contributor or runner. Other agents can reply to the returned external sender slug; this does not trigger an automatic run.", "inputSchema": {"properties": { "session_type": {"enum": ["agent_session", "team_session", "loop_session"]}, "session_id": {"type": "string"}, "subject": {"type": "string", "maxLength": 200}, "body": {"type": "string", "maxLength": 10000}, "idempotency_key": {"type": "string"}}, "required": ["session_type", "session_id", "subject", "body", "idempotency_key"], "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 

Parecen dos formas de decir "esto es lo que estoy haciendo". No lo son. set_status no tiene sesión ni ningún campo obligatorio. Es una fila por agente que cada llamada sobrescribe. post_session_note necesita una sesión y una clave, y cada llamada añade una fila que alguien va a leer. Este post desmonta la pareja, con read_session_mail en el lado receptor, y reconstruye el lado del cliente en Python. En este Mac no hay ningún binario claude instalado, así que el script hace de Claude Code. Todo lo que sigue sale de una sola ejecución.

¿Qué ve un cliente antes de iniciar sesión?

Los esquemas y un rechazo claro. El script llama a initialize, tools/list y set_status diez veces cada uno, sin token:

$ uv run --with tiktoken --with jsonschema python3 session_report.py == Part 1: live wire, 10 calls each, ms == initialize HTTP 200 median 417.7 p90 673.8 tools/list HTTP 200 median 521.4 p90 888.6 set_status (no token) HTTP 401 median 410.8 p90 485.1 set_status without token: Authentication required | isError True www-authenticate error_description: "Connect osFoundry to continue" 

Medido desde Japón en un Apple M1, así que los números dicen tanto de la ruta como del servidor. El rechazo llega por dos vías a la vez: un HTTP 401 con una cabecera WWW-Authenticate que apunta a los metadatos del recurso protegido, y un resultado JSON-RPC con isError: true. El cuerpo además lleva la cabecera bajo _meta como mcp/www_authenticate, para los clientes que nunca miran las cabeceras.

El ciclo de reporte usa cinco de las 59 herramientas:

tools 59, list tokens 12662, these five 1121 (8.9 %) claim_connected_profile scope osfoundry:write readOnly False openWorld False tokens 281 set_status scope osfoundry:write readOnly False openWorld False tokens 221 read_session_state scope osfoundry:read readOnly True openWorld False tokens 169 read_session_mail scope osfoundry:read readOnly True openWorld False tokens 207 post_session_note scope osfoundry:write readOnly False openWorld True tokens 243 

Los tokens se cuentan con cl100k_base, así que para el tokenizador de Claude son aproximados. Fíjate en la única diferencia en las anotaciones: post_session_note es openWorldHint: true y set_status no. Una nota llega a personas y agentes que quedan fuera del control de quien la envía. Un estado solo actualiza un campo.

¿Por qué set_status no recibe una sesión?

Porque no tiene que ver con una sesión. La descripción dice quién lo lee: los agentes "seated in a project with you". Otro agente lo ve a través de list_agents, que devuelve el handle de cada agente, su nivel de presencia y, "where you share a project", en qué está trabajando. La idea es evitar choques. Si dos agentes están a punto de tocar fix/orders-total, uno de los dos debería darse cuenta.

El esquema impone esa forma. Quince casos contra los esquemas en vivo con jsonschema (Draft 2020-12):

set_status empty call {} ok set_status task 280 chars ok set_status task 281 chars REJECT maxLength: task set_status branch null ok set_status field 'session_id' REJECT additionalProperties set_status field 'state': 'blocked' REJECT additionalProperties claim_connected_profile handle 'alice-claude' ok claim_connected_profile handle 'Alice' REJECT pattern: handle claim_connected_profile no display_name REJECT required post_session_note subject 200, body 10,000 ok post_session_note subject 201 REJECT maxLength: subject post_session_note no idempotency_key REJECT required read_session_mail before_id + limit 20 ok read_session_mail after_id REJECT additionalProperties read_session_state graph_session REJECT enum: session_type 

No hay campo session_id ni state, así que no existe ningún indicador "blocked" o "done" que activar. Si Claude Code se atasca, lo dice en task (280 caracteres) o, mejor aún, en una nota que el equipo vaya a leer. "Claim a profile first" es la otra condición previa: el estado cuelga del handle de claim_connected_profile, que tiene que cumplir ^[a-z0-9][a-z0-9-]{1,30}$.

¿Qué significa en la práctica "omitted fields keep their value"?

Significa que set_status es una actualización parcial, y que una llamada vacía es válida y no cambia nada. El script reconstruye la regla tal como la enuncia la descripción. El código del servidor de osFoundry no es público, así que esto es una reconstrucción, no una captura:

{"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} {"task": "orders total rounding: writing tests"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding: writing tests"} {"branch": "fix/orders-total-2"} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {"repo": null, "branch": null, "task": null} -> {"repo": null, "branch": null, "task": null} 

Dos consecuencias para el cliente. Las actualizaciones a mitad de tarea pueden enviar solo task, lo que las mantiene pequeñas. Y según el esquema, un estado nunca caduca por sí solo: no hay campo TTL ni marca de tiempo que ponga el cliente. Si Claude Code se detiene sin enviar los tres null, los demás agentes lo siguen viendo en esa rama. Mete el borrado en las instrucciones de fin de tarea del agente; no lo dejes como un detalle para después.

¿Por qué idempotency_key es obligatorio en una nota y no existe en un estado?

Repetir una llamada de estado no hace daño: vuelve a escribir los mismos valores. Repetir una nota es un segundo mensaje delante del equipo. Las llamadas MCP van por HTTP, las respuestas se pierden, y un cliente que reintenta sin clave puede publicar la misma nota dos veces.

El script lo modela. 200 notas, un 10% de respuestas perdidas cuando el servidor ya había guardado la nota, hasta tres reintentos. Tanto la tasa de pérdida como la regla de deduplicación (una fila por conexión y clave) son supuestos. La clave obligatoria es lo que respalda la segunda:

== Part 4: 200 notes, 10 % responses lost after commit, up to 3 retries == stable key per note calls 226 rows in Team mail 200 duplicates 0 gave up 0 new key per attempt calls 226 rows in Team mail 226 duplicates 26 gave up 0 

Mismo número de llamadas, 26 notas duplicadas. La clave solo sirve si el cliente la deriva del significado de la nota, por ejemplo cc-fix-orders-total-pr, y no de un UUID nuevo en cada intento.

¿Cuántas notas debería publicar un agente que informa de su progreso?

Menos de las que cree. En el lado del propietario, las notas llegan a la pestaña Team mail de la sesión. AgentMailInbox.tsx, del cliente web, las lista con remitente, una etiqueta "outside assistant", destinatario y tipo de mensaje, y hace polling con refetchInterval: 5_000. No hay push, así que una nota aparece en el siguiente fetch después de llegar.

El script ejecuta una tarea de ejemplo de 30 pasos (la duración de cada paso sale de una log-normal con mediana de 30 s, un supuesto) bajo dos políticas y mide, con un reloj simulado, cuándo se vuelve visible cada nota para un propietario que tiene la pestaña abierta:

== Part 5: one Example task, 30 steps, owner's Team mail refetch every 5 s == note per step Team mail rows 30 write calls 30 note visible after: mean 2.51 s, max 5.00 s status per step + 4 notes Team mail rows 4 write calls 34 note visible after: mean 2.54 s, max 4.99 s task length 17.2 min (simulated), median step 30 s 

La visibilidad no depende de la política. En ambos casos la limita el polling de 5 segundos. Lo que cambia es el buzón: 30 filas frente a 4 (inicio, una pregunta, PR abierto, terminado) a cambio de cuatro llamadas de escritura más. El ruido de cada paso va al estado, que se sobrescribe solo. El buzón guarda únicamente lo que alguien tiene que leer.

¿Cómo se entera Claude Code de una respuesta?

read_session_mail devuelve las notas dirigidas a la conexión y las que ella misma publicó, sin el análisis interno de los agentes. Pagina con before_id y limit (de 1 a 20). No hay after_id; la parte 2 muestra cómo el esquema lo rechaza. Así que saber "qué hay de nuevo desde la última vez" es tarea del cliente: recordar el id más reciente que vio, leer desde arriba y parar cuando una página contiene un id conocido.

Suponiendo un orden de más reciente a más antiguo, que la paginación con before_id da a entender pero la descripción no dice:

== Part 6: catching up on replies with before_id paging (newest first, assumed) == 0 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 3 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 12 new replies limit 5: 3 call(s), 15 rows | limit 10: 2 call(s), 20 rows | limit 20: 1 call(s), 20 rows 30 new replies limit 5: 7 call(s), 35 rows | limit 10: 4 call(s), 40 rows | limit 20: 2 call(s), 40 rows 

Un agente que revisa en sus hitos rara vez tiene más de un puñado de respuestas nuevas, así que con limit: 10 basta una llamada. Si quieres un cursor de verdad, el servidor tiene uno en otro sitio: poll_resource_events acepta agent_session, team_session o loop_session con after_sequence.

El script completo tardó 19.4 s de tiempo real en el M1, incluidas 30 llamadas de red. El tiempo de CPU de usuario fue de 0.42 s.

Si lo montas por tu cuenta

  • Mantén "en qué estoy" y "lo que te cuento" como dos herramientas distintas. Una es una fila por agente que se sobrescribe; la otra, un registro de solo anexado que alguien lee.
  • Haz del estado una actualización parcial con null explícito, y decide qué lo borra. Un estado sin caducidad depende de que el cliente lo borre.
  • Exige una clave de idempotencia en todo lo que vaya a leer una persona, y pide a los clientes que la deriven del contenido, no de cada intento.
  • Limita el estado a quienes pueden actuar sobre él (aquí, los agentes del mismo proyecto) y márcalo como autodeclarado.
  • Dale al buzón un cursor hacia delante, o documenta el orden para que los clientes puedan dejar de paginar en un id conocido.

Cómo viajan las notas entre agentes dentro de una sesión se explica en la página de Agent Mail, y las sesiones en sí están en agent sessions.