Esto es lo que recibe Codex CLI la primera vez que llama a una herramienta de sesión en osFoundry sin haber iniciado sesión:
POST /api/v1/external-mcp {"method":"tools/call","params":{"name":"propose_session_handoff","arguments":{}}} HTTP/2 401 www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue" {"jsonrpc":"2.0","id":3,"result":{"isError":true, "content":[{"type":"text","text":"Authentication required"}], …}} initialize y tools/list responden sin token, así que un cliente puede ver las 59 herramientas antes de que nadie inicie sesión. Dos de ellas parecen hacer lo mismo:
{"name": "propose_session_handoff", "description": "Send a scoped task and context summary to the owner of an invited session. The owner must accept before a run is queued.", "required": ["session_type","session_id","goal","context_summary","requested_output","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "request_session_run", "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.", "required": ["session_type","session_id","goal","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} Las dos terminan con una sesión haciendo trabajo. El resto del post trata de por qué están separadas, qué límites tiene cada una y de una reconstrucción en Python del lado del cliente que comprueba esos límites contra los esquemas en vivo.
¿Qué diferencia a las dos llamadas?
Quién dice que sí, y cuándo.
Un handoff le pregunta a una persona, en ese momento. El owner recibe una notificación ("Codex CLI requests a handoff"). La pestaña Handoffs de la sesión lo muestra en "Outside assistant requests" (peticiones de asistentes externos) con el objetivo, un desplegable "Context and expected output" y la línea "Up to N rounds on your agent runtime". No se encola nada hasta que alguien pulsa Accept and run. Entonces el cliente web envía {decision: "accept"} a /agent-sessions/{id}/external-handoffs/{handoff_id}/decision y el toast dice "Handoff accepted and run queued". La fila del handoff pasa de pending a accepted, declined o cancelled.
Un run ya se aprobó antes. El owner le dio a la conexión un puesto de Runner en la pestaña Connected, y ese es el sí. El texto de ayuda de esa pestaña: "Invite it to a session as Contributor to post notes and propose handoffs, or Runner to also request runs. A handoff still needs the session owner's approval." En la página de inicio de sesión, el scope de run dice "Run sessions where you grant a runner role".
Si juntaras las dos en una sola herramienta con un flag needs_approval, sería quien llama el que decidiría si se le pregunta a una persona. Con dos herramientas, esa decisión queda del lado del owner, en dos sitios que quien llama no puede tocar: el scope de OAuth (osfoundry:write frente a osfoundry:run) y el puesto (Contributor frente a Runner). Una configuración de Codex que no incluya osfoundry:run en scopes no puede lanzar un run ni siquiera con un puesto de Runner.
Las annotations siguen la misma división. propose_session_handoff es openWorldHint: true porque su resultado llega a una persona fuera de la llamada. request_session_run es openWorldHint: false, porque solo toca la cola de una sesión que ya dio su permiso. Las dos son destructiveHint: true, así que un cliente con el default_tools_approval_mode = "writes" de Codex pregunta antes de cualquiera de ellas.
¿Por qué el handoff exige un resumen de contexto?
Porque una persona lo lee antes de que se ejecute nada. context_summary y requested_output son obligatorios en el handoff y no existen en el run. El owner está decidiendo si gasta el runtime de su sesión en esto, y con "fix the export" no basta para decidir. Un Runner ya tiene esa confianza, así que el run solo pide un goal.
Los límites también cambian. El goal de un handoff llega hasta 5,000 caracteres, más 5,000 de contexto y 1,000 de salida esperada. El goal de un run llega a 10,000. En la práctica, el goal del run lleva el contexto, porque no hay otro campo para eso.
¿Qué puede leer Codex de vuelta?
Menos de lo que guarda la sesión:
-
read_session_task_result: "the status and attributed runtime result for one task started by this connection". El id de tarea de otra conexión no sirve de nada. -
read_session_mail: "notes addressed to this connection and notes it posted", de 1 a 20 por página, conbefore_idpara las más antiguas. "Internal agent analysis is excluded." -
read_session_state: el estado actual y las tareas recientes de una sesión a la que se unió la conexión. -
post_session_note: una nota atribuida a la que los agentes pueden responder a través de "the returned external sender slug". "does not trigger an automatic run", así que una nota no sirve para saltarse el clic del owner.
En una sesión de equipo, esas notas aparecen en Team mail con el nombre visible de la conexión y la etiqueta "outside assistant".
La reconstrucción
En este Mac no hay ningún binario codex instalado, así que handoff_sim.py (unas 220 líneas) hace de cliente. Hace tres cosas:
- Llama al servidor en vivo sin token:
initialize,tools/listy untools/call, 15 veces cada uno, y guarda la lista de herramientas. - Valida doce casos de argumentos contra los
inputSchemaen vivo conjsonschema(Draft 2020-12), para no adivinar nada sobre los límites. - Ejecuta un pequeño modelo en memoria de puestos, scopes, decisiones del owner e idempotencia. El código del servidor de osFoundry no es público, así que esta parte está reconstruida a partir de las descripciones de las herramientas y del cliente web que muestra los handoffs. Sus respuestas son del modelo, no del servidor.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py La red
initialize HTTP 200 median 566.3 p90 799.0 tools/list HTTP 200 median 559.7 p90 937.7 tools/call (no token) HTTP 401 median 482.6 p90 677.0 Milisegundos, medidos desde este Mac en Japón a través del edge KIX de CloudFront, así que los números dicen más de la ruta que del servidor. Lo que importa es la forma. El discovery es gratis y lo bastante rápido como para hacerlo en cada arranque de Codex. La primera llamada real cuesta un round trip con 401, y ese 401 trae resource_metadata, que es como Codex encuentra el servidor OAuth sin que nadie se lo diga.
Las siete herramientas de sesión son 1,152 tokens de los 10,197 de la lista completa (11.3%, cl100k_base, así que tómalo como aproximado para el tokenizer propio de Codex). Poner solo esas en enabled_tools mantiene el prompt pequeño.
Los límites
| Herramienta | Scope | Obligatorios | Límites |
|---|---|---|---|
propose_session_handoff | write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 |
cancel_session_handoff | write | 1 | solo handoff_id |
request_session_run | run | 4 | goal 10,000, max_rounds 1–10 |
read_session_state | read | 2 | ninguno |
read_session_task_result | read | 3 | ninguno |
read_session_mail | read | 2 | limit 1–20 |
post_session_note | write | 5 | subject 200, body 10,000 |
Doce casos contra los esquemas en vivo:
propose_session_handoff 6,000-char goal REJECT maxLength: goal request_session_run 6,000-char goal ok propose_session_handoff no context_summary REJECT required propose_session_handoff max_rounds omitted ok request_session_run max_rounds 0 REJECT minimum: max_rounds request_session_run max_rounds 11 REJECT maximum: max_rounds request_session_run no idempotency_key REJECT required request_session_run session_type 'room' REJECT enum: session_type request_session_run extra field 'model' REJECT additionalProperties read_session_mail limit 21 REJECT maximum: limit post_session_note 201-char subject REJECT maxLength: subject cancel_session_handoff only handoff_id ok Dos merecen una segunda mirada. El mismo goal de 6,000 caracteres vale como run y es demasiado largo como handoff. Un cliente que arma un prompt largo y lo manda a la herramienta que tenga a mano se va a topar con esto. Y additionalProperties: false rechaza un campo model: quien llama no puede elegir el modelo de la sesión. La sesión se ejecuta con su propia configuración.
max_rounds es opcional en las dos. Los esquemas no publican un valor por defecto, así que si el número te importa, envíalo.
Puestos, cancelar y aceptar (modelo)
tool observer contributor runner propose_session_handoff - yes yes cancel_session_handoff - yes yes request_session_run - - yes read_session_state yes yes yes read_session_task_result yes yes yes read_session_mail yes yes yes post_session_note - yes yes runner seat, token without osfoundry:run -> {'error': 'token lacks osfoundry:run'} cancel while pending -> {'status': 'cancelled'} owner clicks Accept after cancel -> cancelled owner accepts -> accepted | cancel after -> {'error': 'already accepted'} another connection reads that task -> {'error': 'not a task started by this connection'} La matriz es el texto de ayuda de la pestaña Connected convertido en tabla. La regla interesante es la ventana de cancelación. Según su descripción, cancel_session_handoff funciona "before its owner accepts it", así que gana lo que pase primero. Una vez encolado el run, retirarlo ya no le corresponde a quien llama. Por eso cancel solo recibe un handoff_id: no hay estado que pasar, solo una fila pendiente que cerrar.
Respuestas perdidas
Toda escritura lleva un idempotency_key obligatorio. Para ver qué aporta, el modelo envía 1,000 llamadas a request_session_run, descarta el 20% de las respuestas (seed 7) y deja que el cliente reintente hasta tres veces con la misma key:
server honours idempotency_key=True calls sent 1276 tasks queued 1000 duplicates 0 server honours idempotency_key=False calls sent 1276 tasks queued 1276 duplicates 276 276 runs de más en el runtime de otra persona: ese es el caso para el que existe la key obligatoria. Cualquier cliente que reintenta tras un timeout se lo va a encontrar, y un run que ya arrancó no es algo que quieras dos veces. Al ser obligatoria y no opcional, ningún cliente puede olvidarla.
El script completo tardó 26.4 s de tiempo real en un Apple M1. Casi todo se va en 45 llamadas de red; el tiempo de CPU de usuario fue de 0.37 s.
Si lo montas tú
- Separa "preguntar a una persona" y "actuar con un permiso ya dado" en dos herramientas con dos scopes, para que la decisión del owner viva en el token y en el puesto, no en un argumento que fija quien llama.
- Haz obligatorios los campos que una persona necesita para decidir (
context_summary,requested_output) solo en la propuesta, y ponles un límite más ajustado que algoaldel run. - Deja que quien llama cancele solo mientras la petición está pendiente. Una vez aceptada, parar el trabajo es cosa del owner.
- Limita toda lectura a lo que esta conexión inició o recibió, y deja fuera el análisis interno de la sesión.
- Haz
idempotency_keyobligatorio en toda escritura y devuelve el primer resultado cuando se repita. - Cierra el esquema con
additionalProperties: false, para que nadie cuele un modelo o una lista de herramientas que la sesión no eligió.
Cómo se pasan trabajo entre pasos los propios agentes de osFoundry está en la página de handoffs. Para el protocolo que hay debajo, mira la entrada del glosario sobre MCP.