What Claude Code actually sends when it proposes an app to an osFoundry Room A developer documented the exact MCP tool schema Claude Code calls when proposing an app to an osFoundry cloud Room, showing that the propose_room_snippet_app tool creates a reviewable proposal rather than a live app. Testing with a Python stand-in client against the live inputSchema, the developer found that JSON Schema caught 4 of 8 invalid calls while the server had to enforce 4 more, including the required src/App.tsx entry file and safe relative paths. A two-file standup timer app measured 3,067 source bytes and 1,141 argument tokens, with the full 59-tool tools/list weighing 45,368 bytes. This is the tool Claude Code calls to put a small app into a cloud Room in osFoundry https://osfoundry.io , as the live server lists it in tools/list fetched without a token, trimmed to the parts that matter : { "name": "propose room snippet app", "description": "Prepare a Room snippet with source files. It appears in the cloud Room launcher only after its owner reviews and approves the full code in osFoundry.", "inputSchema": { "type": "object", "properties": { "room id": {"type": "string"}, "name": {"type": "string", "maxLength": 200}, "files": {"type": "object", "additionalProperties": {"type": "string"}}, "idempotency key": {"type": "string", "minLength": 8, "maxLength": 128} }, "required": "room id", "name", "files", "idempotency key" , "additionalProperties": false }, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": {"type": "oauth2", "scopes": "osfoundry:write" } } Four Room tools sit next to it: read room , list room apps limit 1–50 , read room app status needs room id and instance id and propose room repo app a repository id and version id instead of files . All three reads are osfoundry:read and readOnlyHint: true . Both proposals are osfoundry:write and destructiveHint: true , which is what makes Claude Code ask before calling them. The verb is the point. The tool doesn't create anything in the Room. It creates a proposal, and a person turns it into a Room tab. Below: what the schema enforces and what it leaves to the server, what a proposal weighs, why the idempotency key is required, and how a client should wait for a human. Everything was measured with a Python stand-in for the client, since no claude binary is installed on this Mac. room proto.py validates calls with jsonschema against the live inputSchema above saved as tools-list.json . Two more rules come from how osFoundry handles snippet proposals. The Room runtime's entry, src/App.tsx , has to be present. And every path has to be a safe relative path. The script applies both on top of the schema: bash $ uv run --with jsonschema --with tiktoken python3 room proto.py == validation == ok PASS no App.tsx REJECT rule: src/App.tsx missing absolute path REJECT rule: unsafe path '/etc/hosts' dot-dot path REJECT rule: unsafe path 'src/../../x.ts' backslash path REJECT rule: unsafe path 'src\\x.ts' name 201 chars REJECT schema: 'nnnn…' is too long key 7 chars REJECT schema: 'abcdefg' is too short extra field REJECT schema: Additional properties are not allowed 'visibility' was unexpected non-string file REJECT schema: {'a': 1} is not of type 'string' caught by published schema: 4, needs server rule: 4 Half of the bad calls get past JSON Schema. Path keys in a files object can't be constrained by additionalProperties . You'd need propertyNames with a pattern, and a pattern that rejects .. after normalisation gets ugly. So the schema says "map of strings" and the server judges the keys. The additionalProperties: false at the top level does real work, though. A model that invents "visibility": "public" gets a schema error rather than a silently ignored field. If you build a client, run the same check before calling. A rejected proposal costs a round trip and, worse, the output tokens to write the files again. The app in the walkthrough is a standup timer: src/App.tsx 63 lines and src/styles.css 14 lines . == size == src/App.tsx 2021 B 63 lines 583 tok src/styles.css 1046 B 14 lines 399 tok source bytes 3067, envelope bytes 3431, escape+frame overhead 11.9% argument tokens cl100k 1141 2 files: envelope 4463 B, 1394 tok 6 files: envelope 12919 B, 4078 tok 21 files: envelope 44639 B, 14143 tok tools/list 45368 B for 59 tools; the 5 room tools 3482 B 939 tok The JSON-RPC envelope adds 11.9% over the raw source, mostly escaped newlines and quotes. That's not the cost that matters. Every byte in files is model output: Claude Code writes the file and then writes it again as a tool argument. A two-file tool is about 1,100 tokens of arguments. A 21-file app, made by repeating the same component, is 14,143. Tokens are counted with tiktoken cl100k base , which is OpenAI's tokenizer, so read them as approximate. The schema publishes no byte cap for files . In practice the limit is the reviewer. The approval card prints every file in full under "Review all proposed files N ", each in its own scroll box. Nobody reads 21 files on a card. For anything that size, propose room repo app is the better fit: it points at a pinned version of a GitNest repository in the same project, and the card links to "Review repository code before approving" instead of pasting it. The tool description says it plainly: the snippet "appears in the cloud Room launcher only after its owner reviews and approves the full code". In the Room, the launcher is the app tab bar. The card reads "Approval makes this code available in the Room app launcher. Review the files before approving." Approve and create inserts the tab. Decline drops it. A pending proposal can't be opened by Room members, so an unreviewed snippet never runs in anyone's session. That puts the retry problem on the client. MCP over HTTP can lose a response after the server has acted. If the client then retries with a fresh key, the owner gets two cards for one app. room proto.py models this with an in-memory server that stores proposals by key, 1,000 proposals and a 5% chance that each response is lost: == review gate, 1000 proposals, 5% lost responses == random calls 1074 owner cards 1074 duplicates 74 in launcher before approval 0 content calls 1057 owner cards 1000 duplicates 0 in launcher before approval 0 With a new UUID per attempt, 74 of the 1,074 cards are duplicates the owner has to recognise and decline. With a key derived from the content, "snip-" + sha256 room id, name, files :32 , a retry carries the same key and the duplicate count is 0. A deliberate change to the files produces a new key and a new card, which is what you want. The schema's 8-character minimum keeps out keys like "1" . Nothing reached the launcher before approval in either run. That part is the server's job, not the client's. initialize returns "capabilities": {"tools": {"listChanged": false}} and nothing else. There is no push for "your proposal was approved". The client has three options. It can ask the user to say when they've approved, it can poll read room app status or list room apps until the new instance shows up , or it can resume poll resource events for the Room, which accepts resource type: "room" . Without a token I didn't verify which events an approval writes there, so the simulation polls status. Human review time is the input. The script draws it from a log-normal distribution with a median of 6 minutes and a one-hour budget. That's an assumption, not a measurement, but the shape most people answer within minutes, a few take half an hour is the realistic part: == polling while the owner reviews n=2000, median review 6 min, 1 h budget == review delay p50 357s p95 1816s fixed 5s calls mean 111.8 lag p50 2.4s p95 4.7s gave up 14 fixed 30s calls mean 19.0 lag p50 14.5s p95 28.4s gave up 14 backoff 5s x1.5 cap 120s calls mean 10.8 lag p50 47.0s p95 111.0s gave up 14 backoff 10s x2 cap 300s calls mean 6.1 lag p50 105.2s p95 273.8s gave up 14 Polling every 5 seconds notices approval within 5 s, at 112 calls per proposal, and each call is a model turn spent on tool output. A 30-second fixed interval is 19 calls with a 28-second worst case. Backoff to a 2-minute cap gets it down to 11 calls, and the p95 lag is 111 s. Fourteen of the 2,000 reviews took longer than an hour, and every strategy gave up on them equally. For an interactive session, the cheapest correct answer is zero calls: end the turn with "approve it under Connected → Needs your approval and tell me", then make one list room apps call. Polling is worth it only when Claude Code has other work to do in between. Real round trips, for scale: ten unauthenticated calls each from this Mac in Japan to the live endpoint. bash $ python3 probe.py initialize HTTP 200 308 B median 680 ms min 358 max 968 tools/list HTTP 200 45368 B median 687 ms min 429 max 1253 tools/call read room no token HTTP 401 337 B median 559 ms min 427 max 847 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" About 0.6 s per call. So a 5-second loop spends a tenth of its time on the network, and a model turn per call costs much more than that. read room app status returns "the safe launcher and latest build state of one app", with no config, files, logs or secrets. A snippet has no server build. The Room compiles its files where the tab is opened: in a sandboxed iframe with Sucrase in the desktop app, in a Sandpack sandbox in the browser. The repository route is pinned too, and its approval card says "This path does not build or deploy a bundle." For both proposal types, the launcher entry is the thing to wait for. A build state appears once someone deploys the tab, and that's done in osFoundry. read room says "Local Rooms are unavailable", and initialize carries the general rule: "Local-only data is unavailable to cloud clients." A local Room lives in the desktop app's own database and is never synced. The MCP endpoint is a cloud service holding a workspace-scoped OAuth token, and it has no route to one particular laptop. Reaching it would take a relay run by the desktop app: enabled per device and project, with an online/offline state and an allowlist of commands. The tool surface doesn't pretend otherwise. The same boundary applies to data inside a snippet. Its SDK storage stays on the device of the person using the tab, and none of the read tools return database content. The Room side, with the tab bar and settings, is on the Room page https://osfoundry.io/platform/rooms-detail . For the protocol in general, see the MCP glossary entry https://osfoundry.io/glossary/mcp .