This is the one tool in osFoundry's MCP server that starts work without a person clicking anything, as tools/list returns it to any client, signed in or not:
{"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.",
"inputSchema": {"properties": {
"session_type": {"enum": ["agent_session", "team_session", "loop_session"]},
"session_id": {"type": "string"},
"goal": {"type": "string", "maxLength": 10000},
"max_rounds": {"type": "integer", "minimum": 1, "maximum": 10},
"idempotency_key": {"type": "string"}},
"required": ["session_type", "session_id", "goal", "idempotency_key"],
"additionalProperties": false},
"annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Two phrases in that description shape everything a client does with it. "Invited runner" is a permission check that happens in three places. "When available" means the call returns before the work starts, so the client has to come back for the result. This post goes through both, plus the mailbox Codex uses to talk to the session afterwards, with a Python rebuild of the client side run on this Mac.
What can a client learn before signing in?
Quite a lot. initialize, tools/list and the protected-resource metadata all answer without a token:
$ uv run --with tiktoken --with jsonschema python3 runner_sim.py
== Part 1: live wire, 10 calls each, ms ==
initialize HTTP 200 median 375.1 p90 726.0
tools/list HTTP 200 median 551.1 p90 755.5
protected-resource HTTP 200 median 325.4 p90 578.8
tools/call (no token) HTTP 401 median 454.2 p90 492.8
scopes_supported: ['osfoundry:read', 'osfoundry:write', 'osfoundry:run', 'osfoundry:models']
Measured from Japan on an Apple M1, so the milliseconds describe the route as much as the server. osfoundry:run is published as a scope of its own, separate from osfoundry:write. A Codex config that leaves it out of scopes never gets a token that can queue a run, whatever seat it's given later.
The runner loop uses five of the server's 59 tools: read_session_state, request_session_run, read_session_task_result, post_session_note and read_session_mail. They come to 821 of the list's 10,197 tokens (8.1%, counted with cl100k_base, so approximate for Codex's own tokenizer). Listing just those in enabled_tools keeps the other 54 out of the prompt.
Who decides that Codex is a runner?
Three separate parties, and a call needs all three:
- The token.
request_session_rundeclaresosfoundry:run. The sign-in page shows that scope as "Run sessions where you grant a runner role". - The grant. In osFoundry's web client (
ExternalAssistantsPanel.tsx), a session grant carries actions, labelled View, Reply, Edit and "Start work". The role dropdown on a grant only offers Contributor when the grant includeswrite, and Runner when it includesrun. - The seat. The owner saves a role per session, which the client sends as
POST /external-assistants/{connection}/sessions/{session_type}/{session_id}with{"role": "observer" | "contributor" | "runner"}. The dropdown shows these as Viewer, Contributor and Runner.
The script rebuilds that check. The role-to-tool mapping comes from the panel's help text ("Contributor to post notes and propose handoffs, or Runner to also request runs"), and the scope per tool from the live schemas. osFoundry's server code isn't public, so this is a reconstruction of the rule, not a capture of the server's refusals:
grant actions ['read'] -> roles offered ['observer']
grant actions ['read', 'write'] -> roles offered ['observer', 'contributor']
grant actions ['read', 'write', 'run'] -> roles offered ['observer', 'contributor', 'runner']
tool observer contributor runner
read_session_state yes yes yes
request_session_run - - yes
read_session_task_result yes yes yes
post_session_note - yes yes
read_session_mail yes yes yes
request_session_run seat=contributor scopes=read+run+write -> seat 'contributor' cannot call it
request_session_run seat=runner scopes=read+write -> token lacks osfoundry:run
The split matters for whoever writes the client. A refusal on request_session_run has two different fixes: re-login with the scope (your side) or ask the owner for the seat (their side). Report which one it is instead of retrying.
What happens between the call and the result?
The call queues a task and returns. The web client's types show what the task is: an AgentTask with task_type: "external_assistant", a type the API's task-creation route refuses, because only the server arms it. Its status moves through pending, running, input_required, completed, failed or cancelled. In the session's Trigger tab it's labelled "Requested by an outside assistant".
Who picks it up depends on where the session runs. For a session on the owner's desktop app, agent_session_runtime.rs spells out the timing:
-
DEFAULT_POLL_INTERVAL_MSis 15,000. A due task is claimed on the next poll with a lease. - After four polls that find nothing (
IDLE_CYCLES_BEFORE_BACKOFF), the interval climbsIDLE_BACKOFF_LADDER_MS: 30 s, 60 s, then 300 s. A comment in the file states the cost: a server-armed trigger "arriving at a fully idle desktop waits up to five minutes". Anything a person does in the app pokes the loop awake within about a second. - A session that "already has a run in flight" defers its due tasks. Runs on one session don't overlap.
- When the run ends, the outcome is written to session mail from
runtimewith atask_id. The subject says whether it finished cleanly ("Task … result") or stopped at a ceiling ("goal not fully met", "stopped at your token ceiling"). - The goal is screened as untrusted input.
provenance_for_task_typereturnsOperatoronly formanual, a run the owner started in the app. Every other type, this one included, goes through osGuard.
input_required deserves a line in any client. It means the run asked a person in osFoundry a question and is blocked on the answer. It's still running. Keep waiting, and tell your user someone on the osFoundry side needs to answer.
How often should the client poll?
Codex can't sleep inside a tool call, and an MCP tool call that blocks for minutes runs into client timeouts. So the realistic shape is a polling loop on read_session_task_result. The question is the interval.
Part 4 of the script simulates it on a virtual clock: 2,000 runs, pick-up delay drawn from the runtime's own intervals, and run length from a log-normal with a median of 90 s. That run length is an assumption, so treat the absolute numbers as illustrative. Each strategy counts the calls it makes and how long after completion it notices:
runtime active pending wait: median 7.5 s p90 13.5 s max 15.0 s
poll every 5 s calls/task median 20 p90 41 noticed late by median 2.5 s p90 4.5 s
poll every 30 s calls/task median 4 p90 7 noticed late by median 15.0 s p90 26.8 s
poll 5 s x1.5, cap 60 s calls/task median 6 p90 8 noticed late by median 21.5 s p90 48.8 s
poll 15 s x2, cap 120 s calls/task median 3 p90 4 noticed late by median 41.8 s p90 107.2 s
poll first at 60 s, then 15 s calls/task median 4 p90 11 noticed late by median 7.9 s p90 14.6 s
runtime idle-300 pending wait: median 152.4 s p90 273.7 s max 300.0 s
poll every 5 s calls/task median 51 p90 78 noticed late by median 2.5 s p90 4.5 s
poll every 30 s calls/task median 9 p90 13 noticed late by median 14.8 s p90 26.6 s
poll 5 s x1.5, cap 60 s calls/task median 9 p90 11 noticed late by median 28.4 s p90 52.9 s
poll 15 s x2, cap 120 s calls/task median 5 p90 6 noticed late by median 59.0 s p90 107.7 s
poll first at 60 s, then 15 s calls/task median 14 p90 23 noticed late by median 7.5 s p90 13.4 s
Three things come out of it.
Exponential backoff doesn't pay here. It's the reflex for polling, but these runs last minutes, not hours. "15 s x2, cap 120 s" saves one or two calls over a flat 30 s and notices the result 40 to 60 s later at the median.
A first wait close to the typical run length is the best trade on a warm runtime: four calls at the median, results noticed within 8 s. Every call before the run could possibly be done is wasted.
The runtime's own state matters more than the client's strategy. On a desktop that has gone idle, the median run sits pending for 152 s before anyone claims it, and no polling interval changes that. If you watch read_session_state and see the task still pending after a few minutes, that's the runtime's idle backoff, not a stuck run.
Runs queued back to back on one session line up behind each other:
queued run 1: starts at 13.9 s, done at 121.9 s
queued run 2: starts at 135.9 s, done at 346.2 s
queued run 3: starts at 357.7 s, done at 406.6 s
If Codex queues three goals and polls all three on a 5 s timer, two of them spend minutes returning pending for no reason other than the queue.
Why can't a note start a run?
post_session_note needs only osfoundry:write and a Contributor seat. If a note could wake the session's agents into a run, every Contributor would be a Runner by another name. The description rules it out: "Other agents can reply to the returned external sender slug; this does not trigger an automatic run."
So the note is a message, not a trigger. It lands in the session's mail with the connection's display name. The web client shows external_sender_name and an "outside assistant" label in Team mail. Agents can answer it when they next run.
What does the mailbox return?
read_session_mail is described as "bounded notes addressed to this connection and notes it posted", with "Internal agent analysis is excluded." The schema bounds a page at limit 1 to 20 and pages back with before_id.
The script models a busy team session's mail, 409 rows of the message types the web client knows (analysis, tool_call, tool_result, agent_note, broadcast), and applies the described filter:
session mail rows 409; visible to the connection 22 (addressed to it 13, posted by it 9)
not visible, by type: {'tool_call': 97, 'tool_result': 62, 'agent_note': 25, 'analysis': 176, 'broadcast': 27}
limit 20: 2 calls to walk back through all 22 notes
limit 10: 3 calls to walk back through all 22 notes
limit 5: 5 calls to walk back through all 22 notes
The row counts are the model's. The shape is what matters: the connection's view is a small slice, so limit: 20 usually gets everything in one or two calls. The run's outcome isn't in that slice either. In the desktop runtime's code, the outcome mail is posted with no recipient, so it isn't addressed to the connection. Read results through read_session_task_result and conversation through read_session_mail, and don't expect either to stand in for the other.
Where do the schemas stop a client?
Thirteen cases against the live inputSchemas, validated with jsonschema (Draft 2020-12):
request_session_run goal 10,000 chars ok
request_session_run goal 10,001 chars REJECT maxLength: goal
request_session_run max_rounds 10 ok
request_session_run max_rounds 11 REJECT maximum: max_rounds
request_session_run extra field 'context' REJECT additionalProperties
request_session_run session_type 'graph_session' REJECT enum: session_type
read_session_task_result no task_id REJECT required
read_session_mail limit 20 ok
read_session_mail limit 0 REJECT minimum: limit
read_session_mail limit 50 REJECT maximum: limit
post_session_note subject 200, body 10,000 ok
post_session_note body 10,001 REJECT maxLength: body
post_session_note to: '@writer' REJECT additionalProperties
Two are easy to trip over. There's no context field on a run, so background goes inside goal, and the 10,000-character cap covers both. A note has no to, so you can't address one agent. Every note goes to the session, and agents reply to the connection's sender slug.
The whole script took 19.3 s wall-clock on the M1, 40 network calls included. User CPU time was 0.47 s, so the simulation itself is negligible.
If you wire this up yourself
- Publish "start work" as its own OAuth scope, and gate the role on the grant as well as the token, so neither the caller nor a stale grant can make itself a runner.
- Return immediately with a task id, and give the caller a cheap status read. Don't make an MCP tool call wait for an agent run.
- Expose the runtime's real pick-up behaviour (poll interval, one run per session) in the docs or the status, so clients poll at a sane rate instead of guessing.
- Screen anything from an outside caller as untrusted input, even when the caller holds a run seat.
- Keep notes and runs apart: a note-writer shouldn't be able to wake a run, and the read-back should exclude the session's internal analysis.
How runs line up per session is on the agent task queue page, and the other ways a session gets started are under agent triggers.
Top comments (0)