Das bekommt Codex CLI zurück, wenn es auf osFoundry zum ersten Mal ein Session-Tool aufruft, ohne angemeldet zu sein:
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 und tools/list antworten ohne Token, ein Client sieht also alle 59 Tools, bevor sich überhaupt jemand anmeldet. Zwei davon scheinen dasselbe zu tun:
{"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"]}]}
Bei beiden arbeitet am Ende eine Session. Im Rest des Beitrags geht es darum, warum die beiden getrennt sind, welche Grenzen jeweils gelten, und um einen Python-Nachbau der Client-Seite, der diese Grenzen gegen die Live-Schemas prüft.
Was unterscheidet die beiden Aufrufe?
Wer Ja sagt, und wann.
Ein Handoff fragt einen Menschen, und zwar jetzt. Der Owner bekommt eine Benachrichtigung ("Codex CLI requests a handoff"). Der Handoffs-Tab der Session listet die Anfrage unter "Outside assistant requests" (Anfragen externer Assistenten), mit dem Ziel, einem aufklappbaren Bereich "Context and expected output" und der Zeile "Up to N rounds on your agent runtime". Solange niemand auf Accept and run klickt, wird nichts eingereiht. Der Web-Client schickt dann {decision: "accept"} an /agent-sessions/{id}/external-handoffs/{handoff_id}/decision, und der Toast meldet "Handoff accepted and run queued". Die Handoff-Zeile wechselt von pending zu accepted, declined oder cancelled.
Bei einem Run kam das Ja schon vorher. Der Owner hat der Verbindung im Connected-Tab einen Runner-Platz gegeben, und das ist die Zustimmung. Der Hilfetext dort: "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." Auf der Anmeldeseite heißt der Run-Scope "Run sessions where you grant a runner role".
Würde man beides in ein Tool mit einem needs_approval-Flag packen, entschiede der Aufrufer, ob ein Mensch gefragt wird. Mit zwei Tools liegt diese Entscheidung beim Owner, und zwar an zwei Stellen, an die der Aufrufer nicht herankommt: beim OAuth-Scope (osfoundry:write gegenüber osfoundry:run) und beim Platz (Contributor gegenüber Runner). Eine Codex-Konfiguration, die osfoundry:run nicht in scopes aufführt, kann selbst auf einem Runner-Platz keinen Run starten.
Die Annotations folgen derselben Trennung. propose_session_handoff hat openWorldHint: true, weil das Ergebnis einen Menschen außerhalb des Aufrufs erreicht. request_session_run hat openWorldHint: false, weil es nur die Queue einer Session berührt, die vorab zugestimmt hat. Beide sind destructiveHint: true, ein Client mit Codex' default_tools_approval_mode = "writes" fragt also vor jedem der beiden nach.
Warum verlangt der Handoff eine Kontextzusammenfassung?
Weil ein Mensch sie liest, bevor irgendetwas läuft. context_summary und requested_output sind beim Handoff Pflicht und fehlen beim Run ganz. Der Owner entscheidet, ob er die Laufzeit seiner Session dafür hergibt, und "fix the export" reicht für diese Entscheidung nicht. Ein Runner hat dieses Vertrauen schon, deshalb braucht der Run nur ein goal.
Auch die Obergrenzen unterscheiden sich. Ein Handoff-goal endet bei 5,000 Zeichen, dazu 5,000 für den Kontext und 1,000 für den erwarteten Output. Ein Run-goal darf bis 10,000 gehen. In der Praxis trägt das goal des Runs den Kontext mit, weil es kein anderes Feld dafür gibt.
Was darf Codex zurücklesen?
Weniger, als die Session enthält:
-
read_session_task_result: "the status and attributed runtime result for one task started by this connection". Mit der Task-ID einer anderen Verbindung kommt man nicht weiter. -
read_session_mail: "notes addressed to this connection and notes it posted", 1 bis 20 pro Seite, ältere überbefore_id. "Internal agent analysis is excluded." -
read_session_state: aktueller Status und letzte Tasks einer Session, der die Verbindung beigetreten ist. -
post_session_note: eine zugeordnete Notiz, auf die Agents über "the returned external sender slug" antworten können. Sie "does not trigger an automatic run", mit einer Notiz lässt sich der Klick des Owners also nicht umgehen.
In einer Team-Session erscheinen diese Notizen in Team mail mit dem Anzeigenamen der Verbindung und dem Label "outside assistant".
Der Nachbau
Auf diesem Mac ist kein codex-Binary installiert, deshalb spielt handoff_sim.py (etwa 220 Zeilen) den Client. Das Skript macht drei Dinge:
- Es ruft den Live-Server ohne Token auf:
initialize,tools/listund eintools/call, jeweils 15 Mal, und speichert die Tool-Liste. - Es validiert zwölf Argumentfälle mit
jsonschema(Draft 2020-12) gegen die Live-inputSchemas, sodass bei den Grenzen nichts geraten ist. - Es lässt ein kleines In-Memory-Modell aus Plätzen, Scopes, Owner-Entscheidungen und Idempotenz laufen. Der Servercode von osFoundry ist nicht öffentlich, dieser Teil ist also aus den Tool-Beschreibungen und dem Web-Client, der Handoffs rendert, rekonstruiert. Seine Antworten stammen vom Modell, nicht vom Server.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
Die Leitung
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
Millisekunden, gemessen von diesem Mac in Japan über die KIX-Edge von CloudFront. Die Zahlen sagen also mehr über die Route als über den Server. Entscheidend ist das Muster. Discovery kostet nichts und ist schnell genug, um bei jedem Codex-Start zu laufen. Der erste echte Aufruf kostet einen 401-Roundtrip, und dieser 401 enthält resource_metadata. So findet Codex den OAuth-Server, ohne dass man ihn angeben muss.
Die sieben Session-Tools machen 1,152 der 10,197 Tokens der vollen Liste aus (11.3%, cl100k_base, für Codex' eigenen Tokenizer also nur ein Näherungswert). Wer nur diese in enabled_tools einträgt, hält den Prompt klein.
Die Grenzen
| Tool | Scope | Pflichtfelder | Obergrenzen |
|---|---|---|---|
propose_session_handoff |
write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 |
cancel_session_handoff |
write | 1 | nur handoff_id |
request_session_run |
run | 4 | goal 10,000, max_rounds 1–10 |
read_session_state |
read | 2 | keine |
read_session_task_result |
read | 3 | keine |
read_session_mail |
read | 2 | limit 1–20 |
post_session_note |
write | 5 | subject 200, body 10,000 |
Zwölf Fälle gegen die Live-Schemas:
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
Zwei davon lohnen einen zweiten Blick. Dasselbe goal mit 6,000 Zeichen geht als Run durch und ist als Handoff zu lang. Ein Client, der einen langen Prompt baut und ihn an das Tool schickt, das gerade verfügbar ist, läuft genau hier auf. Und additionalProperties: false weist ein model-Feld ab: Der Aufrufer kann das Modell der Session nicht wählen. Die Session läuft mit ihren eigenen Einstellungen.
max_rounds ist bei beiden optional. Die Schemas veröffentlichen keinen Default, wenn einem die Zahl also wichtig ist, sollte man sie mitschicken.
Plätze, Abbrechen und Annehmen (Modell)
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'}
Die Matrix ist der Hilfetext aus dem Connected-Tab, als Tabelle. Die interessante Regel ist das Zeitfenster fürs Abbrechen. cancel_session_handoff funktioniert laut Beschreibung "before its owner accepts it", es gewinnt also, was zuerst passiert. Sobald ein Run eingereiht ist, kann der Aufrufer ihn nicht mehr zurückziehen. Deshalb nimmt cancel auch nur eine handoff_id: Es gibt keinen Zustand zu übergeben, nur eine offene Zeile zu schließen.
Verlorene Antworten
Jeder Schreibaufruf verlangt einen idempotency_key. Um zu sehen, was das bringt, schickt das Modell 1,000 request_session_run-Aufrufe, verwirft 20% der Antworten (Seed 7) und lässt den Client bis zu dreimal mit demselben Key wiederholen:
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 zusätzliche Runs auf der Laufzeit von jemand anderem, genau dafür gibt es den Pflicht-Key. Jeder Client, der nach einem Timeout neu sendet, gerät in diesen Fall, und einen Run, der schon gestartet ist, will man nicht doppelt haben. Weil der Key Pflicht und nicht optional ist, kann ihn kein Client vergessen.
Das ganze Skript brauchte 26.4 s Wall-Clock-Zeit auf einem Apple M1. Fast alles davon entfällt auf 45 Netzwerkaufrufe, die User-CPU-Zeit lag bei 0.37 s.
Wenn du das selbst verdrahtest
- Trenne "einen Menschen fragen" und "mit stehender Erlaubnis handeln" in zwei Tools mit zwei Scopes, damit die Entscheidung des Owners im Token und im Platz steckt und nicht in einem Argument, das der Aufrufer setzt.
- Mach die Felder, die ein Mensch für seine Entscheidung braucht (
context_summary,requested_output), nur beim Vorschlag zur Pflicht und begrenze sie enger als dasgoaldes Runs. - Lass den Aufrufer nur abbrechen, solange eine Anfrage offen ist. Nach dem Annehmen ist das Stoppen Sache des Owners.
- Beschränke jedes Zurücklesen auf das, was diese Verbindung gestartet hat oder bekommen hat, und lass die interne Analyse der Session draußen.
- Mach
idempotency_keybei jedem Schreibaufruf zur Pflicht und gib bei einer Wiederholung das erste Ergebnis zurück. - Schließ das Schema mit
additionalProperties: false, damit ein Aufrufer kein Modell und keine Tool-Liste einschmuggeln kann, die die Session nicht gewählt hat.
Wie die eigenen Agents von osFoundry Arbeit zwischen Schritten weitergeben, steht auf der Handoffs-Seite. Das Protokoll darunter erklärt der Glossareintrag zu MCP.
Top comments (0)