Actions
An action is a typed write into a source dataset — validated before anything touches disk, snapshotted before the write, and reversible after.
An action is a typed write into a source dataset. It's how new rows reach your data through
the same checkpointed path the agent edit loop uses: validated before anything touches disk,
snapshotted before the write, reversible after. You declare an action in the manifest; an agent
runs it over MCP.
What an action can write
Only a source dataset. A file-backed dataset (CSV, JSON, JSONL) or a SQLite table takes
inserts. A derived sql dataset never does, because it's a query,
not a file. Pointing an action at a sql dataset is a named validation error.
Declaring one
An action names its target dataset and a mode. Four modes are available:
| Mode | What it does |
|---|---|
insert | Appends rows (default). |
replace | Overwrites all rows in the dataset with the new rows. |
delete | Drops rows where every column in the match predicate equals the given value (equality, multi-column AND). An empty match is rejected. |
update | Patches matching rows: match selects the rows, set applies the new values. An empty match is rejected. |
"actions": {
"log_meal": {
"dataset": "meals",
"mode": "insert"
},
"delete_meal": {
"dataset": "meals",
"mode": "delete"
},
"fix_meal": {
"dataset": "meals",
"mode": "update"
}
}The match predicate and new values for delete and update are passed at call time via
runActions, not in the manifest. match is equality-only in v1 — no ranges or operators.
That alone inserts rows whose columns match the meals data. Most actions add a fields block
to constrain or annotate specific columns.
The row schema
An action carries no hand-written schema. It derives one from the live data: the compiler
infers each column's type from the dataset, then layers your fields overrides on top. The
result is a strict schema (unknown columns are rejected) that an agent reads before it sends a
single row.
A fields.<column> entry narrows one column:
"actions": {
"log_meal": {
"dataset": "meals",
"mode": "insert",
"fields": {
"meal_type": { "enum": ["breakfast", "lunch", "dinner", "snack"] },
"calories": { "type": "number", "min": 0 },
"source": { "default": "manual" }
}
}
}Each override key does one job:
type: constrain the column tostring,number,boolean, ordate, overriding what was inferred.enum: restrict a string column to a fixed set of values.min/max: numeric bounds.default: a value applied when a row omits the column.description: a note that rides along with the schema, so the agent knows what the column means.
A key in fields that doesn't match a real column is an error. You can only override columns
that exist.
What happens on a write
An agent calls oi.app().runActions([{ action, rows? }]). Before a single byte lands:
- Every row (for
insert/replace) is validated against the resolved schema. One bad row (wrong type, a value outsidemin/max, an unknown column) rejects the whole call with an error naming the row index and the field, and nothing is written. - For
delete/update, thematchpredicate is checked — an emptymatchis rejected outright to prevent an accidental full-table wipe. - The target file is snapshotted to
.openislands/history/, so every write is reversible withrollback.
Then the write lands: rows appended or replaced in a flat file, or the matched rows deleted / patched. A SQLite insert needs the file and table to exist already; a flat file is created if it's missing.
All writes are all-or-nothing and path-confined: an action can only write the one source file
its dataset names. There is no general file write.
No null in flat files
CSV and other flat-file datasets store no null. Pass "" for an empty string value, or omit
the field entirely to apply its default. Sending null in a row is a validation error.
Running an action
Actions belong to the agent edit loop, not to a CLI command. An agent:
- Calls
oi.app().listActions()to get each declared action and its resolved row JSON Schema (the live schema merged withfields). That schema is its grounding for a valid row. - Calls
oi.app().runActions([...])with one call object per action:
| Mode | Call shape |
|---|---|
insert | { action, rows: [{ col: val, ... }, ...] } |
replace | { action, rows: [{ col: val, ... }, ...] } |
delete | { action, match: { col: val, ... } } |
update | { action, match: { col: val, ... }, set: { col: newVal, ... } } |
Multiple calls in one runActions are atomic by default — if any call fails validation,
nothing is written; a mid-batch write failure rolls back earlier writes automatically.
Note
Because every mutation is schema-checked and snapshotted before it runs, data that tries to
talk an agent into a bad write still can't get past validation or escape rollback.
Surfacing an action to humans
An action isn't agent-only. Drop a form.entry island on a
page and point it at an insert action, and the runtime renders a form — one typed input per field,
the action's types, enums, ranges, and defaults carried straight through — with a submit button that
inserts a row. Point a board.kanban at an update
action and dragging a card POSTs match + set the same way. Both share the write path
runActions uses: validate, snapshot, write, then the bound dataset's islands refresh live.
See MCP Server for Code Mode and the full oi API, and Connectors for
syncing a provider's data into source datasets on a schedule, through this same write path.
Queries
A query is a typed, read-only read over one dataset — a declarative spec the compiler translates to a parameterized SELECT, declared in the manifest and called on demand.
Connectors
A connector syncs an external provider's data into your source datasets through the same checkpointed path actions use, so a sync is as reversible as any other write.