Skip to main content
Glama

data_set_schema

Declare (or clear with schema=null) a datastore's TYPED schema (ADR 0032 §6).

A typed datastore renders as readable cards/records instead of a flat table. schema = {"fields": [{"key": str, "label"?: str, "type"?: "text|number|date| datetime|bool|json|object|list|url|email|phone|ref|enum", "options"?: [str], "display"?: "title", "role"?: "status|metric|note|qualif", "description"?: str, "meta"?: {…}}], "key"?: str, "new_rows"?: "create|reject", "description"?: str, "meta"?: {…}}. An enum column's allowed values go in options, ON the field ({"key": "status", "type": "enum", "options": ["kept", "rejected"]}) — that is what constrains the values and what a board groups on. Never in meta: a key the field admits is REFUSED there (stored, it would do nothing). ⚠️ The vocabulary is CLOSED: a key its level does not admit is REFUSED — at the head, on a field, on a sub-field, in of, in lifecycle. The refusal names the path, the key and the closest known one (read_only → readonly), or where it belongs (states → lifecycle.states, semantic_search → a parameter of this call). An unknown key ALREADY stored and left unchanged is tolerated (named in warning), never one you add or change. Your own annotations go in meta — an object, allowed at every level, carried as is, never read, bounded in size. Help text goes in description (note, help, hint, placeholder are refused). Each level's keys, who reads each one and the meta bound (meta_max_bytes) are served at GET /api/datastore/schema/keys. ⚠️ This REPLACES the schema — it does not merge. Any setting absent from the body you send DISAPPEARS (a field note, a bound, options, the business key and its UNIQUE index). The response now names what it just removed (declarations_effacees, with the lost VALUES — it is the only copy left) and enforced (the validation keys THIS version applies). To EDIT a schema without risking part of it, use data_patch_schema: it merges by key and cannot destroy what it does not name. The response also carries existing_violations: per path (contacts[].email), the rows ALREADY IN PLACE that the posed schema condemns — rows, blocking_rows (rows that accept no write at all until fixed), sample_ids and consequence. Clean them before arming the guard; existing_violations_scope.complete: false means the row cap was hit and the counts are floors.

The optional top-level "key" names the field that is the row's BUSINESS KEY (e.g. "email", "siren"). A write that DESIGNATES by the key — key= passed to the call (a batch, or a single row naming this key), or any write without id on a CLOSED table — modifies the row of an existing key value and creates a new one. A write that ADDS (neither id nor key=) with a key value already present is REFUSED unless it passes upsert=true, which merges onto that row — data_write, REST and oto_upload_url alike. The key never duplicates: its UNIQUE index guarantees it. ⚠️ A column the schema does NOT declare is REFUSED at write, on EVERY table: declaring a column (data_patch_schema(fields=[{"key": …}])) is the ONLY way to add one. ⚠️ The declared format is a CONTRACT on every table: top-level options are enforced, the SHAPE of text/url/object/list/enum values is checked, an unknown layer is refused and a declared sub-record refuses an attribute it does not declare. There is NO head setting for it any more: unknown_columns was REMOVED on 2026-10-05 and is refused — columns and values are always checked. ONE head setting is left (strict, unknown_fields and key_required were replaced on 2026-10-02; unknown_columns was REMOVED on 2026-10-05 — no setting decides what is checked, all are refused):

  • "new_rows" — whether a NEW row may be born: "create" (default) or "reject", which CLOSES the table: a write that designates NO existing row (no id, and no key value the table already carries) is REFUSED instead of creating one. Needs key. A table often fills up before it has its key, hence the default. data_get_schema serves it AS APPLIED (reglages). Pass schema=null to switch back to free-table mode.

PRESENTATION — the schema also DRIVES THE UI (there is no visual editor: this tool IS the way to configure how a table looks):

  • field ORDER in the record view = the order of fields here. Reorder the list to reorder the form.

  • type picks the WIDGET: date/datetime → date picker (readable, not a raw ISO string), url → compact field + open link (never a giant text box), email → mail field, enum (+ options: [...]) → dropdown, bool → true/false, number → numeric. Untyped fields fall back to a plain text box, so DECLARE the type when the rendering matters.

  • width: "half"|"full" = the field's width in the record form. Without it the width is derived from the widget — declare it to keep a stable layout.

  • hidden: true = keep the field OUT of the table columns by default (still editable in the record). Use it for opaque ids and technical fields.

  • display: "title" NAMES the row: that field titles the record everywhere the server names a line (work queue, undo, cards) instead of a raw _id. One per table.

  • role only steers the dashboard's rendering (metric tiles, note placed last, status badges) — oto does not read it: a lifecycle block designates its own column, no role needed. ⚠️ The dashboard still titles rows from role: "title", which the server no longer reads. On a table meant to look right in BOTH, declare both until they converge.

