These are the record channel tools a Codex CLI connection gets from osFoundry, as the public MCP endpoint lists them. They were fetched with an unauthenticated tools/list on 2026-11-09 and trimmed to the fields that matter here:
{"name": "list_record_items",
"description": "List the schema and a bounded page of item previews from one granted record channel. Use read_record_item for the complete values.",
"inputSchema": {"required": ["record_channel_id"],
"properties": {"record_channel_id": {"type": "string"}, "before_id": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50}}},
"annotations": {"readOnlyHint": true}}
{"name": "read_record_item",
"description": "Read one item by ID from a granted record channel and return its current version.",
"inputSchema": {"required": ["record_channel_id", "item_id"]}}
{"name": "edit_record_item",
"description": "Update validated fields in one record item. Supply the version from read_record_item; concurrent changes are rejected.",
"inputSchema": {"required": ["record_channel_id", "item_id", "values", "expected_version", "idempotency_key"],
"properties": {"values": {"type": "object"}}},
"annotations": {"readOnlyHint": false, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Look at values. It's {"type": "object"} and nothing more. The tool schema can't say which keys a channel has, which ones are Select fields, or what Points may hold, because those differ per channel and change when someone edits the Schema tab. The only place Codex can learn them is the first line of the list tool's description: the schema comes back with the page. The rest of this post measures what that buys, on a rebuild, and two other choices in the same four tools: a before_id cursor instead of an offset, and an event log to come back to.
What is a valid value?
The field types are fixed. packages/shared/src/types/record.ts in the osFoundry frontend defines RecordChannelFieldType as 16 types: text, number, date, boolean, email, phone, url, select, multi_select, currency, percentage, rating, relation, file, json and formula. Each field carries is_required, is_unique and a type-specific config: max_length for text, min/max for number, max_stars for rating, options for the two select types, expression for formula.
Select is the one that trips agents. An option is {value, label, color}. The value is stored, and the label is what people see. RecordDataGrid.tsx renders a select cell with options.find((o) => o.value === params.value) and falls back to String(params.value) on a muted chip when nothing matches. So if a lenient check let "In progress" into a field whose value is in_progress, the grid would show something that reads right and is wrong: no colour, and invisible to anything that groups or filters by value. A model that has only seen the grid's labels, or a person's message, will send labels. Somebody has to say no.
How should a rejection read?
osFoundry's record API answers a failed call with {ok: false, error: {message}}. The desktop app's own record tools for in-app agents (src-tauri/src/record_tools.rs) hand that message to the model unchanged, and the comment says why: the API's message "names the field that failed validation, which is usually enough for a self-correcting retry." Its unit test uses values.status is not a valid option as the example.
"Usually enough" is measurable. The rebuild tries four levels of detail in a rejection:
- terse:
{"error": "validation_failed"} - field: which keys failed
- values: keys, reason, and the allowed option values or the range
- labels: the same, plus each option's label
Here's the "values" level, verbatim from the run:
{"error": "validation_failed", "fields": [
{"key": "status", "message": "values.status must be one of the option values, got \"In progress\"",
"allowed": ["new", "triaged", "in_progress", "fixed", "wont_fix"]},
{"key": "owner", "message": "values.owner must be one of the option values, got \"Aiko\"",
"allowed": ["aiko", "mika", "ren"]},
{"key": "points", "message": "values.points must be between 0 and 13, got 20", "min": 0, "max": 13},
{"key": "due", "message": "values.due must be a date as YYYY-MM-DD, got \"Friday\""},
{"key": "age_days", "message": "values.age_days is a formula field and is computed, not written"}]}
These strings are the rebuild's. Only the values.<key> naming follows osFoundry's.
The rebuild
No Codex binary runs here. record_store.py implements list_record_items, read_record_item, edit_record_item and poll_resource_events on in-memory sqlite, with the live tool names, argument names and limits. The field types and config keys come from record.ts. The preview length (120 characters), the result shapes and every validation rule are the rebuild's own. An edit runs inside BEGIN IMMEDIATE: idempotency lookup, version compare, validation, merge, version bump, event append, commit. Any problem rolls the whole edit back.
triage.py is a scripted Codex. It works on a bug tracker with 11 fields (Example: ticket, title, description, status, owner, severity, points, area, due, reporter, age_days), 200 New items per run, seeds 1–5. Each item gets a triage intent in a person's words: "In progress", "Aiko", "S2 Major" or just "major", points from {1, 2, 3, 5, 8, 13, 20}, due "Friday" or an ISO date, one area. The agent either sends the words as they are, or maps them through the schema first, matching labels to values. On a rejection it fixes what the rejection lets it fix, drops what it can't, and flags that field for a person. It never clamps a number. Python 3.9.6, Apple M1.
Does the schema in the page matter?
1,000 items, 6 intended fields each:
| Agent read the schema | Rejection says | Fields saved (of 6,000) | Left for a person | Edit calls per item |
|---|---|---|---|---|
| no | nothing | 1,363 (22.7%) | 4,637 | 7.00 |
| no | which field | 1,363 (22.7%) | 4,637 | 2.00 |
| no | field, reason, allowed values | 5,361 (89.3%) | 639 | 2.00 |
| no | field, reason, values and labels | 5,857 (97.6%) | 143 | 2.00 |
| yes | nothing | 5,857 (97.6%) | 143 | 1.86 |
| yes | which field | 5,857 (97.6%) | 143 | 1.14 |
| yes | values, or values and labels | 5,857 (97.6%) | 143 | 1.14 |
97.6% is the ceiling. The other 143 are the items where the person said 20 points and the field stops at 13. Those should be refused, and they were in every row.
An agent that read the schema hit the ceiling whatever the rejection said. Its first edit failed only for the 143 out-of-range points. Without the schema, every first edit failed: status, owner, severity and area were labels, and half the due dates were "Friday". From there, recovery depends entirely on the error. "Which field" is useless without the allowed values: the agent knows status is wrong but not what's right, so it drops it. Allowed values without labels gets 89.3%. The 639 misses are "Won't fix" against wont_fix and "major" against s2. Neither can be matched from a value list. With labels in the rejection, the schemaless agent catches up, at one extra call per item. A terse error with no schema made the agent split every edit into six one-field edits, 7 calls per item, and it still saved under a quarter.
So the schema is half of the validation design. The check on the server stops bad values. The schema that rides along with each page is what lets the first attempt be right. It costs 1,705 bytes per page in this channel, about 10% of a 16.7 KiB page of 50 previews.
Why previews, and why at most 50?
A page of 50 previews was 16.7 KiB. Reading those 50 items in full was 124.6 KiB, because descriptions here run 300–4,000 characters. For triage, a preview is enough to pick what to open: the status, the ticket, the start of the title. Codex opens the ones it's going to change. A bounded page also keeps a large channel from flooding the context. The tool description says "bounded" and the schema says maximum: 50.
Why before_id instead of an offset?
People keep filing bugs while Codex reads. The rebuild pages 600 items 50 at a time, newest first, and changes the channel after every page. Five runs:
| Change after each page | Paging | Pages | Item reads | Read twice | Never seen |
|---|---|---|---|---|---|
| 2 new bugs filed | offset | 65 | 3,120 | 120 | 0 |
| 2 new bugs filed | before_id |
60 | 3,000 | 0 | 0 |
| 2 already-read items deleted | offset | 60 | 2,890 | 0 | 110 |
| 2 already-read items deleted | before_id |
60 | 3,000 | 0 | 0 |
New items at the head shift everything down, so offset pages overlap and Codex triages some items twice. Deleting something already read shifts everything up, so the next offset page starts past items nobody has read. 110 bugs that existed from start to end were never seen. A cursor names the last item read, and an insert or a delete elsewhere can't move it. Items filed during the pass aren't on any page. That's what the event log is for.
How does Codex find what changed since the last pass?
After a pass over 400 items, people edited 30 items, filed 12 and deleted 3. Two ways to find those 45:
| Strategy | Calls | Data returned | Found |
|---|---|---|---|
| list every page again, compare versions | 9.0 | 135.8 KiB | 45 of 45 |
poll_resource_events from the stored sequence |
1.0 | 4.6 KiB | 45 of 45 |
The script asserts both found exactly the 45. Polling costs what changed. Re-listing costs the size of the channel, every time. The state to keep between runs is one integer, the last sequence.
And the version check?
Briefly, because it works the same way it does for Notes and knowledge bases. In 1,000 edits where a person moved 1 in 8 items between Codex's read and its write, a version from the read gave 131 conflicts and overwrote 0 of the person's changes. Re-reading the version just before writing gave 0 conflicts and overwrote all 131. After a conflict, the scripted Codex re-reads, sees the item is already in progress, and only adds severity.
What does validation cost?
2,000 valid six-field edits, three runs: median 72.8–75.1 µs per edit with validation, 41.9–42.4 µs with validate() stubbed out. The difference is the schema query and the per-field checks inside the transaction. Against a model turn of several seconds it doesn't register.
If you wire this up yourself
- Return the schema with the listing, every page, not from a separate call the agent may skip. The measured gap is one rejected edit per item.
- Put the allowed option values and their labels into the rejection. A field name alone doesn't let the agent fix anything.
- Reject the whole edit when any field fails, and say which fields. A partial save that reports success hides the field the agent got wrong.
- Never coerce labels or clamp numbers on the server. "major" and "20 points" are questions for a person, not for a matcher.
- Page with a cursor on a stable ID, and give the agent an event sequence for the next run.
- Keep schema changes, creates and deletes out of the agent's tools until you need them. Validation only means something if the agent can't widen the field.
osFoundry's short definition of the protocol underneath is in its MCP glossary entry. The record channel screen the schema comes from is on the Records page.
Top comments (0)