Data
Read and write objects in a project's Data collections with a PAT — data_set, data_get, data_query, data_batch. Every field name is snake_case.
IonWarp stores its records as objects in a project's Data collections. All
writes are scoped to one project and authenticated with a PAT. Every argument
and every field name is snake_case, and MCP and REST take the identical name —
there are no camelCase aliases, on either surface.
The smallest write — create or update one object:
{
"name": "data_set",
"arguments": {
"project_id": "pr_acme",
"collection": "learnings",
"object_id": "learning-tests-run-under-bun",
"data": {"learning":"The test suite runs under bun, not jest.","why":"package.json test script invokes bun test."}
}
}data_get, data_query, data_batch, data_delete and data_schema_get cover
the rest; every argument is in MCP Tools, generated
from the live registry.
The same writes over REST are one flat resource path per collection, carrying the project in a header — rendered from the live resource spec on API Quickstart and on each resource's own page under API reference, so this page never has a second copy of it to get wrong.
One spelling, everywhere
project_id, object_id, event_id, reported_at, skip_action_refresh. A
timeline write carries its human-readable text in event. An object's payload is
data — never properties (an event still carries its own properties).
Batches are small
Keep each data_batch to a few operations with minimal fields, and give a heavy
collection its own small batch — several small batches beat one huge serialized
call. Each operation is { op, collection, object_id, data } where op is
"set", "event", or "delete".
Provenance
Set/event context should carry provenance: source, provider, model,
integration, worker_run_id, prompt_id, prompt_version, prompt_hash,
badges, and media. Store prompt ids, versions, and hashes — not full prompt
text. Never write slug (the server derives it), and never write secrets to
Data. Collection shapes are in Collections.
Field traps — the reads that return a WRONG answer, not an error
Every row below produced a shipped defect: a number that looked sourced and was not. None of them raises; each just quietly answers something you did not ask.
| Trap | What actually happens | Do this instead |
|---|---|---|
where is a POST-PAGE filter | It filters the page you fetched, not the collection. Rows past the first page are silently absent — a first-page count under-reported 20 as 18. | Pass a limit high enough to cover the whole collection (≤500), or paginate to exhaustion. Read fields off objects[].data. |
timeline_query returns events[], not items[] | A parser looking for items reports 0 while every write is durable. 40 notes read back as 0/40. | Read events[]. An unverified write is a named failure, never "indexing lag, OK". |
reportedAt is an envelope sibling of data | It is a millisecond-epoch integer at the top level — never inside .data, never an ISO string. row["data"].get("reportedAt") returns nothing on every row and yields a silent 0. | Compare integers against a ms-epoch cutoff. Objects expose no createdAt; the envelope reportedAt is the field. A bare except: pass around the timestamp parse is banned — a row that fails to parse is a named error, never a 0. |
data_set is a MERGE patch | Only the fields you send change; everything else is preserved. Sending a partial object does not clear the rest — and sending a half-specified record leaves stale links in place. | Send only changed fields, but keep records that carry links (goal, product, content, list ids) fully specified. Send null to clear a field. |
| Denials arrive in-band | A refused write returns a normal result with isError set — not an HTTP error. A caller that only checks the status code records a denied write as done. | Check isError on every result. |
| Canonical vs legacy field pairs | Reading a legacy field alone reports a configured value as missing — a target_value of 500 rendered as "0/? (no target set)" because only target was read. | Read the canonical pair (current_value / target_value); a missing legacy target is not "no target set". |
| List order is not a contract | Taking [0] from a list reported one project's last run as a 21-hour-old failure while a run had succeeded 33 minutes earlier. | Sort explicitly by updatedAt || lastRunAt || createdAt. |
| A capped list is a page, not a sweep | tasks_list caps at 50 rows. A count taken from it reads as a full inventory. | Say the cap out loud, or read the full collection. |
Values are snake_case; the whoami response is not | Tool arguments and stored field names are snake_case on both MCP and REST. The whoami response is a response, and carries camelCase keys such as projectPinned. | Copy the exact casing from the live contract per surface. Do not assume one casing everywhere. |
Keep batches small. 2–4 operations per data_batch, and give a heavy
collection its own batch. A single oversized batch is hard for any model to
serialize, and it truncates by dropping the later operations silently.
Aggregate at ONE altitude. A total taken from one full-collection paginated query and a total summed from per-entity pages are different numbers: the per-entity sum silently drops rows with a null owner (7 unassigned rows made "65/7" out of a true 67). Take the total once, from the full collection, and let the per-entity lines sum into it.
Rows, never prose. A record's own summary text is a claim, not a count. Where a record carries both a prose summary and countable rows, the rows win — one agent's prose said "4 sent" while its rows said 2.