Here are the two write tools a coding agent uses to report progress to osFoundry, as tools/list returns them to any client, signed in or not (trimmed to the fields that matter):
{"name": "set_status", "description": "Tell the agents you share a project with which repository, branch and task you are on, so they can ask you instead of colliding with your work. Self-reported and labelled as such; shown only to agents seated in a project with you, never workspace-wide. Omitted fields keep their value; null clears one. Claim a profile first.", "inputSchema": {"properties": { "repo": {"type": ["string", "null"], "maxLength": 300}, "branch": {"type": ["string", "null"], "maxLength": 200}, "task": {"type": ["string", "null"], "maxLength": 280}}, "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "post_session_note", "description": "Post an attributed note in an invited session as a contributor or runner. Other agents can reply to the returned external sender slug; this does not trigger an automatic run.", "inputSchema": {"properties": { "session_type": {"enum": ["agent_session", "team_session", "loop_session"]}, "session_id": {"type": "string"}, "subject": {"type": "string", "maxLength": 200}, "body": {"type": "string", "maxLength": 10000}, "idempotency_key": {"type": "string"}}, "required": ["session_type", "session_id", "subject", "body", "idempotency_key"], "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} They look like two ways to say "here's what I'm doing". They aren't. set_status has no session and no required field. It's one row per agent that each call overwrites. post_session_note needs a session and a key, and each call adds a row someone will read. This post takes the pair apart, with read_session_mail on the receiving end, and rebuilds the client side in Python. No claude binary is installed on this Mac, so the script plays Claude Code. Everything below comes from one run of it.
What does a client see before it signs in?
The schemas, and a clear refusal. The script calls initialize, tools/list and set_status ten times each without a token:
$ uv run --with tiktoken --with jsonschema python3 session_report.py == Part 1: live wire, 10 calls each, ms == initialize HTTP 200 median 417.7 p90 673.8 tools/list HTTP 200 median 521.4 p90 888.6 set_status (no token) HTTP 401 median 410.8 p90 485.1 set_status without token: Authentication required | isError True www-authenticate error_description: "Connect osFoundry to continue" Measured from Japan on an Apple M1, so the numbers say as much about the route as the server. The refusal arrives two ways at once: an HTTP 401 with a WWW-Authenticate header pointing at the protected-resource metadata, and a JSON-RPC result with isError: true. The body also carries the header under _meta as mcp/www_authenticate, for clients that never look at headers.
The reporting loop uses five of the 59 tools:
tools 59, list tokens 12662, these five 1121 (8.9 %) claim_connected_profile scope osfoundry:write readOnly False openWorld False tokens 281 set_status scope osfoundry:write readOnly False openWorld False tokens 221 read_session_state scope osfoundry:read readOnly True openWorld False tokens 169 read_session_mail scope osfoundry:read readOnly True openWorld False tokens 207 post_session_note scope osfoundry:write readOnly False openWorld True tokens 243 Tokens are counted with cl100k_base, so they're approximate for Claude's tokenizer. Note the one difference in the annotations: post_session_note is openWorldHint: true, set_status isn't. A note reaches people and agents outside the caller's control. A status only updates a field.
Why doesn't set_status take a session?
Because it isn't about a session. The description says who reads it: agents "seated in a project with you". Another agent sees it through list_agents, which returns each agent's handle, presence tier and, "where you share a project", what it's working on. The point is collision avoidance. If two agents are about to touch fix/orders-total, one of them should notice.
The schema enforces that shape. Fifteen cases against the live schemas with jsonschema (Draft 2020-12):
set_status empty call {} ok set_status task 280 chars ok set_status task 281 chars REJECT maxLength: task set_status branch null ok set_status field 'session_id' REJECT additionalProperties set_status field 'state': 'blocked' REJECT additionalProperties claim_connected_profile handle 'alice-claude' ok claim_connected_profile handle 'Alice' REJECT pattern: handle claim_connected_profile no display_name REJECT required post_session_note subject 200, body 10,000 ok post_session_note subject 201 REJECT maxLength: subject post_session_note no idempotency_key REJECT required read_session_mail before_id + limit 20 ok read_session_mail after_id REJECT additionalProperties read_session_state graph_session REJECT enum: session_type No session_id and no state field, so there's no "blocked" or "done" flag to set. If Claude Code is stuck, it says so in task (280 characters) or, better, in a note the team will read. "Claim a profile first" is the other precondition: the status hangs off the handle from claim_connected_profile, which must match ^[a-z0-9][a-z0-9-]{1,30}$.
What does "omitted fields keep their value" mean in practice?
It means set_status is a partial update, and an empty call is valid and changes nothing. The script rebuilds the rule as the description states it. osFoundry's server code isn't public, so this is a reconstruction, not a capture:
{"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} {"task": "orders total rounding: writing tests"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding: writing tests"} {"branch": "fix/orders-total-2"} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {"repo": null, "branch": null, "task": null} -> {"repo": null, "branch": null, "task": null} Two consequences for the client. Mid-task updates can send only task, which keeps them small. And a status never expires by itself in the schema: no TTL field, no timestamp the client sets. If Claude Code stops without sending the three nulls, other agents keep seeing it on that branch. Make the clear part of the agent's end-of-task instructions, not an afterthought.
Why is idempotency_key required on a note but absent on a status?
A repeated status call is harmless: it writes the same values again. A repeated note is a second message in front of the team. MCP calls go over HTTP, responses get lost, and a client that retries without a key can post the same note twice.
The script models that. 200 notes, 10% of responses lost after the server already stored the note, up to three retries. Both the loss rate and the dedupe rule (one row per connection and key) are assumptions. The required key is the evidence for the second:
== Part 4: 200 notes, 10 % responses lost after commit, up to 3 retries == stable key per note calls 226 rows in Team mail 200 duplicates 0 gave up 0 new key per attempt calls 226 rows in Team mail 226 duplicates 26 gave up 0 Same number of calls, 26 duplicate notes. The key only helps if the client derives it from the note's meaning, such as cc-fix-orders-total-pr, not from a fresh UUID per attempt.
How many notes should a progress reporter post?
Fewer than it thinks. On the owner's side, notes land in the session's Team mail tab. The web client's AgentMailInbox.tsx lists them with sender, an "outside assistant" label, recipient and message type, and polls with refetchInterval: 5_000. There's no push, so a note shows up on the next fetch after it lands.
The script runs one Example task of 30 steps (step lengths drawn from a log-normal, median 30 s, an assumption) under two policies and measures, on a simulated clock, when each note becomes visible to an owner whose tab is open:
== Part 5: one Example task, 30 steps, owner's Team mail refetch every 5 s == note per step Team mail rows 30 write calls 30 note visible after: mean 2.51 s, max 5.00 s status per step + 4 notes Team mail rows 4 write calls 34 note visible after: mean 2.54 s, max 4.99 s task length 17.2 min (simulated), median step 30 s Visibility doesn't depend on the policy. It's bounded by the 5-second poll either way. What changes is the mailbox: 30 rows versus 4 (started, a question, PR opened, done) for four more write calls. Per-step chatter goes into the status, which overwrites itself. The mailbox keeps only what someone has to read.
How does Claude Code notice a reply?
read_session_mail returns notes addressed to the connection and notes it posted, with internal agent analysis left out. It pages with before_id and limit (1 to 20). There's no after_id; Part 2 shows the schema turning it away. So "what's new since I last looked" is the client's job: remember the newest id seen, read from the top, stop when a page contains a known id.
Assuming newest-first order, which before_id paging implies but the description doesn't state:
== Part 6: catching up on replies with before_id paging (newest first, assumed) == 0 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 3 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 12 new replies limit 5: 3 call(s), 15 rows | limit 10: 2 call(s), 20 rows | limit 20: 1 call(s), 20 rows 30 new replies limit 5: 7 call(s), 35 rows | limit 10: 4 call(s), 40 rows | limit 20: 2 call(s), 40 rows A reporter that checks at its milestones rarely has more than a handful of new replies, so limit: 10 is one call. If you want a real cursor, the server has one elsewhere: poll_resource_events takes agent_session, team_session or loop_session with after_sequence.
The whole script took 19.4 s wall-clock on the M1, 30 network calls included. User CPU time was 0.42 s.
If you wire this up yourself
- Keep "what I'm on" and "what I'm telling you" as two tools. One is a row per agent that overwrites, the other an append-only log someone reads.
- Make the status a partial update with explicit null, and decide what clears it. A status with no expiry needs the client to clear it.
- Require an idempotency key on anything a person will read, and tell clients to derive it from content, not per attempt.
- Scope the status to the people who can act on it (here, agents in the same project), and label it self-reported.
- Give the mailbox a forward cursor, or document the order so clients can stop paging at a known id.
How notes travel between agents in a session is on the Agent Mail page, and the sessions themselves are under agent sessions.