STRUCTURED RECORDS (ADR 0046 — every layer opt-in):

  • nested types: type:"object" + fields:[…] (sub-record, e.g. occupant); type:"list" + of:<field-def> (list of scalars or sub-records, e.g. contacts = list of {nom, titre, email}).

  • write validation: field.required: true, type conformity, field.required_when: {"<field>": "<value>"} (e.g. deliverables required when status="qualified" — ⚠️ to UNDO, clear the gated field AND leave the triggering state in the SAME data_write call: the gate is judged on the MERGED row, so a call that only clears the field while the state still matches is refused), field.max_length: <int> on a SCALAR field, and field.pattern: "<regex>" for its SHAPE when the size does not separate anything (a code, a snake_case identifier) — re.search, so anchor it yourself (^…$). A pattern applies on its own: the value it reads is bounded by max_length when declared (≤1000), else by 1000 characters — a longer value is refused. The cost of a regex is bounded against that length, and oto refuses what it cannot price — an ambiguous repeated group, a backreference, a lookaround are rejected AT DECLARATION TIME, each naming why. type: "phone" holds a phone number: international (+, country code — E.164) preferred, national digits accepted, spaces, dots and dashes allowed; a sentence or an identifier is refused. type: "ref", "target": <table number> is a LINK column: the cell holds the _id of a row of that table (a list of links: "type": "list", "of": {"type": "ref", "target": <number>}). A name written there is refused — find the row with data_rows on the target and write its _id. The target must be a table you can read. field.required_layers: ["comment"] refuses a write that leaves a NON-EMPTY value in that column without posting the layer — the value must arrive WITH its provenance, in the SAME call ("col": {"valeur": …, "comment": "where it comes from"}). A null or empty value, and a layer posted alone without a value (a note beside the value, not an assumed empty — that is @empty), trigger nothing — even when the value already in place lacks the layer: that is how you add it ("col": {"comment": …}), without re-sending the value. Applies to sub-fields of objects and of list items too; never to readonly nor the lifecycle column; never to a column this write does not name. It arms ITSELF — no head setting needed. ⚠️ It does NOT make a comment TRUE: it forces you to NAME a source, which makes a lie checkable — the truth is still established on the documents. Every declared constraint applies (the format as a whole from the date above). A non-conforming write FAILS naming the culprit (max_length reports the actual length AND the bound; pattern reports the value it saw AND the motif). Only what a write SETS is judged: a row whose stored value is already off-format still accepts a write to its OTHER columns — the stale value is named back in hors_type, never a reason to refuse. Fields the caller does NOT write — one question ("whose column is this?"), and each refusal names the field, the reason and where the thing goes: field.readonly: true refuses a write that CHANGES a value already SET (layers stay open — what another source says goes in <field>.comment); a cell with NO value (key absent, null or "") can still be filled once, @empty counts as set, and clearing a set value (null) is a change; ⚠️ field.origine: "system" was REMOVED on 2026-09-08 and is now REFUSED like any unknown key. Nothing captures a previous value automatically any more: the origin is set by the call that BRINGS the data, with donnees_d_origine=true (data_write says how). Re-sending the SAME value is never a write, so re-emitting a record you just read always passes. Bound the fields meant to hold ONE short value (a job title, a city): a column that collects reasoning stops being groupable/filterable. The bound applies to the keys a write actually SETS, so rows already over it keep working until that field is rewritten — and setting a bound on a table that already overflows answers with a warning saying how many.

  • lifecycle: the column that carries the block IS the status column (no role tag needed), lifecycle: {states:[…], transitions:{from:[to…]}, terminal?:[…]} — unknown state or undeclared transition is refused. Each transitions value is a LIST, even for one destination: {"a": ["b"]}; {"a": "b"} is refused at declaration; terminal is a LIST too. ⚠️ It no longer releases the work-queue claim: writing a "final" state does NOT free the row (#317). Release is a gesture of the LOCK — data_release, or closing your run — never an inference from a business value.

  • step labels: lifecycle.labels: {"a_qualifier": "To qualify", …} — the name a screen shows for each state instead of its code. Presentation only: no write reads it, a row still carries the code. Each key must be a state of states (an unknown one is REFUSED and named), each value a non-empty string of at most 60 characters; a state without a label is fine (the screen derives one from the code).

  • work-queue ceiling: lifecycle.max_claims: <int >= 1> + lifecycle.abandon_state: "<terminal state>" — a row claimed that many times WITHOUT a successful write leaves the queue in that state, with a platform reason in _abandon (ending with what the last attempt hit; its run in _abandon_run). Both go together: a ceiling without an abandon state, or an abandon state that is not terminal, is REFUSED here. Counter (_claims) resets on the first successful write to the row. Undeclared, a platform default ceiling (3) still applies: the row is set aside with its reason in _abandon, its status left untouched. lifecycle.claimable: {col: val | {op: val}} (filter grammar) = the rows the queue SERVES: no claim hands out a row outside it, whatever filter says.

  • passes: lifecycle.advance: {"societe": "dirigeant", "dirigeant": "email"} — the counterpart of abandon_state. A claimed row RELEASED (data_release, run_finish) after at least one successful write since its claim moves to the next state, as a platform revision of that run. Agents claim by state and never write it; if a pass writes the state itself, its write wins. No write, no move (the ceiling applies). Each step must be a declared transition, never from a terminal state — refused here otherwise.

SEMANTIC SEARCH (#67 V2.2 — opt-in per datastore): pass semantic_search=true to make this datastore's ROWS findable by MEANING via oto_search (not just exact words), embedding each row (has a per-row cost → off by default; enable it on the tables you actually search by concept). false turns it off and purges the embeddings. Passing ONLY semantic_search leaves the schema untouched.

Tool call template

Replace each placeholder with a value matching the input schema. Only required parameters are included; add optional parameters as needed.

{
  "id": 1,
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "arguments": {
      "datastore": "<datastore>"
    },
    "name": "data_set_schema"
  }
}

Input Schema

NameRequiredDescriptionDefault
_orgNoOrg (id) de CET APPEL. `oto_list_orgs()`.
_groupNoÉquipe (id) de CET APPEL, sous son org parente.
schemaNothe schema object, or null to clear it. Head key `new_rows: "create"|"reject"` (`unknown_columns` is refused: no setting any more); a field may carry `readonly: true` (a value once set is locked, an empty cell fills; layers open). ⚠️ `origine` was REMOVED on 2026-09-08 and is refused — the origin is set by the call that brings the data (`donnees_d_origine=true`), not by a schema format.
_run_idNoOBLIGATOIRE à chaque appel dès `run_start` ou `data_claim_next` : sans lui, l'écriture sur une ligne réservée est refusée. Hérité seulement si le serveur tient un run actif — n'y compte pas.
_projectNoProjet (id) de CET APPEL — jeton PRIMAIRE. `oto_project(op='list')`.
datastoreYestarget datastore (must exist; you must have write access).
semantic_searchNotrue/false to toggle semantic row search; null = leave as is.

Schema Changelog

Changes observed during successful MCP inspections.

No schema history has been recorded yet.

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full burden and does so: it warns that the call REPLACES rather than merges, that absent settings disappear (including the business key and its UNIQUE index), and that the response names what was removed via declarations_effacees plus existing_violations and enforced. It also discloses closed-vocabulary refusals, cost/opt-in nature of semantic_search, and the rule that une clé inconnue déjà stockée is tolerated but never one you add.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness2/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The critical scoping facts are front-loaded, but the body is an extremely long wall of warnings, ADR references, and repeated deprecation history (the unknown_columns removal is stated twice, plus origine/strict/unknown_fields/key_required removals) that an agent must parse to find usable rules. Presentation, structured-record, lifecycle and semantic-search sections read as documentation spilled into the tool definition rather than the minimum needed to call it.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a destructive, one-required-param replace operation with no annotations and no output schema, the description covers destruction semantics, validation contracts, refusals, response fields and the revert path. Nothing an agent needs in order to call this safely appears to be missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, but the underlying schema is largely untyped (empty {} properties, additionalProperties: false), so the description must supply the meaning — and it does: the full field-definition grammar (type vocabulary, options for enum, display, role, width, hidden, readonly, required_when, max_length, pattern, ref/target, lifecycle shapes) plus head settings. It even resolves failure modes the schema cannot express, such as enum options refused in meta and {"a": "b"} being refused versus {"a": ["b"]}.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource+scope: "Declare (or clear with schema=null) a datastore's TYPED schema", and immediately explains the effect (typed datastore renders as cards instead of a flat table). It also names the sibling it is not — "To EDIT a schema without risking part of it, use data_patch_schema" — so an agent can route correctly without opening another schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit branching: use this tool to replace/declare, use data_patch_schema to merge-edit, use data_get_schema to see it as applied, and pass schema=null to revert to free-table mode. It also carves out the edge case of passing ONLY semantic_search, which leaves the schema untouched — exactly the kind of when/when-not guidance needed for a destructive replace.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Try in Browser

Glama MCP Gateway

Add one secure layer between your agents and this server.

Resources