Ecco cosa riceve Codex CLI la prima volta che chiama un tool di sessione su osFoundry senza aver fatto l'accesso:

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 e tools/list rispondono senza token, quindi un client vede tutti i 59 tool prima che qualcuno faccia l'accesso. Due di questi sembrano fare la stessa cosa:

{"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"]}]} 

In entrambi i casi, alla fine una sessione lavora. Il resto del post spiega perché sono tenuti separati, quali limiti valgono per ciascuno, e mostra una ricostruzione in Python del lato client che verifica quei limiti contro gli schemi live.

Cosa distingue le due chiamate?

Chi dice di sì, e quando.

Un handoff chiede a una persona, adesso. L'owner riceve una notifica ("Codex CLI requests a handoff"). La scheda Handoffs della sessione lo elenca sotto "Outside assistant requests" (richieste da assistenti esterni) con l'obiettivo, un pannello espandibile "Context and expected output" e la riga "Up to N rounds on your agent runtime". Non viene accodato nulla finché qualcuno non clicca Accept and run. A quel punto il client web invia {decision: "accept"} a /agent-sessions/{id}/external-handoffs/{handoff_id}/decision e il toast dice "Handoff accepted and run queued". La riga dell'handoff passa da pending a accepted, declined o cancelled.

Un run invece è stato approvato prima. L'owner ha dato alla connessione un posto da Runner nella scheda Connected, e quello è il sì. Il testo di aiuto lì dice: "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." Nella pagina di accesso, lo scope run si legge "Run sessions where you grant a runner role".

Se le unissi in un unico tool con un flag needs_approval, sarebbe il chiamante a decidere se chiedere a un umano. Con due tool quella decisione resta dalla parte dell'owner, in due punti che il chiamante non può toccare: lo scope OAuth (osfoundry:write contro osfoundry:run) e il posto (Contributor contro Runner). Una config di Codex che non mette osfoundry:run in scopes non può avviare un run nemmeno con un posto da Runner.

Le annotations seguono la stessa divisione. propose_session_handoff è openWorldHint: true perché il suo output raggiunge una persona fuori dalla chiamata. request_session_run è openWorldHint: false, perché tocca solo la coda di una sessione che ha già dato il consenso. Entrambi sono destructiveHint: true, quindi un client con il default_tools_approval_mode = "writes" di Codex chiede conferma prima di ognuno dei due.

Perché l'handoff pretende un riassunto del contesto?

Perché una persona lo legge prima che parta qualsiasi cosa. context_summary e requested_output sono obbligatori nell'handoff e assenti nel run. L'owner sta decidendo se spendere il runtime della propria sessione su questa richiesta, e "fix the export" non basta per decidere. Un Runner ha già quella fiducia, quindi il run prende solo un goal.

Anche i limiti cambiano. Il goal di un handoff si ferma a 5,000 caratteri, più 5,000 di contesto e 1,000 di output atteso. Il goal di un run arriva a 10,000. In pratica è il goal del run a portarsi dietro il contesto, perché non c'è un altro campo per farlo.

Cosa può rileggere Codex?

Meno di quanto contiene la sessione:

  • read_session_task_result: "the status and attributed runtime result for one task started by this connection". L'id di un task di un'altra connessione non serve a niente.
  • read_session_mail: "notes addressed to this connection and notes it posted", da 1 a 20 per pagina, con before_id per quelle più vecchie. "Internal agent analysis is excluded."
  • read_session_state: stato attuale e task recenti di una sessione a cui la connessione si è unita.
  • post_session_note: una nota attribuita a cui gli agenti possono rispondere tramite "the returned external sender slug". "does not trigger an automatic run", quindi una nota non permette di aggirare il clic dell'owner.

In una sessione di team, queste note compaiono in Team mail con il nome visualizzato della connessione e l'etichetta "outside assistant".

La ricostruzione

