Das sind die beiden Schreib-Tools, mit denen ein Coding-Agent seinen Fortschritt an osFoundry meldet, so wie tools/list sie an jeden Client ausliefert, angemeldet oder nicht (auf die relevanten Felder gekürzt):
{"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"]}]}
Sie sehen aus wie zwei Arten zu sagen: "Daran arbeite ich gerade." Sind sie aber nicht. set_status hat keine Session und kein Pflichtfeld. Es ist eine Zeile pro Agent, die jeder Aufruf überschreibt. post_session_note braucht eine Session und einen Key, und jeder Aufruf hängt eine Zeile an, die jemand lesen wird. Dieser Post nimmt das Paar auseinander, mit read_session_mail auf der Empfängerseite, und baut die Client-Seite in Python nach. Auf diesem Mac ist kein claude-Binary installiert, also spielt das Skript Claude Code. Alles Folgende stammt aus einem einzigen Lauf.
Was sieht ein Client, bevor er sich anmeldet?
Die Schemas und eine klare Absage. Das Skript ruft initialize, tools/list und set_status jeweils zehnmal ohne Token auf:
$ 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"
Gemessen aus Japan auf einem Apple M1, die Zahlen sagen also genauso viel über die Route wie über den Server. Die Absage kommt auf zwei Wegen gleichzeitig: als HTTP 401 mit einem WWW-Authenticate-Header, der auf die Protected-Resource-Metadaten zeigt, und als JSON-RPC-Ergebnis mit isError: true. Der Body enthält den Header außerdem unter _meta als mcp/www_authenticate, für Clients, die nie auf Header schauen.
Die Reporting-Schleife nutzt fünf der 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
Die Tokens sind mit cl100k_base gezählt, für den Tokenizer von Claude also nur Näherungswerte. Achte auf den einzigen Unterschied bei den Annotations: post_session_note ist openWorldHint: true, set_status nicht. Eine Notiz erreicht Menschen und Agenten, die der Aufrufer nicht kontrolliert. Ein Status aktualisiert nur ein Feld.
Warum nimmt set_status keine Session?
Weil es dabei nicht um eine Session geht. Die Beschreibung sagt, wer ihn liest: Agenten, die "seated in a project with you" sind. Ein anderer Agent sieht ihn über list_agents, das für jeden Agenten Handle, Presence-Stufe und, "where you share a project", die aktuelle Arbeit zurückgibt. Es geht darum, Kollisionen zu vermeiden. Wenn zwei Agenten gleich fix/orders-total anfassen wollen, sollte einer von beiden das merken.
Das Schema erzwingt genau diese Form. Fünfzehn Fälle gegen die Live-Schemas mit 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
Kein session_id- und kein state-Feld, also gibt es kein "blocked"- oder "done"-Flag zum Setzen. Hängt Claude Code fest, sagt es das in task (280 Zeichen) oder, besser, in einer Notiz, die das Team liest. "Claim a profile first" ist die zweite Voraussetzung: Der Status hängt am Handle aus claim_connected_profile, und das muss auf ^[a-z0-9][a-z0-9-]{1,30}$ passen.
Was bedeutet "omitted fields keep their value" in der Praxis?
Es bedeutet, dass set_status ein partielles Update ist und ein leerer Aufruf gültig ist und nichts ändert. Das Skript baut die Regel so nach, wie die Beschreibung sie formuliert. Der Servercode von osFoundry ist nicht öffentlich, das hier ist also eine Rekonstruktion, kein Mitschnitt:
{"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}
Zwei Folgen für den Client. Updates mitten in der Aufgabe können nur task schicken, das hält sie klein. Und laut Schema läuft ein Status nie von selbst ab: kein TTL-Feld, kein Zeitstempel, den der Client setzt. Hört Claude Code auf, ohne die drei nulls zu schicken, sehen andere Agenten es weiter auf diesem Branch. Mach das Zurücksetzen zum festen Teil der Abschlussanweisungen des Agenten, nicht zu einem Nachgedanken.
Warum ist idempotency_key bei einer Notiz Pflicht, beim Status aber gar nicht vorhanden?
Ein wiederholter Status-Aufruf schadet nicht: Er schreibt dieselben Werte noch einmal. Eine wiederholte Notiz ist eine zweite Nachricht vor dem Team. MCP-Aufrufe laufen über HTTP, Antworten gehen verloren, und ein Client, der ohne Key wiederholt, kann dieselbe Notiz zweimal posten.
Das Skript bildet das nach. 200 Notizen, 10% der Antworten gehen verloren, nachdem der Server die Notiz schon gespeichert hat, bis zu drei Wiederholungen. Sowohl die Verlustrate als auch die Dedupe-Regel (eine Zeile pro Verbindung und Key) sind Annahmen. Der Pflicht-Key ist der Beleg für die zweite:
== 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
Gleich viele Aufrufe, 26 doppelte Notizen. Der Key hilft nur, wenn der Client ihn aus der Bedeutung der Notiz ableitet, etwa cc-fix-orders-total-pr, und nicht bei jedem Versuch eine neue UUID erzeugt.
Wie viele Notizen sollte ein Agent posten, der Fortschritt meldet?
Weniger, als er denkt. Auf der Seite des Owners landen Notizen im Tab Team mail der Session. AgentMailInbox.tsx im Web-Client listet sie mit Absender, einem Label "outside assistant", Empfänger und Nachrichtentyp und pollt mit refetchInterval: 5_000. Es gibt kein Push, eine Notiz taucht also beim nächsten Fetch nach ihrem Eintreffen auf.
Das Skript lässt eine Beispielaufgabe mit 30 Schritten (Schrittlängen aus einer Log-Normalverteilung, Median 30 s, eine Annahme) unter zwei Strategien laufen und misst auf einer simulierten Uhr, wann jede Notiz für einen Owner mit offenem Tab sichtbar wird:
== 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
Die Sichtbarkeit hängt nicht von der Strategie ab. Sie wird so oder so vom 5-Sekunden-Poll begrenzt. Was sich ändert, ist das Postfach: 30 Zeilen gegen 4 (gestartet, eine Frage, PR geöffnet, fertig), für vier Schreibaufrufe mehr. Das Geplapper pro Schritt gehört in den Status, der sich selbst überschreibt. Im Postfach bleibt nur, was jemand lesen muss.
Wie bemerkt Claude Code eine Antwort?
read_session_mail gibt Notizen zurück, die an die Verbindung adressiert sind, und solche, die sie selbst gepostet hat, ohne interne Agentenanalyse. Es paginiert mit before_id und limit (1 bis 20). Ein after_id gibt es nicht; Teil 2 zeigt, wie das Schema es ablehnt. "Was ist neu, seit ich zuletzt geschaut habe" ist also Sache des Clients: sich die neueste gesehene id merken, von oben lesen und aufhören, sobald eine Seite eine bekannte id enthält.
Unter der Annahme, dass die Reihenfolge neueste zuerst ist, was das Paging mit before_id nahelegt, die Beschreibung aber nicht sagt:
== 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
Ein Agent, der an seinen Meilensteinen nachschaut, hat selten mehr als eine Handvoll neuer Antworten, mit limit: 10 ist das also ein Aufruf. Wer einen echten Cursor will: Der Server hat einen, nur woanders. poll_resource_events nimmt agent_session, team_session oder loop_session mit after_sequence.
Das ganze Skript brauchte auf dem M1 19.4 s Wall-Clock-Zeit, 30 Netzwerkaufrufe eingeschlossen. Die User-CPU-Zeit lag bei 0.42 s.
Wenn du das selbst verdrahtest
- Halte "woran ich gerade bin" und "was ich dir mitteile" als zwei Tools getrennt. Das eine ist eine Zeile pro Agent, die überschrieben wird, das andere ein Append-only-Log, das jemand liest.
- Mach den Status zu einem partiellen Update mit explizitem null und leg fest, was ihn zurücksetzt. Ein Status ohne Ablauf braucht einen Client, der ihn zurücksetzt.
- Verlange einen Idempotency-Key für alles, was ein Mensch lesen wird, und sag Clients, dass sie ihn aus dem Inhalt ableiten sollen, nicht pro Versuch.
- Beschränke den Status auf die, die etwas damit anfangen können (hier: Agenten im selben Projekt), und kennzeichne ihn als Selbstauskunft.
- Gib dem Postfach einen Vorwärts-Cursor oder dokumentiere die Reihenfolge, damit Clients an einer bekannten id mit dem Paging aufhören können.
Wie Notizen innerhalb einer Session zwischen Agenten wandern, steht auf der Seite Agent Mail, die Sessions selbst findest du unter agent sessions.
Top comments (0)