app/rcc/ai/skills/api_semantics.md
Schemas tell you what's accepted. This skill tells you what each value MEANS, when things execute, and what the corners look like. Load it whenever a behavior question (timing, lookup, identity) is about to send you down a debugging hole.
A dataset has three integer-shaped identifiers, and they are NOT the same thing.
| Field | Set by | Used for |
|---|---|---|
datasetId | Auto, on dataset.add | CRUD APIs: project.dataset.update, project.dataset.delete, every setOption*. Position within the group. |
index | User | Position in the parser's output array (1-based). The parser's parse(frame)[i] populates the dataset whose index == i + 1. Patchable via project.dataset.update {index: N}; bulk renumbers should go through project.batch (see the "Bulk mutations" section). |
uniqueId | Allocated, persisted | OPAQUE stable handle for datasetGetRaw / datasetGetFinal, the __datasets__ system table, xAxisId, and workspace WidgetRef.groupId. Stays stable across reorders, renames, and moves. |
Treat uniqueId as opaque. It's allocated from a project-level
counter (nextUniqueId in the project JSON) when a dataset or group
is created, duplicated, or imported, then persisted on disk. Two
common assumptions are wrong:
(sourceId, groupId, datasetId). The old
sourceId*1_000_000 + groupId*10_000 + datasetId formula is only
used as a one-shot back-fill when loading projects from before this
scheme. Once those projects are saved again, the persisted values
are what you'll see. Arithmetic on a uniqueId is meaningless.xAxisId references, workspace WidgetRef.groupId values, and any
other persisted refs survive untouched.Resolve unfamiliar datasets by path or title, then use the returned
uniqueId:
// Looking up a dataset by name, in scripts or tools
project.dataset.getByPath { path: "Audio/Channel A" }
project.dataset.getByTitle { title: "Channel A", groupId: 0 }
project.dataset.getByUniqueId { uniqueId: 10001 }
assistant.dataset.resolve { path: "Audio/Channel A" }
// Inside transforms / painter scripts: read peers via the API
const v = datasetGetFinal(uid) // uid from the response above
project.dataset.list, project.group.list's datasetSummary, and
project.snapshot all return uniqueId on every dataset. Duplicating
a dataset (or group) allocates a fresh uniqueId, so a copy is
distinguishable from the original.
When users say "the third dataset," ask them which they mean: the
project-editor row order is datasetId, the parser-output position is
index. They CAN diverge if index was edited.
Workspace IDs are a separate range: always >= 1000. Auto-generated tabs start at 1000-1001 and per-group at 1002+; user-created workspaces start at 5000.
bytes from driver
│
▼
FrameReader splits on delimiters, stamps each frame with a timestamp
│
▼
FrameBuilder: parse(frame) -> array of channel strings (or 2D array)
│
│ for each parsed row (1+):
▼
┌─────────────────────────────────────────────────────────────┐
│ for each group (project order): │
│ for each dataset (project order): │
│ 1. raw = channels[index - 1] │
│ 2. setDatasetRaw(uniqueId, raw) │
│ 3. if transformCode: final = transform(raw) │
│ - sees: all raw, final of EARLIER datasets only, │
│ Constants + persisted Computed register │
│ values (writes from prior frames + this one) │
│ 4. setDatasetFinal(uniqueId, final) │
├─────────────────────────────────────────────────────────────┤
│ TimestampedFramePtr published once, shared by all consumers │
└─────────────────────────────────────────────────────────────┘
│
├─► Dashboard widgets (visualization update on UI tick, 60 Hz default)
│ └─► Painter onFrame() then paint(ctx,w,h) per painter widget
├─► CSV / MDF4 export workers (lock-free queue, batch on worker thread)
├─► API / gRPC / MQTT publishers
└─► Session DB writer (Pro)
The cycle in prose form, for each parsed frame in a source:
parse()[index - 1].setDatasetRaw(uniqueId, value).transformCode is non-empty, call transform(value). The
transform sees:
setDatasetFinal(uniqueId, value).So: a transform on dataset C in group 1 can read final values of datasets A and B that came earlier in the same group (or earlier groups). It cannot read final values of D or later, because they haven't run yet.
Painters run on the UI refresh tick, NOT on every parsed frame. The
dashboard repaints at 60 Hz by default (configurable 1-240 via
dashboard.setFps); if frames arrive faster, the painter
samples whichever frame was latest at tick time. A painter reading
datasetGetFinal(uid) always sees the most recent fully-processed
value, but might skip intermediate frames between two onFrame()
calls. Don't put per-frame accumulators in painter onFrame(); that
belongs in a transform, where every frame fires.
hotpathRxSourceFrame(sourceId, data) processes one source's frame at
a time. Each source has its own dataset list and parser.
The data table store is shared across sources. So:
sourceId=0 can tableGet / datasetGetFinal values
written by sourceId=1, but it sees whatever was last written,
which is sourceId=1's previous frame, not its current one.When a frame parser returns a 2D array (N rows × C channels), the
FrameBuilder treats each row as its own logical frame and assigns
timestamps as chunk.timestamp + step * i where step is the
driver-provided cadence in nanoseconds. So:
TimestampedFramePtr carries the correct interpolated
time. CSV/MDF4 export and the dashboard see strictly monotonic time.When step isn't set by the driver, FrameBuilder estimates it from
the chunk size and the previous chunk's timestamp. Audio drivers
populate it directly; UART / network usually leave it 0.
tableGet(table, register) and datasetGetRaw / datasetGetFinal(uid):
undefined (JS) / nil (Lua) AND logs a
one-shot warning per (table, register) miss to the runtime console.
No throw. Always if (val === undefined) ... or if val == nil then .... The warning helps catch typos; look for
[DataTableStore] Missing register ... in the runtime log on first
occurrence.tableSet
with a number stays numeric; written with a string stays string. Don't
rely on coercion. When you need a number, call Number(val) /
tonumber(val) first.tableSet only writes Computed registers. Writing to a Constant
register name silently no-ops.tableGet on the very first frame returns the Constant
default declared in the project, not "zero from a reset".__datasets__ is the auto-generated system table. Each dataset has
two registers: raw:<uniqueId> and final:<uniqueId>. You almost
never read those directly; datasetGetRaw / datasetGetFinal are
the typed shortcuts and avoid the string-key arithmetic.
project.dataset.setOptions, setOption, and the options field on
dataset.add accept string slugs (preferred) or integer bitflags
(back-compat).
| Slug | Bit | JSON key (.ssproj) |
|---|---|---|
"plot" | 1 | graph: true |
"fft" | 2 | fft: true |
"bar" | 4 | widget: "bar" ┐ |
"gauge" | 8 | widget: "gauge" ├ mutually |
"compass" | 16 | widget: "compass" ┘ exclusive |
"led" | 32 | led: true |
"waterfall" | 64 | waterfall: true (Pro) |
project.dataset.setOptions { groupId, datasetId, options: ["plot","fft"] }
project.dataset.setOption { groupId, datasetId, option: "fft", enabled: true }
The integer bitflags above do NOT line up with the DashboardWidget
enum integers used by project.workspace.addWidget, which is
exactly why slugs exist. Use slugs and the collision disappears. See
dashboard_layout for the full table.
Three independent pairs per dataset (Plot, Widget, FFT/Waterfall). The API parameter names you write are NOT the keys you read back:
Read (responses, .ssproj) | Write (project.dataset.update) | Drives |
|---|---|---|
plotMin / plotMax | pltMin / pltMax | Plot, MultiPlot Y-axis |
widgetMin / widgetMax | wgtMin / wgtMax | Gauge dial, Bar fill, Meter arc |
fftMin / fftMax | fftMin / fftMax | Expected raw input range used to normalize the FFT/Waterfall input to [-1, +1]. NOT a dB axis. |
Writing {"plotMin": 100} to dataset.update returns success: true
and writes nothing, because the field name doesn't match the param check.
The response now carries result.warnings with a code: "unknown_field"
entry listing every dropped key, so the trap is no longer fully silent,
but you have to read the warnings array. Always:
pltMin/wgtMin (etc.) on the WRITE side.result.warnings after the call. Any
{code: "unknown_field", fields: [...]} entry means those keys were
dropped. Fix and re-issue; do not assume success means applied.project.dataset.getByPath and confirm the
response shows your values under plotMin/widgetMin/fftMin. If
they're still 0, the write was silently dropped.fftMin/fftMax are identical in both directions, so they don't trip
this trap.
project.{group,dataset,action,outputWidget}.update accept any subset
of writable fields and return success: true. Fields they don't
recognize (typos, fields that aren't on this struct, runtime-only fields
like numericValue) are dropped with a structured warning instead of a
hard error:
{
"success": true,
"result": {
"groupId": 0,
"datasetId": 5,
"updated": true,
"warnings": [
{
"code": "unknown_field",
"fields": ["plotMin", "fooBar"],
"message": "These fields were ignored because they are not patchable via project.dataset.update. Call meta.describeCommand for the list of writable fields, or check your spelling."
}
]
}
}
When you see unknown_field, the call did NOT mis-apply; it skipped
those keys entirely. Re-issue with the correct names. Don't tell the
user the change was applied without re-reading the dataset; an
unknown_field warning means the parts you misnamed are still on the
old value.
Schema-side, meta.describeCommand{name: "project.dataset.update"} lists
the canonical writable fields; the description block on each *.update
command also enumerates them.
project.batch and project.dataset.addMany40 sequential project.dataset.update calls is 40 round-trips and 40
autosave-debounce restarts. Two endpoints collapse that. If the user
is asking for a change that scales with N, your first thought should
be batch.
project.dataset.list returns more than ~10 rows the user
wants edited.project.dataset.update /
project.group.update / project.dataset.setOptions more than ~5
times.If you've already issued 2 individual mutations and are about to issue a 3rd that looks similar: STOP and convert the rest into a batch.
project.batch: exact shapeThe single required parameter is ops (NOT commands, NOT mutations,
NOT requests). Each element is {command: <registered name>, params: <object>}. Forgetting the params wrapper and putting per-call args at
the top of the op object is the most common mistake: those args are
silently ignored and the underlying handler then errors with MissingParam
on the keys it expected to find inside params.
project.batch {
ops: [
{ command: "project.dataset.update", params: {groupId:0, datasetId:0, title:"LED 1", index:1} },
{ command: "project.dataset.update", params: {groupId:0, datasetId:1, title:"LED 2", index:2} },
...
],
stopOnError: false // default: keep going past failures
}
Returns {results: [{index, command, success, result|error}, ...], total, succeeded, failed, aborted}. Each op is dispatched through
the same CommandRegistry::execute path as a direct call, so per-op
error semantics (validation_failed, missing_param, etc.) are
unchanged.
Critical caveats:
stopOnError: true aborts the loop on the first
failure, but already-mutated state stays mutated. Treat it as
"save-suspend wrapper", not "database transaction."command: "project.batch" inside
ops returns INVALID_PARAM). Don't wire batch into batch.widgetSettingsChanged mid-loop.When to use:
When NOT to use:
project.batch is mutation-oriented;
reads still work but you don't get cross-op transactionality.project.dataset.addManyproject.dataset.addMany {
groupId: 0,
count: 40,
options: 32, // bitfield, OR an array of slugs
// ["plot","fft"] -> 1|2 = 3
// ["led"] -> 32
// bits: 1=Plot, 2=FFT, 4=Bar, 8=Gauge,
// 16=Compass, 32=LED, 64=Waterfall (Pro)
titlePattern: "LED {n}", // {n} -> startNumber + i, {i} -> 0-based
startNumber: 1, // optional, default 1
startIndex: 1 // optional: -1 = auto-assign next slot,
// 0 = leave unset, 1+ = consecutive slots
}
Returns {count, created: [{groupId, datasetId, title, index, uniqueId}, ...]}.
Capture the response before the next call. The datasetIds are
the keys required for any follow-up project.dataset.update /
setOptions calls.
Same autosave-suspend window as project.batch. Use it for sensor
arrays, channel banks, or any "create N similar datasets" pattern. For
finer post-creation tweaking (per-dataset transforms, units, ranges),
follow up with one project.batch of project.dataset.update calls
keyed off the returned datasetIds.
The pairs DO NOT CASCADE. A dataset wired to Plot + Gauge needs both
pltMin/pltMax AND wgtMin/wgtMax set, or one widget renders
against the default 0/0. See dashboard_layout for the full
widget→pair mapping and recipes.
Failed tool calls carry error.data.category. Distinct categories
that matter:
validation_failed: fix args; schema is in error.data.inputSchema.unknown_command: look at error.data.did_you_mean.license_required: propose a non-Pro path.connection_lost: ask the user to reconnect; don't retry.script_compile_failed: iterate via frameParser.dryCompile (compile
only) or frameParser.dryRun / transform.dryRun (compile + execute).
frameParser.dryRun now drives the full pipeline (extraction, decoder,
parser) and requires raw stream bytes: inputBytesHex is recommended,
inputBytes is fine for ASCII. There is no parser-only sampleFrame
shortcut anymore; the legacy fallback hid extraction and decoder bugs
from the dryRun response.bus_busy: brief retry, then surface.permission_denied: OS-level (filesystem, network) refusal.hardware_write_blocked: the runtime refuses io.* / console.send
writes for safety. Distinct from permission_denied. Explain to the
user that hardware writes are gated; suggest building an Output
Control tile so the user triggers the write themselves.file_not_found: ask for the right path.execution_error: everything else; read the message.