cd /news/ai-agents/what-claude-code-actually-sends-when… · home › topics › ai-agents › article
[ARTICLE · art-166067] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read8 min views1 publishedNov 9, 2026

This is the tool Claude Code calls to put a small app into a cloud Room in osFoundry, 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:

$ 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.

$ 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. For the protocol in general, see the MCP glossary entry.

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/what-claude-code-act…] indexed:0 read:8min 2026-11-09 · —