data_write
Write one row, or a BATCH of rows in a single call.
⚠️ Provenance goes in comment, never in origine. Put WHAT you
established and WHERE it came from in <field>.comment, and the page in
<field>.link. origine is the platform's layer — an agent never writes
it. Writing <field>.origine is REFUSED unless the call declares
origine_override=true — which belongs to an import, not to a write of
your own.
Layers: valeur/comment/link are yours to write, origine is read only — write nested, {"field": {"valeur": …, "comment": …, "link": …}}, never these as top-level keys of your own row. Provenance (WHAT you established, WHERE it came from) goes in <field>.comment, the page in <field>.link — never in origine, the platform's layer. A misspelled layer name next to a known layer ({"valeur": …, "comnent": …}) is REFUSED; alone ({"field": {"comnent": "x"}}), it is no layer: that dict IS the value.
What a write destroys, what readonly and the business key protect,
and where the REST face differs: guide datastore-semantics
(oto_guide op=read slug=datastore-semantics).
⚠️ Writing a value DROPS the comment and link that came with it —
they described the OLD value. To keep one while the value changes, send it
back as it is.
Two gestures on a cell, and only two:
null = ERASE the cell: {"field": null}. The erased value is kept
nowhere: the response hands it back once, in valeurs_effacees.
"@empty" = "searched, nothing found", its reason in comment:
{"valeur": "@empty", "comment": "registry and imprint: none"}. It
satisfies required (a plain "" does not) and reads back "" — or
"@empty" with empties="sentinel". On a layer (comment, link), it
only empties that layer.
⚠️ On a cell that holds a value: keep it and write the layers alone
({"field": {"comment": "…"}}), erase it with null, or discard it with
@empty and the reason in comment. A field you leave out is "not mine" —
its value stays. A comment alone is a note beside the value, never
"searched, nothing found".
⚠️ "@keep" and "@clear" are not accepted: a write carrying them anywhere is REFUSED whole. Instead of @clear, write null. Instead of @keep, leave the sub-field out — or, for a comment or link that must survive a value that changes, send it back as it is.
"" and [] are values: they REPLACE the value in place, like any other
value, and the replaced value comes back in valeurs_effacees. To keep a
value, leave the field out; to erase it, write null.
⚠️ Business key: ADDING a row whose key value already exists is REFUSED (business_key_exists, naming the existing row) unless you pass upsert=true, which merges onto it. To DESIGNATE a row, pass its id=, or name the business key with key= (a batch, or a single row naming the declared key): an existing key value then MODIFIES its row and a new one creates it, no upsert needed. Without id or key=, a write ADDS a row. Two rows of one call with the same key value refuse the WHOLE batch, before anything is written, unless upsert=true. A batch reports every merge in fusions — {rang, dans_rang, id, cle}, dans_rang being the earlier row of the SAME batch that made the target row, null for a row already in the table — and ids stays aligned row for row. On a CLOSED table (new_rows: "reject") every write without id designates by the key.
⚠️ A write into a column the schema does NOT declare is REFUSED (unknown_column), on every table — nothing is written, a batch is refused WHOLE. To write a NEW column, declare it first — data_patch_schema(datastore=…, fields=[{"key": "<column>", "type": …}]) — then write. ⚠️ 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. A violation is refused (row_invalid; an off-options value alone in its fault is set aside and the rest of the row is written). Only what a write SETS is judged: a row already off-format still accepts a write to its OTHER columns (the stale value is reported in hors_type). No head setting changes this.
⚠️ @empty must be the ENTIRE sub-field, alone. Mixed into a sentence it
is just text and gets stored as such — "@empty ; nothing on the imprint"
lands in the cell verbatim, and a client reads it in their deliverable. The
reason goes in comment.
In a list, the word goes on the element's field:
{"contacts": [{"nom": "Alice", "fonction": "@empty"}]}. A list whose schema
declares no of.key is REPLACED by what you send: read the row with
empties="sentinel" and send each element back as it came, so an assumed
empty stays "@empty". Refused on an element's identity (of.key), in a list
of plain values, and inside an object or a json column.
ONE element of a list is written at its RANK — the address you read and
filter by: {"contacts[1].email": "d@x.fr"} (or {"valeur": …, "comment": …}; contacts[1].email.comment alone annotates it, null erases it).
{"contacts[+]": {…a whole record…}} appends, {"contacts[0]": null} removes.
Ranks start at 0 and point at the list AS YOU READ IT; the append comes last.
To add or remove WITHOUT reading the row first: {"tags[+]": ["x", "y"]}
appends one element or a list of them, in order, duplicates kept;
{"tags[-]": ["x"]} removes every occurrence of each value (plain values
only — a record is removed at its rank; an absent value is refused). Both are
applied under the row lock: two concurrent appends both land. On a text
column, {"journal[+]": "…"} appends a line to the cell (a list = several
lines) without resending it; a log kept by many is better as a table — past
64 000 characters (unless the column declares max_length) an append is
refused: move the log to a table, one row per entry.
Refused, with the form that works: a rank that does not exist (append with
contacts[+]), contacts[0]: {…} (write its fields), contacts[].x, and the
whole column together with one of its ranks.
A requirement declared on a sub-field (required, options, max_length…
on an element's nom) is enforced like one on a column. In a list with
of.key, only the elements your write CHANGES are judged: an element sent
back unchanged never blocks yours — its defect comes back in hors_type.
Written at its rank, only that element is judged, with or without of.key.
Dates: a date/datetime column reads ISO in any variant, 04/09/2026
(day first) and Unix timestamps, and STORES one form — 2026-09-04T10:00:00Z
(UTC) for an instant, 2026-09-04 for a day; known only to the month or the
year, it stays 2026-09 / 2026. An instant without a time zone is taken as
UTC, and notices says so: write its offset (+02:00) if it is local time.
A schema refusal ends with the payload to send back: only the fields to fix,
a <…> template in place of each value, | @empty where that is allowed —
{"contacts": [{"role": "RH", "nom": "<text> | @empty"}]}. Fill the
templates and write again; a list goes back WHOLE, that element fixed in
its place (by its of.key, else its rank), the others as they were — or
write only that element's fields at its rank (contacts[1].nom).
⚠️ A write DESTROYS what is in the column. On an open column there is no
undo: the previous value leaves the row the moment yours lands. It survives
only in the row's revision journal — data_row_history serves each write's
before and after, kept 90 days by default — and nothing puts it back for you.
If the value was supplied by the table's owner, announce what you are about to
change on a column you did not fill yourself.
⚠️ There is NO automatic safety net. origine: "system" was REMOVED on
2026-09-08. It captured the previous value lazily, on the first write that
changed it — which required having been declared BEFORE the row existed.
Declared after the fact it caught nothing: 837 cells out of 846 lost on one
campaign table, the owner's values already overwritten by agents.
What replaces it is a DECLARED gesture, carried by the call that brings the
data in: donnees_d_origine=true. It sets the FIRST version of the data (the
origin) and marks the write as an import in the journal — the current value
and the origin both go in at the moment the value enters, so there is
no "before" and no "after" to get wrong. Three rules: an origin ALREADY set
is never touched (a re-import updates the current value, never the origin) ;
an EMPTY column receives nothing (supplying nothing is not supplying blank) ;
the call's layers go into BOTH versions.
So on a column whose origin was never captured, overwriting is FINAL in the
row: only data_row_history still shows the previous value. The 28 799 origine layers already in the
base are untouched — they are still read, served and protected; it is the
mechanism that went, not the data.
Re-writing the SAME value changes nothing and captures nothing, by design. And it does not depend on how the row was created: a row appended through the MCP tool and one created through the REST face behave identically (measured 2026-09-04, both faces call the same store).
SINGLE (row): WITHOUT id = ADD a NEW row (new JSON keys auto-create
columns, unless the table is CLOSED — see below); if the table declares a
business key and your row carries a value that already exists, the
business-key rule above applies. WITH key=<the declared key> = DESIGNATE the
row by its key value: an existing value modifies that row and returns its
_id, a new one creates it. WITH id = PARTIAL update of that row (only
provided fields change). Returns the row
(with _id/_created_at/_updated_at/_revision).
expected_revision (with id only) — Only when what you write was COMPUTED
from a row you read (a list or text you re-send whole, a status chosen from the
current one): pass that read's _revision. If the row changed since — any
column, or its reservation — nothing is written and the call is refused with
revision_conflict and the current revision: read again, recompute, write
again. Omit it otherwise: writes to different columns never overwrite each other.
On a row you CLAIMED, address it by the _id of the row data_claim_next
returned.
BATCH (rows = list of dicts): write them all at once — for importing a
dataset without round-tripping each row through your context. If a business
KEY is in effect (the key arg, else the datastore's declared schema.key):
with key= the batch DESIGNATES — a row whose key value exists modifies that
row, a new value creates one; without key= it ADDS, and the business-key
rule above applies to values already in the table. Two rows of the batch
with the same key value follow that rule either way; rows without a key are
appended. Returns a summary
{inserted, updated, count, key, ids, fusions?} — ids holds one _id per row
sent, rank for rank. Use data_set_schema to
declare a persistent key. For a FILE, never retype its rows here: oto_import
loads it from where it is (link, Drive, project file), oto_upload_url takes
it from your disk.
⚠️ An error on the way BACK (expired session, dropped connection, timeout)
does NOT mean the write failed: it may have committed before the error.
Read the row back (data_rows, by id or filtered on its key) before
re-sending.
Re-sending is safe with id, or with key= (or upsert=true) on a table
with a business key: the row is designated, or merged; a keyless append
re-sent creates a DUPLICATE.
⚠️ A table can be CLOSED by its schema (new_rows: "reject", next to its
business key) — data_get_schema says whether it is. On such a table there
is NO append at all: a write designating no existing row (no id, and no key
value the table already carries) is REFUSED, single row and batch alike, and
nothing is created — including a key value that is simply NEW. That is a
deliberate setting of that table, not a platform rule. To make a row EXIST
there, it is a schema move and not a write:
data_patch_schema(datastore=…, new_rows="create"), your write, then
data_patch_schema(datastore=…, new_rows="reject") to close it back.
origine_override=true belongs to an IMPORT, not to a write of your own:
it declares that this call knowingly sets the origine layer — refused
without it. ⚠️ For a real import, prefer
donnees_d_origine=true, which sets the first version (origin) in one gesture; the
override only says "I know what I am doing on this layer".
⚠️ A COLUMN can be LOCKED by the schema (readonly: true) — it holds a
value someone put there, and an ordinary write that CHANGES it is refused by
name (writing the same value again is fine, and <column>.comment always
stays open for what another source says). The lock holds what is SET, not
what is missing: a cell with no value (key absent, null or "") can be filled
once; @empty ("searched, nothing") IS a value, and clearing one (null)
is a change. To REPLACE it anyway, pass
readonly_override=true ON THIS CALL. It is open to the OWNER of the table
(you, your org or your team) or to whoever GOVERNS it — a table merely SHARED
with you in write is refused, by design. It applies to this one call and
nothing else: there is no schema setting to reopen and therefore none to
close back. Every forced replacement is written to the call journal (row,
column, replaced value) next to who called. ⚠️ That journal entry is
BOUNDED: at most 25 replacements per call, each value cut at 120
characters (…, with was_len/now_len giving the original length). When
it is cut short it says so (readonly_forced_bilan: "25 of N") — the count
you read there is then a floor. The full history, whole values, is the row
history: data_row_history.
⚠️ The datastore must EXIST first (create it with data_create_datastore);
writing to an unknown datastore raises "datastore inconnu" — it is NOT
auto-created.
⚠️ Address the table by its NUMBER. The reply carries ns_id — the
table's number — and that is the form to pass as datastore:
data_write(datastore=174, id=…). A name still resolves until 2026-11-08,
then it is REFUSED.
datastore also accepts slot:<name> = the table BOUND under that slot
name by the ACTIVE project (procedures reference tables as slot:name;
the project maps the name via its links). Requires an active project +
the binding — otherwise an actionable error, never a fallback.
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_write"
}
}Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| id | No | omit = append a new row ; provided = partial update of that `_id` (the one data_write / data_claim_next returned for that row). | |
| key | No | DESIGNATES rows by this business key column: an existing value modifies its row, a new one creates it — no `upsert` needed. BATCH (with `rows=[…]`): any column (default `schema.key`, but then the batch ADDS). Alongside a single `row`, accepted only when it names the table's DECLARED business key, `row` carries its value and no `id` is given; refused otherwise — wrap the row in `rows=[…]` to designate by another column. | |
| row | No | single-row content as a dict (JSON-encoded automatically). | |
| _org | No | Org (id) de CET APPEL. `oto_list_orgs()`. | |
| rows | No | BATCH mode — a list of row dicts written in one call. | |
| force | No | force the NAMED columns on this call instead of everything it carries — `["raison_sociale", "raison_sociale.origine"]`. Naming them is enough; no need for `readonly_override` as well. ⚠️ It changes the SCOPE, not the right: forcing stays reserved to the table's owner or whoever governs it. A locked column absent from the list is refused normally, and the refusal says so. | |
| _group | No | Équipe (id) de CET APPEL, sous son org parente. | |
| layers | No | shape of the ROW this write returns (`flat` default, `nested`) — same as `data_rows`. Single row only. | flat |
| upsert | No | without `id` only. `true` = MERGE when the key value already exists — for a write that ADDS (neither `id` nor `key=`), and for rows of one call that share a key value. A write that designates by `key=` needs no `upsert`. Every merge of a batch comes back in `fusions`. Without it, an adding write on an existing key, or two rows of one call on the same key, is REFUSED (`business_key_exists`). Refused on a table with no business key: there is nothing to merge on. | |
| _run_id | No | OBLIGATOIRE à 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. | |
| empties | No | how the returned row serves an assumed empty (`plain` default, `sentinel`) — same as `data_rows`. Single row only. | plain |
| _project | No | Projet (id) de CET APPEL — jeton PRIMAIRE. `oto_project(op='list')`. | |
| versions | No | which versions of each cell the returned row carries. Default: the current value only; the origin (the FIRST version of the data) is asked for with `versions=["current","origine"]`. Single row only; the reply states what it served in `versions_servies`. | |
| datastore | Yes | the table's NUMBER (`ns_id`, e.g. 174) — the form to use. Its name still resolves until 2026-11-08, then is refused. `slot:<name>` also works. It must already exist. | |
| origine_override | No | IMPORT path only — declares that this call sets the `origine` layer knowingly (without it, such a write is refused). ⚠️ `origine: "system"` was removed on 2026-09-08: there is no longer a "formatted column". For a real import, prefer `donnees_d_origine`. This call only. | |
| donnees_d_origine | No | sets the FIRST version of the data (the origin) and marks the write as an import in the journal: this call brings data AS THE CLIENT HANDED IT OVER. Each cell gets its `origine` version frozen at the same time as its current value, carrying the same layers, so `comment` says where the data came from. Use it for the import itself, NOT for enrichment: an agent's findings are the current version. An origin already set is never overwritten (a re-import updates the current version and leaves the origin alone), and an empty cell gets nothing — the client handed over nothing there, which is not the same as handing over an empty value. | |
| expected_revision | No | with `id` only — the `_revision` of that row as you read it, when what you write was computed from that read (see above). | |
| readonly_override | No | `true` = overwrite the `readonly` columns THIS CALL writes, instead of being refused. Owner or governor of the table only ; valid for this call alone ; journaled. |