This is what Codex CLI gets back the first time it calls a session tool on osFoundry before signing in:
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 and tools/list answer without a token, so a client can see all 59 tools before anyone signs in. Two of them look like they do the same job:
{"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"]}]}
Both end with a session doing work. The rest of this post is about why they're kept apart, what bounds sit on each, and a Python rebuild of the client side that checks those bounds against the live schemas.
What separates the two calls?
Who says yes, and when.
A handoff asks a person now. The owner gets a notification ("Codex CLI requests a handoff"). The session's Handoffs tab lists it under "Outside assistant requests" with the goal, a fold-out "Context and expected output" and the line "Up to N rounds on your agent runtime". Nothing is queued until someone clicks Accept and run. The web client posts {decision: "accept"} to /agent-sessions/{id}/external-handoffs/{handoff_id}/decision, and the toast reads "Handoff accepted and run queued". The handoff row moves through pending, then accepted, declined or cancelled.
A run was approved earlier. The owner gave the connection a Runner seat in the Connected tab, and that is the yes. The help text there: "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." On the sign-in page, the run scope reads "Run sessions where you grant a runner role".
If you folded these into one tool with a needs_approval flag, the caller would decide whether a human gets asked. Two tools put that decision on the owner's side, in two places the caller can't touch: the OAuth scope (osfoundry:write versus osfoundry:run) and the seat (Contributor versus Runner). A Codex config that leaves osfoundry:run out of scopes can't start a run even on a Runner seat.
The annotations follow the same split. propose_session_handoff is openWorldHint: true because its output reaches a person outside the call. request_session_run is openWorldHint: false, because it only touches the queue of a session that agreed in advance. Both are destructiveHint: true, so a client running Codex's default_tools_approval_mode = "writes" asks before either.
Why does the handoff demand a context summary?
Because a person reads it before anything runs. context_summary and requested_output are required on the handoff and absent from the run. The owner is deciding whether to spend their session's runtime on this, and "fix the export" isn't enough to decide on. A Runner already has that trust, so the run takes only a goal.
The caps differ too. A handoff goal stops at 5,000 characters, plus 5,000 of context and 1,000 of requested output. A run goal goes to 10,000. In practice the run's goal carries the context, since there's no other field for it.
What does Codex get to read back?
Less than the session holds:
-
read_session_task_result: "the status and attributed runtime result for one task started by this connection". Another connection's task id is no use. -
read_session_mail: "notes addressed to this connection and notes it posted", 1 to 20 per page withbefore_idfor older ones. "Internal agent analysis is excluded." -
read_session_state: current status and recent tasks of a session the connection joined. -
post_session_note: an attributed note that agents can reply to through "the returned external sender slug". It "does not trigger an automatic run", so a note can't be used to get round the owner's click.
In a team session, those notes show up in Team mail with the connection's display name and an "outside assistant" label.
The rebuild
No codex binary is installed on this Mac, so handoff_sim.py (about 220 lines) plays the client. It does three things:
- Calls the live server without a token:
initialize,tools/listand onetools/call, 15 times each, and saves the tool list. - Validates twelve argument cases against the live
inputSchemas withjsonschema(Draft 2020-12), so nothing about the bounds is guessed. - Runs a small in-memory model of seats, scopes, owner decisions and idempotency. osFoundry's server code isn't public, so this part is a reconstruction from the tool descriptions and the web client that renders handoffs. Its responses are the model's, not the server's.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
The wire
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
Milliseconds, measured from this Mac in Japan through CloudFront's KIX edge, so the numbers say more about the route than the server. The point is the shape. Discovery is free and fast enough to run on every Codex start. The first real call costs one 401 round trip, and that 401 carries resource_metadata, which is how Codex finds the OAuth server without being told.
The seven session tools are 1,152 tokens of the full list's 10,197 (11.3%, cl100k_base, so treat it as approximate for Codex's own tokenizer). Listing only those in enabled_tools keeps the prompt small.
The bounds
| Tool | Scope | Required | Caps |
|---|---|---|---|
propose_session_handoff |
write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 |
cancel_session_handoff |
write | 1 | handoff_id only |
request_session_run |
run | 4 | goal 10,000, max_rounds 1–10 |
read_session_state |
read | 2 | none |
read_session_task_result |
read | 3 | none |
read_session_mail |
read | 2 | limit 1–20 |
post_session_note |
write | 5 | subject 200, body 10,000 |
Twelve cases against the 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
Two of these are worth a second look. The same 6,000-character goal is fine as a run and too long as a handoff. A client that builds one long prompt and sends it to whichever tool is available will hit this. And additionalProperties: false turns away a model field: the caller can't pick the session's model. The session runs on its own settings.
max_rounds is optional on both. The schemas don't publish a default, so if the number matters to you, send it.
Seats, cancel and accept (model)
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'}
The matrix is the Connected tab's help text turned into a table. The interesting rule is the cancel window. cancel_session_handoff is described as working "before its owner accepts it", so whichever happens first wins. Once a run is queued, withdrawing it is no longer the caller's call. That's also why cancel takes nothing but a handoff_id: there's no state to pass, only a pending row to close.
Lost responses
Every write takes a required idempotency_key. To see what that buys, the model sends 1,000 request_session_run calls, drops 20% of responses (seed 7) and lets the client retry up to three times with the same 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 extra runs on someone else's runtime is the case the required key is there for. Any client that times out and retries will hit it, and a run that already started is not something you want twice. Making the key required rather than optional means no client can forget it.
The whole script took 26.4 s wall-clock on an Apple M1. 45 network calls account for nearly all of it, and user CPU time was 0.37 s.
If you wire this up yourself
- Split "ask a person" and "act on standing permission" into two tools with two scopes, so the owner's choice lives in the token and the seat, not in an argument the caller sets.
- Require the fields a person needs to decide (
context_summary,requested_output) on the proposal only, and cap them tighter than the run's goal. - Let the caller cancel only while a request is pending. After acceptance, stopping work is the owner's job.
- Scope every read-back to what this connection started or was sent, and leave the session's internal analysis out of it.
- Make
idempotency_keyrequired on every write, and return the first result on a repeat. - Close the schema with
additionalProperties: false, so a caller can't sneak in a model or a tool list the session didn't choose.
How osFoundry's own agents pass work between steps is on the handoffs page. For the protocol underneath, see the MCP glossary entry.
Top comments (0)