Esta es la herramienta que llama un chat de Claude para arrancar un Graph en osFoundry, tal como la lista el endpoint MCP público. Obtenida con un tools/list sin autenticar el 2026-11-08 y recortada:
{"name": "run_graph_session", "description": "Queue a Graph run on the owner's online desktop in a connected project. Retrieve progress and result with get_graph_run. The desktop can go offline, and local tool approvals still apply.", "inputSchema": { "properties": { "graph_session_id": {"type": "string"}, "arguments": {"type": "object"}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}, "required": ["graph_session_id", "idempotency_key"], "additionalProperties": false}, "annotations": {"readOnlyHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} Tiene tres hermanas. list_graph_sessions (project_id, limit 1–50) y get_graph_run (command_id) son de solo lectura bajo osfoundry:read. cancel_graph_run (command_id) va bajo osfoundry:run, igual que la llamada de arranque. Dos respuestas reales de una cuenta conectada, sin IDs:
list_graph_sessions {"project_id": "…"} → {"project_id": "…", "sessions": []} get_graph_run {"command_id": "<unknown>"} → error: Graph run not found or access removed La lista vacía es lo normal. No aparece nada ahí hasta que el propietario comparte una sesión desde el escritorio. El verbo de la descripción es "Queue": la herramienta responde antes de que el Graph haya empezado, y el resto de este post va de por qué, y de lo que eso le cuesta al agente del otro lado.
¿Qué es una sesión de Graph y por qué el servidor no puede ejecutarla sin más?
Un Graph es un programa JavaScript guardado que ejecuta el motor de flota del escritorio: rutas condicionales, agentes en paralelo, reintentos acotados, llamadas a otros Graphs. En apps/web/src-tauri/src/graph_config.rs, run_graph_now es el comando Tauri que usa la pantalla de Graph. Le pasa el script a run_fleet_script con un techo duro de tokens que rechaza la siguiente oleada de agentes en cuanto se alcanza. Una sesión de Graph es la identidad duradera de un Graph guardado en un dispositivo, gs_ más 32 caracteres hexadecimales, en la tabla local graph_sessions.
El motor, los agentes que lanza el script, los modelos locales, las carpetas y la configuración de aprobación por herramienta viven en el proceso del escritorio. Un ejecutor en el servidor necesitaría copias de todo eso. El índice de funciones lo dice claro: leer Graphs funciona en el navegador, ejecutarlos necesita el escritorio.
¿Cómo llega una petición a un escritorio al que nadie se puede conectar?
No llega. Es el escritorio el que pide trabajo. apps/web/src/hooks/useConnectedGraphRelay.ts, montado una sola vez en la ventana principal del escritorio, hace esto:
- Cada 20 segundos (y justo después de que cambie lo compartido), se salta la pasada si la anterior sigue ocupada.
- Reenvía las finalizaciones de un outbox local que no llegaron al servidor la última vez.
- Para cada proyecto que el propietario haya habilitado, hace
POST /external-assistants/graph-relays/synccon los metadatos de las sesiones marcadas para ese proyecto, 100 como máximo: nombre, descripción, slug, versión, estado. La respuesta trae los comandos en cola para ese proyecto. - Para cada comando, vuelve a comprobar que el proyecto sigue habilitado, que la sesión sigue marcada y que conserva el mismo slug. Si no, lo cierra como
cancelledcon "Graph session is no longer shared with this project". - Si todo cuadra, lo ejecuta mediante
run_graph_nowen el canalgraph:external:<command id>, con un sondeo de control de 3 segundos para la cancelación, y después publica el resultado acotado.
Así que list_graph_sessions muestra lo que subió la última sincronización, y run_graph_session escribe una fila que el escritorio encontrará en su siguiente pasada. Los IDs de comando son UUIDs. ConnectedGraphProjectBinding rechaza cualquier otra cosa antes de escribir la fila canal-proyecto que archiva la ejecución bajo el proyecto conectado.
La reconstrucción
En este Mac no corre ningún binario de Claude, así que sim.py hace los tres papeles sobre un reloj simulado. La mitad del escritorio es un port del relay de arriba: sondeo de 20 s, salto si está ocupado, comandos en serie, recomprobación antes de ejecutar, sondeo de control de 3 s, outbox, el límite de 100,000 caracteres en el resultado. La mitad del servidor es un sustituto, porque su código no es público. Implementa lo que dicen las descripciones de las herramientas: una cola, resultados que solo puede leer el agente que inició la ejecución, cancelación distinta para en cola y en curso. También implementa el comportamiento de idempotencia que pide la guía de la CLI de osFoundry ("reuse idempotency keys after a lost response"). El cliente es un Claude con guion.
Los tiempos de ejecución de los Graphs son sintéticos: lognormal, mediana de 60 s, mínimo 5 s. Cada experimento usa entre 500 y 2,000 ejecuciones con semilla, y la fase del relay es aleatoria respecto a la petición. Python 3.9.6 en un Apple M1, 2.8 s de tiempo real para todo. La salida está en code/run.out.
¿Cuánto tiempo pasa una ejecución en la cola?
pickup wait (queued->started), n=2000: mean 9.8 s, median 9.8 s, p95 18.9 s, max 20.0 s Hasta un intervalo de sondeo, la mitad de media. Es el precio de un escritorio que solo hace conexiones salientes. No tiene ningún puerto abierto ni un socket de larga duración, y un portátil cerrado no es más que un relay que todavía no ha sondeado.
El otro coste se ve menos. El relay ejecuta los comandos uno detrás de otro y se salta su temporizador mientras está ocupado. Cinco ejecuciones encoladas a la vez terminan en secuencia:
| Ejecuciones encoladas a la vez | Primer resultado (mediana) | Último resultado (mediana) | Último resultado (p95) |
|---|---|---|---|
| 1 | 74 s | 74 s | 254 s |
| 3 | 73 s | 233 s | 483 s |
| 5 | 75 s | 399 s | 776 s |
Un agente que reparte cinco ejecuciones de Graph para ganar tiempo obtiene el peor calendario posible. Un escritorio es un solo worker. Si quieres paralelismo, mételo dentro del Graph, donde el motor tiene max_concurrency y un techo de tokens para eso.
¿Cada cuánto debería llamar el agente a get_graph_run?
Tampoco hay canal push de vuelta a Claude. Cada estrategia cambia llamadas por el retraso con que el agente se entera del resultado. 2,000 ejecuciones por fila:
| Estrategia de sondeo del cliente | Llamadas por ejecución (media) | p95 | Retraso al detectar el resultado (media) | p95 |
|---|---|---|---|---|
| cada 2 s | 47.1 | 118 | 1.0 s | 1.9 s |
| cada 5 s | 19.1 | 48 | 2.5 s | 4.8 s |
| cada 15 s | 6.7 | 16 | 7.5 s | 14.2 s |
| cada 30 s | 3.6 | 8 | 14.9 s | 28.4 s |
| backoff 2, 4, 8 … 30 s | 6.6 | 11 | 14.8 s | 28.4 s |
| esperar 20 s y luego cada 10 s | 8.8 | 23 | 5.0 s | 9.6 s |
Sondear cada 2 s cuesta 47 llamadas a herramientas, y por tanto 47 pasadas por el contexto del modelo, para ahorrar unos segundos en un trabajo que dura un minuto. El backoff exponencial parece listo y aquí pierde: hace tantas llamadas como un sondeo fijo de 15 s y detecta el resultado el doble de tarde, porque ya está en su tope de 30 s cuando termina un Graph típico. La última fila usa lo que te dice el relay. Nada puede empezar durante el primer intervalo de sondeo, así que esperar más o menos un intervalo antes de la primera comprobación no cuesta casi nada.
¿Qué detiene realmente la cancelación?
cancel at a random 0-40 s after queueing, n=2000: still queued 523, already running 1370 queued : terminal after 0.0 s (graph never started) running: terminal after mean 2.0 s, p95 3.3 s, max 3.5 s Cancelar algo en cola es un cambio de estado en el servidor. El escritorio nunca ve el comando y no se gasta ningún token. Cancelar algo en curso es un flag que el escritorio lee en su sondeo de control de 3 segundos, y entonces llama a fleet_cancel_run con el ID de ejecución que recibió en el evento fleet_started. El relay envía esa cancelación una sola vez y deja el resultado como cancelled, aunque el Graph llegue a terminar entre el flag y la parada.
¿Qué aporta la clave de idempotencia?
idempotency_key es obligatoria, de 8 a 128 caracteres. 200 ejecuciones por repetición, con el 30% de las respuestas perdidas para que el cliente reintente:
| Comportamiento al reintentar | Llamadas enviadas | Ejecuciones de Graph encoladas |
|---|---|---|
| clave nueva en cada reintento | 266 / 302 / 286 | 266 / 302 / 286 |
| misma clave en el reintento | 270 / 291 / 275 | 200 / 200 / 200 |
Con un Graph que redacta correos o escribe archivos, un duplicado no es un minuto perdido, es una segunda tanda de efectos secundarios. Por eso la clave es obligatoria en el esquema y la herramienta está marcada con destructiveHint: true.
¿Y si el escritorio se desconecta?
desktop offline 600 s: statuses ['queued'], executed 0 back online: first start +3 s, all 5 ['completed'] by +131 s, executed 5 completion POST lost once, outbox=on : status completed, delivered 40 s after start completion POST lost once, outbox=off: status running, never delivered El trabajo en cola sobrevive a un escritorio desconectado, porque la cola está en el servidor. El punto débil es la vuelta. Si el escritorio terminó un Graph y se perdió su POST, un relay que solo guardara el resultado en memoria dejaría la ejecución atascada en running para siempre. El relay real escribe cada finalización en localStorage antes de publicarla y solo la borra tras un 2xx, así que la siguiente pasada, 20 s después, la entrega.
¿Qué tamaño puede tener el resultado?
El relay publica {execution_id, status, result, total_tokens, reported_cost} si el JSON.stringify de eso ocupa como mucho 100,000 caracteres. Si no, publica {execution_id, status, result_truncated: true}. Los errores se cortan a 2,000 caracteres. El límite cuenta caracteres, no bytes:
result text that still fits (ascii): 99,899 chars = 99,899 UTF-8 bytes result text that still fits (japanese): 99,899 chars = 299,697 UTF-8 bytes El corte es todo o nada. No hay medio resultado que malinterpretar, y el registro completo de la ejecución se queda en el historial del escritorio bajo ese execution_id. Aun así es mucho texto para ponérselo delante a un modelo, así que un Graph pensado para que lo llame un agente debería terminar con un resumen, no con un volcado.
¿Quién más puede ver la ejecución?
another agent reads the run: {'error': 'Graph run not found or access removed'} En el sustituto, una segunda conexión que pide el command_id de Claude recibe el mismo error que devolvió el servidor real para un ID inventado. Tanto get_graph_run como cancel_graph_run se describen como herramientas que funcionan sobre ejecuciones "started by this Connected agent". No distinguir entre "no es tuyo" y "no existe" significa que un ID filtrado no confirma nada.
Si lo montas tú mismo
- Deja que la máquina dueña de las herramientas sea la que pida trabajo. Un sondeo saliente sobrevive a NAT, a la suspensión y a portátiles cerrados. Elige el intervalo sabiendo que cada llamante espera de media la mitad.
- Devuelve un ticket desde la llamada de arranque y di en la descripción de la herramienta qué herramienta lee el resultado. El modelo no puede deducir una cola a partir del nombre.
- Pon la primera comprobación de estado a más o menos un intervalo de recogida. El sondeo fijo le gana al backoff exponencial cuando la duración de los trabajos ronda el minuto.
- Exige una clave de idempotencia en la llamada de arranque. Con trabajos que tienen efectos secundarios, una petición reintentada sin clave es una segunda ejecución.
- Persiste las finalizaciones en el worker antes de enviarlas. El resultado es la parte del viaje de ida y vuelta que no puedes volver a calcular barato.
- Vuelve a comprobar el consentimiento en el worker justo antes de ejecutar, no solo cuando el trabajo entra en cola. Lo compartido puede cambiar entre medias.
La definición corta de osFoundry del protocolo que hay debajo está en su entrada del glosario sobre MCP, y el patrón más amplio al que pertenecen los Graphs, en la entrada sobre orquestación de agentes.