Su questo Mac non è installato nessun binario codex, quindi è handoff_sim.py (circa 220 righe) a fare la parte del client. Fa tre cose:

  1. Chiama il server live senza token: initialize, tools/list e un tools/call, 15 volte ciascuno, e salva la lista dei tool.
  2. Valida dodici casi di argomenti contro gli inputSchema live con jsonschema (Draft 2020-12), così sui limiti non si tira a indovinare.
  3. Esegue un piccolo modello in memoria di posti, scope, decisioni dell'owner e idempotenza. Il codice server di osFoundry non è pubblico, quindi questa parte è ricostruita a partire dalle descrizioni dei tool e dal client web che mostra gli handoff. Le sue risposte sono del modello, non del server.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py 

La rete

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 

Millisecondi, misurati da questo Mac in Giappone attraverso l'edge KIX di CloudFront, quindi i numeri dicono più sul percorso che sul server. Quello che conta è la forma. Il discovery è gratuito e abbastanza veloce da farlo a ogni avvio di Codex. La prima chiamata vera costa un round trip con 401, e quel 401 contiene resource_metadata, che è il modo in cui Codex trova il server OAuth senza che glielo si dica.

I sette tool di sessione valgono 1,152 token sui 10,197 della lista completa (11.3%, cl100k_base, quindi da considerare un'approssimazione per il tokenizer di Codex). Mettere solo quelli in enabled_tools tiene piccolo il prompt.

I limiti

Tool Scope Obbligatori Limiti
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 nessuno
read_session_task_result read 3 nessuno
read_session_mail read 2 limit 1–20
post_session_note write 5 subject 200, body 10,000

Dodici casi contro gli schemi live:

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 

Due meritano una seconda occhiata. Lo stesso goal di 6,000 caratteri va bene come run ed è troppo lungo come handoff. Un client che costruisce un unico prompt lungo e lo manda al tool che ha a disposizione ci finirà dentro. E additionalProperties: false respinge un campo model: il chiamante non può scegliere il modello della sessione. La sessione gira con le proprie impostazioni.

max_rounds è opzionale in entrambi. Gli schemi non pubblicano un default, quindi se il numero ti interessa, mandalo.

Posti, annullamento e accettazione (modello)

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 matrice è il testo di aiuto della scheda Connected trasformato in tabella. La regola interessante è la finestra di annullamento. Secondo la descrizione, cancel_session_handoff funziona "before its owner accepts it", quindi vince chi arriva prima. Una volta che il run è in coda, ritirarlo non spetta più al chiamante. È anche per questo che cancel prende solo un handoff_id: non c'è stato da passare, solo una riga in sospeso da chiudere.

Risposte perse

Ogni scrittura richiede un idempotency_key obbligatorio. Per vedere cosa ci si guadagna, il modello invia 1,000 chiamate request_session_run, perde il 20% delle risposte (seed 7) e lascia che il client ritenti fino a tre volte con la stessa chiave:

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 run in più sul runtime di qualcun altro: è proprio il caso per cui esiste la chiave obbligatoria. Qualsiasi client che ritenta dopo un timeout ci finirà, e un run già partito non è una cosa che vuoi due volte. Rendere la chiave obbligatoria e non opzionale significa che nessun client può dimenticarla.

L'intero script ha impiegato 26.4 s di tempo reale su un Apple M1. Quasi tutto se ne va in 45 chiamate di rete, e il tempo CPU utente è stato di 0.37 s.

Se lo colleghi da solo

  • Separa "chiedere a una persona" e "agire con un permesso già dato" in due tool con due scope, così la scelta dell'owner sta nel token e nel posto, non in un argomento impostato dal chiamante.
  • Rendi obbligatori i campi che servono a una persona per decidere (context_summary, requested_output) solo nella proposta, e mettigli un limite più stretto del goal del run.
  • Lascia annullare al chiamante solo finché la richiesta è in sospeso. Dopo l'accettazione, fermare il lavoro è compito dell'owner.
  • Limita ogni lettura a ciò che questa connessione ha avviato o ricevuto, e lascia fuori l'analisi interna della sessione.
  • Rendi idempotency_key obbligatorio su ogni scrittura e restituisci il primo risultato in caso di ripetizione.
  • Chiudi lo schema con additionalProperties: false, così un chiamante non può infilare un modello o una lista di tool che la sessione non ha scelto.

Come gli agenti di osFoundry si passano il lavoro tra un passo e l'altro è spiegato nella pagina sugli handoff. Per il protocollo sottostante, vedi la voce MCP del glossario.