This is the create call a Claude connection gets for osFoundry Notes, from an unauthenticated tools/list against the public MCP endpoint on 2026-11-09, trimmed:
{"name": "create_note_tab",
"description": "Create a new Slides or Sheet tab in an approved Note. Use the version from list_note_tabs; returns the exact new tab ID for later edits. An optional outline fills a Slides tab. A repeated idempotency key returns the first tab.",
"inputSchema": {
"required": ["note_id", "expected_version", "idempotency_key", "tab_type"],
"properties": {
"tab_type": {"enum": ["slides", "sheet"]},
"label": {"type": "string"},
"slides": {"type": "array", "minItems": 1, "maxItems": 20,
"items": {"required": ["title"], "additionalProperties": false,
"properties": {"title": {}, "body": {}, "notes": {}}}}},
"additionalProperties": false},
"annotations": {"readOnlyHint": false, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Its two neighbours for this job:
{"name": "add_note_source",
"description": "Add an HTTP or HTTPS citation to the Sources tab of an approved Note. Uses the Note version and an idempotency key; returns the new source ID.",
"inputSchema": {"required": ["note_id", "expected_version", "idempotency_key", "title", "url"]}}
{"name": "list_note_tabs",
"description": "List every tab instance and its type. A Note may have multiple Slides or Sheet tabs; use the exact tab ID returned here."}
Three phrases carry the design: "every tab instance", "returns the exact new tab ID", and "a repeated idempotency key returns the first". The rest of this post rebuilds the tools in Python and measures what each of those phrases prevents. Optimistic locking on the Note version is the subject of a separate post and only shows up here where it interacts with the other two.
What can Claude actually put on a slide?
Less than the desktop app's own assistant. Inside osFoundry, the Note assistant's slide_create_deck_from_outline and slide_add_slide (in apps/web/src/screens/notes/lib/note-surface-tools.ts) take layouts, bullets, two-column content, tables, images and one of five themes. The connector's outline item is title, body, notes, with additionalProperties: false. edit_note_tab offers slide_add for one slide and slides_add_outline for an array, both with the same three fields.
code/note_store.py loads the saved tools/list response and checks payloads against it with a tiny validator (required, enum, additionalProperties, minItems/maxItems). Eight payloads a model might plausibly send:
edit_note_tab {"operation": "slide_add", "title": "Sources", "body": "3 links"} -> ok
edit_note_tab {"operation": "slide_add", "title": "Sources", "index": 0} -> unexpected index
edit_note_tab {"operation": "slides_add", "slides": [{"title": "a"}]} -> operation: not in enum
edit_note_tab {"operation": "slides_add_outline", "slides": [{... "bullets"}]} -> slides: [0] unexpected bullets
create_note_tab {"tab_type": "slides", "slides": "[21 items]"} -> slides: maxItems 20
create_note_tab {"tab_type": "markdown"} -> tab_type: not in enum
add_note_source {"url": "https://example.org/report"} -> missing title
list_note_attachments {"kind": "sources", "limit": 100} -> ok
The last one passes this validator but not the live schema in full: limit has maximum: 50, which the rebuild doesn't check. slide_add can't pick a position, create_note_tab can't make a Markdown tab, and a deck longer than 20 slides needs more than one call. A narrow schema is a choice about who styles the deck: the connector writes content, a person in the desktop app applies themes and layout.
Why an exact tab ID and not "the slides tab"?
Because the desktop app itself still accepts the shortcut, and the shortcut picks the first match. resolveTabInstanceId() in apps/web/src/screens/notes/types.ts:
if (cfg.some((t) => t.id === rawTabId)) return rawTabId
const byType = cfg.find((t) => t.type === rawTabId)
if (byType) return byType.id
That fallback keeps old links like …/slides working. For an agent it means "edit slides" goes to whichever Slides tab sits first in the strip. New tabs get IDs like slides-9-mh8z1k2a from nextTabId() in NotesContentPanel.tsx, the fixed ones are markdown, files and sources, and a person can add any number of Slides tabs, drag them into a new order, and rename them, duplicates included (handleRename only trims).
The rebuild gives a Note K Slides tabs with distinct labels. A scripted agent lists the tabs, picks one deck as its target, and keeps a handle to it: its position in the strip, its type, its label, or its ID. Between the agent's turns the person makes zero to two random changes (move a tab 40%, rename a Slides tab to one of the five labels 35%, add a Slides tab 25%). Then the agent lists again and resolves its handle. 10,000 trials per K, seed 7:
| K decks | handle | right | wrong deck | not found |
|---|---|---|---|---|
| 2 | position | 83.7% | 16.3% | 0.0% |
| 2 | type | 50.7% | 49.3% | 0.0% |
| 2 | label | 85.6% | 2.1% | 12.3% |
| 2 | ID | 100% | 0 | 0 |
| 5 | position | 82.7% | 17.3% | 0.0% |
| 5 | type | 19.7% | 80.3% | 0.0% |
| 5 | label | 92.1% | 3.0% | 5.0% |
| 5 | ID | 100% | 0 | 0 |
The type handle is right 1/K of the time, as you'd expect. Position fails whenever a drag crosses the target, regardless of K. Label is the tempting one because it's what the person types in the prompt, and it's right most of the time. The 2–3% where it's wrong are the bad cases: another deck now carries the label, and the agent writes into it without any error. "Not found" is the safe failure. An ID is assigned once and never reused, so it survives every change the person can make in the tab strip.
Doesn't the version check already catch a wrong tab?
No. Every tab-strip change above bumps the Note version, so a stale write is turned away and the agent lists again. The re-list gives it a current version, and then it resolves its old handle against the new list. In the rebuild, 100% of those writes pass the version check, including all the ones aimed at the wrong deck. The version proves the agent saw the latest Note. It says nothing about whether the agent found the right tab in it. The tab ID does that part.
Where does the idempotency check go, before or after the version check?
Before, or the promise in the tool description breaks. Run create_note_tab 50 times per run with 30% of replies lost after the server committed, which is what a host timeout looks like from the client. The client resends the same call. Three client policies, two server orders, seeds 1–3:
| server checks | client key policy | tabs created (calls sent) |
|---|---|---|
| key, then version | new key per call | 63 (76) / 75 (100) / 62 (74) |
| key, then version | same key on retry | 50 (67) / 50 (81) / 50 (62) |
| key, then version | new key after a conflict | 50 (67) / 50 (81) / 50 (62) |
| version, then key | new key per call | 63 (76) / 75 (100) / 62 (74) |
| version, then key | same key on retry | 50 (79) / 50 (98) / 50 (72) |
| version, then key | new key after a conflict | 72 (94) / 73 (96) / 61 (72) |
The resend carries the version from before the lost commit. A server that checks the version first sees a stale version and returns a conflict instead of the first result. A client that reacts to conflicts the sensible way, by reading again and treating the next attempt as a new intent with a new key, now creates a second deck. Even a client that keeps its key sends 16–21% more calls. With the key checked first, the resend returns the original tab ID whatever the version says. That's what "a repeated idempotency key returns the first tab" has to mean.
What should the key on add_note_source be?
The rebuild runs 300 research sessions. Each has three turns, each turn cites 6 of 8 pages, and a page may come back in any of seven spellings: with a trailing slash, www., ?utm_source=…, a #fragment, http://, or no scheme. 20% of replies are lost and resent with the same key.
| key | sources stored | duplicates | per session |
|---|---|---|---|
| fresh UUID per citation, reused on resend | 5,400 | 3,038 | 10.13 |
| hash of the URL as written | 4,814 | 2,452 | 8.17 |
| hash of the normalized URL and Note ID | 2,362 | 0 | 0.00 |
A fresh key per citation handles the lost replies (no row in that first line comes from a resend) and nothing else. Research cites the same page again in the next turn, and every new turn is a new key. Hashing the URL as written catches exact repeats but not ?utm_source= or a trailing slash. The key that removes duplicates names the intent, "this page is a source of this Note", by hashing note_id plus a normalized URL (https, lower-case host without www., no fragment, no utm_*, no trailing slash).
The desktop app's own Sources form does a lighter version of this. NotesSourcesTab.tsx normalizes the typed link with new URL(...) and refuses one that's already attached ("This source is already attached"). In the rebuild, comparing browser-style hrefs would have flagged 725 of the 3,038 duplicates in the first row. The connector's add_note_source doesn't advertise a URL check, so the key is where the agent expresses it. A key derived from the URL has one side effect: re-adding the same page with a better description returns the first row. Editing it is a job for the Sources tab.
One create with an outline, or one slide at a time?
Cost of a 12-slide deck, every request validated against the live schema:
| approach | writes | request bytes | conflicts per deck |
|---|---|---|---|
create_note_tab with slides
|
1 | 1,673 | 0.070 |
empty create + 12 × slide_add
|
13 | 5,237 | 0.817 |
The conflict column is a model, not a measurement of osFoundry: 4 s between the agent's read and its write, a person saving somewhere in the Note once a minute on average (exponential), 2,000 simulated decks. Each write is a new window in which the Note-level version can move, so per-slide building takes about twelve times as many re-reads. Put the deck in the outline (up to 20 slides) and use slide_add for the slide you only know about afterwards.
Paging is the other count worth knowing. list_note_attachments caps limit at 50, so a Note with 120 sources takes 3 calls to list.
How the rebuild was run
No Claude binary runs on this machine. code/run.py plays the agent and calls code/note_store.py directly. The store is in memory behind one lock, with a counter as the version. Tool names, required fields, enums and item limits come from the live schema in code/tools_list.raw, and tab IDs follow nextTabId(). Error shapes, where keys are scoped (per Note here) and the idempotency order are the rebuild's own choices, and the second order exists only to show what goes wrong. Python 3.9.6 on Apple Silicon, all five experiments in 1.0 s wall time. The output is code/run.out.
If you wire this up yourself
- Return a per-instance ID from every list and create call, and make edit tools require it. Don't resolve types or labels on the write path, even if your UI still accepts them in URLs.
- Check the idempotency key before the version. A resend after a lost reply always carries the old version.
- Tell agents in the tool description what a key should name. For citations, "this URL in this document", normalized, removes repeats across turns. A random UUID only covers resends.
- Keep the connector's slide shape small (title, body, notes) and leave design to the person in the editor. A small schema means fewer fields a model can get wrong.
- Let agents send a whole outline in one call and cap it (osFoundry uses 20). Fewer writes means fewer conflict windows on a shared document.
- Return attachment metadata, never content or signed links, through the agent channel. A link outlives the session that fetched it.
What the Sources tab holds on the person's side is on the Sources tab page. The protocol underneath is summed up in osFoundry's MCP glossary entry.
Top comments (0)