IonWarp logoIonWarp
Get started
IonWarp Docs
Core

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.

TrapWhat actually happensDo this instead
where is a POST-PAGE filterIt 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 dataIt 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 patchOnly 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-bandA 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 pairsReading 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 contractTaking [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 sweeptasks_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 notTool 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.

On this page

IonWarp logoIonWarp

Ready to build? Connect an agent and go — no credit card needed to start.

Get startedSee our plans