Estas son las herramientas de record channel que recibe una conexión de Codex CLI desde osFoundry, tal como las lista el endpoint MCP público. Se obtuvieron con un tools/list sin autenticar el 2026-11-09 y se recortaron a los campos que importan aquí:
{"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"]}]} Fíjate en values. Es {"type": "object"} y nada más. El esquema de la herramienta no puede decir qué claves tiene un channel, cuáles son campos Select ni qué puede contener Points, porque eso cambia de un channel a otro y cambia cuando alguien edita la pestaña Schema. El único sitio donde Codex puede aprenderlo es la primera línea de la descripción de la herramienta de listado: el esquema vuelve junto con la página. El resto del post mide lo que eso aporta, sobre un rebuild, y otras dos decisiones de las mismas cuatro herramientas: un cursor before_id en lugar de un offset, y un log de eventos al que volver.
¿Qué es un valor válido?
Los tipos de campo son fijos. packages/shared/src/types/record.ts en el frontend de osFoundry define RecordChannelFieldType con 16 tipos: text, number, date, boolean, email, phone, url, select, multi_select, currency, percentage, rating, relation, file, json y formula. Cada campo lleva is_required, is_unique y un config específico del tipo: max_length para text, min/max para number, max_stars para rating, options para los dos tipos select, expression para formula.
Select es el que hace tropezar a los agentes. Una opción es {value, label, color}. Se guarda el value, y el label es lo que ve la gente. RecordDataGrid.tsx renderiza una celda select con options.find((o) => o.value === params.value) y, si nada coincide, recurre a String(params.value) en un chip apagado. Así que si una validación permisiva dejara pasar "In progress" a un campo cuyo valor es in_progress, la grid mostraría algo que se lee bien y está mal: sin color, e invisible para todo lo que agrupe o filtre por valor. Un modelo que solo ha visto los labels de la grid, o el mensaje de una persona, va a enviar labels. Alguien tiene que decir que no.
¿Cómo debería leerse un rechazo?
La API de registros de osFoundry responde a una llamada fallida con {ok: false, error: {message}}. Las herramientas de registros propias de la app de escritorio para los agentes integrados (src-tauri/src/record_tools.rs) le pasan ese mensaje al modelo sin cambios, y el comentario explica por qué: el mensaje de la API "names the field that failed validation, which is usually enough for a self-correcting retry". Su test unitario usa values.status is not a valid option como ejemplo.
Ese "usually enough" se puede medir. El rebuild prueba cuatro niveles de detalle en un rechazo:
- terse:
{"error": "validation_failed"} - field: qué claves fallaron
- values: claves, motivo y los valores de opción permitidos o el rango
- labels: lo mismo, más el label de cada opción
Este es el nivel "values", tal cual salió de la ejecución:
{"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"}]} Estos strings son del rebuild. Solo la nomenclatura values.<key> sigue la de osFoundry.
El rebuild
Aquí no se ejecuta ningún binario de Codex. record_store.py implementa list_record_items, read_record_item, edit_record_item y poll_resource_events sobre sqlite en memoria, con los mismos nombres de herramientas, nombres de argumentos y límites que en producción. Los tipos de campo y las claves de config vienen de record.ts. La longitud de la preview (120 caracteres), la forma de los resultados y todas las reglas de validación son propias del rebuild. Una edición se ejecuta dentro de BEGIN IMMEDIATE: búsqueda de idempotencia, comparación de versión, validación, merge, incremento de versión, append del evento, commit. Cualquier problema hace rollback de la edición entera.
triage.py es un Codex con guion. Trabaja sobre un bug tracker con 11 campos (Ejemplo: ticket, title, description, status, owner, severity, points, area, due, reporter, age_days), 200 items New por ejecución, seeds 1–5. Cada item recibe una intención de triage con las palabras de una persona: "In progress", "Aiko", "S2 Major" o solo "major", points de {1, 2, 3, 5, 8, 13, 20}, due "Friday" o una fecha ISO, un area. El agente o bien envía las palabras tal cual, o bien las mapea antes a través del esquema, emparejando labels con values. Ante un rechazo corrige lo que el rechazo le permite corregir, descarta lo que no puede y marca ese campo para una persona. Nunca recorta un número. Python 3.9.6, Apple M1.
¿Importa el esquema en la página?
1,000 items, 6 campos previstos en cada uno:
| El agente leyó el esquema | El rechazo dice | Campos guardados (de 6,000) | Pendientes para una persona | Llamadas de edición por item |
|---|---|---|---|---|
| no | nada | 1,363 (22.7%) | 4,637 | 7.00 |
| no | qué campo | 1,363 (22.7%) | 4,637 | 2.00 |
| no | campo, motivo, valores permitidos | 5,361 (89.3%) | 639 | 2.00 |
| no | campo, motivo, valores y labels | 5,857 (97.6%) | 143 | 2.00 |
| sí | nada | 5,857 (97.6%) | 143 | 1.86 |
| sí | qué campo | 5,857 (97.6%) | 143 | 1.14 |
| sí | valores, o valores y labels | 5,857 (97.6%) | 143 | 1.14 |
97.6% es el techo. Los otros 143 son los items en los que la persona dijo 20 points y el campo se queda en 13. Esos deben rechazarse, y se rechazaron en todas las filas.
Un agente que leyó el esquema llegó al techo dijera lo que dijera el rechazo. Su primera edición solo falló por los 143 points fuera de rango. Sin el esquema, todas las primeras ediciones fallaron: status, owner, severity y area eran labels, y la mitad de las fechas due eran "Friday". A partir de ahí, la recuperación depende por completo del error. "Qué campo" no sirve de nada sin los valores permitidos: el agente sabe que status está mal pero no qué está bien, así que lo descarta. Valores permitidos sin labels llega al 89.3%. Los 639 fallos son "Won't fix" frente a wont_fix y "major" frente a s2. Ninguno de los dos se puede emparejar desde una lista de valores. Con labels en el rechazo, el agente sin esquema se pone al día, con una llamada extra por item. Un error escueto y sin esquema hizo que el agente partiera cada edición en seis ediciones de un campo, 7 llamadas por item, y aun así guardó menos de una cuarta parte.
Así que el esquema es la mitad del diseño de la validación. La comprobación en el servidor frena los valores malos. El esquema que viaja con cada página es lo que permite acertar al primer intento. Cuesta 1,705 bytes por página en este channel, más o menos el 10% de una página de 16.7 KiB con 50 previews.
¿Por qué previews, y por qué 50 como máximo?
Una página de 50 previews ocupaba 16.7 KiB. Leer esos 50 items completos eran 124.6 KiB, porque aquí las descripciones van de 300 a 4,000 caracteres. Para hacer triage, una preview basta para decidir qué abrir: el status, el ticket, el principio del título. Codex abre los que va a cambiar. Una página acotada también evita que un channel grande inunde el contexto. La descripción de la herramienta dice "bounded" y el esquema dice maximum: 50.
¿Por qué before_id en lugar de un offset?
La gente sigue abriendo bugs mientras Codex lee. El rebuild pagina 600 items de 50 en 50, de más nuevo a más antiguo, y modifica el channel después de cada página. Cinco ejecuciones:
| Cambio tras cada página | Paginación | Páginas | Lecturas de items | Leídos dos veces | Nunca vistos |
|---|---|---|---|---|---|
| 2 bugs nuevos abiertos | offset | 65 | 3,120 | 120 | 0 |
| 2 bugs nuevos abiertos | before_id | 60 | 3,000 | 0 | 0 |
| 2 items ya leídos borrados | offset | 60 | 2,890 | 0 | 110 |
| 2 items ya leídos borrados | before_id | 60 | 3,000 | 0 | 0 |
Los items nuevos al principio empujan todo hacia abajo, así que las páginas por offset se solapan y Codex hace triage de algunos items dos veces. Borrar algo ya leído desplaza todo hacia arriba, así que la siguiente página por offset empieza después de items que nadie ha leído. 110 bugs que existieron de principio a fin nunca se vieron. Un cursor apunta al último item leído, y una inserción o un borrado en otro sitio no lo mueven. Los items abiertos durante la pasada no aparecen en ninguna página. Para eso está el log de eventos.
¿Cómo encuentra Codex lo que cambió desde la última pasada?
Después de una pasada sobre 400 items, la gente editó 30 items, abrió 12 y borró 3. Dos formas de encontrar esos 45:
| Estrategia | Llamadas | Datos devueltos | Encontrados |
|---|---|---|---|
| volver a listar todas las páginas y comparar versiones | 9.0 | 135.8 KiB | 45 de 45 |
poll_resource_events desde la secuencia guardada | 1.0 | 4.6 KiB | 45 de 45 |
El script comprueba con un assert que ambas encontraron exactamente los 45. El polling cuesta lo que cambió. Volver a listar cuesta el tamaño del channel, cada vez. El estado que hay que guardar entre ejecuciones es un único entero, la última secuencia.
¿Y la comprobación de versión?
Brevemente, porque funciona igual que en Notes y en las bases de conocimiento. En 1,000 ediciones en las que una persona movió 1 de cada 8 items entre la lectura y la escritura de Codex, usar la versión de la lectura dio 131 conflictos y sobrescribió 0 cambios de la persona. Releer la versión justo antes de escribir dio 0 conflictos y sobrescribió los 131. Tras un conflicto, el Codex con guion relee, ve que el item ya está in progress y solo añade severity.
¿Cuánto cuesta la validación?
2,000 ediciones válidas de seis campos, tres ejecuciones: mediana de 72.8–75.1 µs por edición con validación, 41.9–42.4 µs con validate() sustituido por un stub. La diferencia es la consulta del esquema y las comprobaciones por campo dentro de la transacción. Frente a un turno del modelo de varios segundos, ni se nota.
Si lo montas tú
- Devuelve el esquema con el listado, en cada página, no en una llamada aparte que el agente puede saltarse. La diferencia medida es una edición rechazada por item.
- Pon en el rechazo los valores de opción permitidos y sus labels. Con solo el nombre del campo, el agente no puede arreglar nada.
- Rechaza la edición entera cuando falle cualquier campo, y di qué campos. Un guardado parcial que informa de éxito esconde el campo que el agente hizo mal.
- Nunca conviertas labels ni recortes números en el servidor. "major" y "20 points" son preguntas para una persona, no para un matcher.
- Pagina con un cursor sobre un ID estable, y dale al agente una secuencia de eventos para la siguiente ejecución.
- Deja fuera de las herramientas del agente los cambios de esquema, las creaciones y los borrados hasta que los necesites. La validación solo significa algo si el agente no puede ampliar el campo.
La definición breve de osFoundry del protocolo que hay debajo está en su entrada del glosario sobre MCP. La pantalla del record channel de la que sale el esquema está en la página de Records.

