V4 sync job reference
A version 4 sync job is a graph of named nodes: requests fetch data, transforms reshape it, and destination nodes send it to a webhook or a datastore. This page lists every node type and field the V4 runtime accepts.
Skip the reading — fast path for coding agents
truto sync-jobs create --stdin < job.json
/skill truto-operator
The job
| Field | Required | What it does |
|---|---|---|
default_runtime_version |
Yes | 4. Always send it: a job created without it runs on version 2. |
label |
No | A name for the job. Defaults to Untitled. |
integration_name |
No | The integration the job is for, or "" for a job any integration can use. |
resources |
Yes | The nodes, as an array. A run of a job with no nodes is refused with sync_job_no_resources. |
args_schema |
No | The arguments a run takes. See Args. |
args_validation |
No | A JSONata expression over args. Return null to accept the run, or { "message": "..." } to refuse it with 400 and that message. |
mutex_key |
No | A placeholder template, such as {{args.integrated_account_id}}. While a run with the same resolved key is still active, a new run is refused with 400 and truto_error_code: sync_job_run_not_completed. |
state_key |
No | A placeholder template that names where get_state and update_state keep their values. Runs that resolve to the same key share state. A state node in a job without a state_key fails the run. |
delay_between_requests_ms |
No | A pause between requests. |
Args
A V4 run gets everything it needs from args: which account to read, and where to send the output. The run itself has no integrated_account_id, webhook_id or datastore_id. Truto ignores those top-level fields on a V4 run.
Each key of args_schema describes one argument with type, an optional format and an optional required. Truto reads args_schema only for the ID formats in the table below. For those, a missing required argument refuses the run with 400, and so does a value that doesn't match its format:
format |
The value must be |
|---|---|
integrated_account_id |
An integrated account in the job's environment |
webhook_id |
A webhook in the job's environment |
super_query |
A SuperQuery region: apac or wnam |
datastore_id |
A datastore in the job's environment |
For any other argument, type, format and required are not checked: a run without start_date below still starts, and {{args.start_date}} stays in the output as literal text. To refuse such a run, check the argument in args_validation, as the example does for start_date.
If every account named by an integrated_account_id argument needs reauthentication, the run fails as soon as it starts. See Sync Jobs overview.
"args_schema": {
"integrated_account_id": { "type": "string", "format": "integrated_account_id", "required": true },
"webhook_id": { "type": "string", "format": "webhook_id" },
"start_date": { "type": "string", "format": "date", "required": true }
},
"args_validation": "$exists(args.start_date) ? null : { \"message\": \"start_date is required\" }"Nodes
Every node has a name and a type. Names must be unique: depends_on and resources_to_persist refer to nodes by name. Every node also takes:
depends_on: the name of one parent node. A node without one starts the run.run_if: a JSONata expression. The node runs only when it's truthy.debug:truelogs a transform's input and output, which helps when an expression misbehaves.
request
Calls an API for an integrated account.
| Field | What it does |
|---|---|
resource |
<unified model>/<resource>, such as ticketing/tickets, for a Unified API call. A name without a /, such as tickets, is a Proxy API resource. |
method |
list, get, or another method of the resource. |
integrated_account_id |
Required. Usually {{args.integrated_account_id}}. |
id |
The record ID for methods that take one. |
query, body |
An object with {{placeholders}}, or a JSONata string that returns the object. |
loop_on |
A dotted path, such as resources.ticketing.tickets. The node runs once per item, and each run sees that path set to one item. |
recurse |
{ "if": ..., "config": { "id": ..., "query": ... } }: call the same resource again for each record where if holds, such as child pages. |
paginate_if |
A JSONata expression. Truto fetches the next page only while it's truthy. It sees payload and pagination (previous_total_records_size, current_page_records_size, total_records_size, next_cursor). |
delay_after_ms |
A pause after each call. The longest of this and the job's and run's delays wins. |
Later nodes read a request's records under resources, with each / in resource as a nested key: ticketing/tickets is resources.ticketing.tickets. In JSONata, quote a key with a hyphen in backticks, such as resources.`user-directory`.users.
transform
Runs config.expression, a JSONata expression over the parent's output, and passes the result on as payload.records. An empty result passes nothing. Set use_args: true when the parent isn't a request, such as a transform that builds records from args.
spool
Waits for every page and iteration of its parent, then passes all of it on at once. Put one before a transform that needs the whole set, such as one that combines pages into a single document.
add_context
Runs config.expression and merges the object it returns into the context of later nodes. Use it to expose a value, such as a watermark, as {{last_synced_at}} or last_synced_at in JSONata.
get_state, update_state, delete_state
Read and write values kept under the job's state_key, so one run can pick up where the last one stopped.
| Node | config |
|---|---|
get_state |
key, and default, which is stored and used the first time the key has no value. Later nodes read it at resources.<node name>.value. |
update_state |
key, and value (a placeholder template) or value_expression (JSONata). |
delete_state |
key. |
You can inspect and reset a job's state on its Run State tab in the dashboard.
event
config.name is start or complete. Nodes that depend on a start event run when the run starts. Nodes that depend on a complete event run after everything else, which is where an update_state that moves the watermark belongs.
destination
Sends the output of the nodes named in resources_to_persist somewhere. config.id names the target, usually from args. A destination's run_if also sees payload, the records about to be sent, so $count(payload.records) > 0 skips empty batches.
destination_type |
What it does |
|---|---|
webhook |
Sends each batch to the webhook in config.id as a sync_job_run:record event. The run's sync_job_run:started, sync_job_run:completed and sync_job_run:failed events go to the same webhooks. A job with no webhook destination sends those to every webhook in the environment that subscribes to them. |
datastore |
Calls method on the datastore in config.id, with config.config as the method's input. loop_on calls it once per item at that path. See the methods each type takes. |
Placeholders and context
{{...}} placeholders and JSONata expressions read the same context:
args: the run's arguments.resources: output of earlier requests andget_statenodes.payload: in a destination, the batch being sent, so{{payload.records.0.file_name}}is the first record'sfile_name.sync_job_run: the run, such assync_job_run.started_at.toISOString()orsync_job_run.status.- Anything an
add_contextnode added.
Add a type suffix when a value must not become text: {{resources.knowledge-base.page-content.has_children:bool}} is a boolean, and {{payload.records.0.parquet:any}} keeps the value as it is, such as binary Parquet built with $jsonToParquet. Expressions can use the $ functions of @truto/truto-jsonata.
An incremental job
This job reads tickets created since the last successful run and sends them to a webhook. The first run starts from args.start_date.
{
"label": "Fetch tickets with incremental sync",
"default_runtime_version": 4,
"integration_name": "",
"state_key": "{{args.integrated_account_id}}",
"mutex_key": "{{args.integrated_account_id}}",
"args_schema": {
"start_date": { "type": "string", "format": "date", "required": true },
"integrated_account_id": { "type": "string", "format": "integrated_account_id", "required": true },
"webhook_id": { "type": "string", "format": "webhook_id", "required": true }
},
"args_validation": "$exists(args.start_date) ? null : { \"message\": \"start_date is required\" }",
"resources": [
{
"type": "get_state",
"name": "get-last-synced-at",
"config": { "key": "last_synced_at", "default": "{{args.start_date}}" }
},
{
"type": "add_context",
"name": "add-last-synced-at",
"depends_on": "get-last-synced-at",
"config": { "expression": "{ 'last_synced_at': resources.`get-last-synced-at`.value }" }
},
{
"type": "request",
"name": "list-tickets",
"resource": "ticketing/tickets",
"method": "list",
"depends_on": "get-last-synced-at",
"query": { "created_at": { "gt": "{{last_synced_at}}" } },
"integrated_account_id": "{{args.integrated_account_id}}"
},
{
"name": "webhook",
"type": "destination",
"destination_type": "webhook",
"config": { "id": "{{args.webhook_id}}" },
"resources_to_persist": ["list-tickets"]
},
{ "name": "on-complete", "type": "event", "config": { "name": "complete" } },
{
"type": "update_state",
"name": "update-last-synced-at",
"depends_on": "on-complete",
"config": {
"key": "last_synced_at",
"value_expression": "sync_job_run.status = 'completed' ? sync_job_run.started_at.toISOString()"
}
}
]
}Which filters a Unified API list accepts, such as created_at with gt, depends on the integration's mapping. Check the resource's query parameters in the Unified API reference before you rely on one.
Create the job, then start a run with its args:
truto sync-jobs create --stdin < job.json
truto sync-job-runs create -b '{
"sync_job_id": "<sync_job_id>",
"args": {
"integrated_account_id": "<integrated_account_id>",
"webhook_id": "<webhook_id>",
"start_date": "2026-01-01"
}
}'curl -X POST "https://api.truto.one/sync-job" \
-H "Authorization: Bearer <API token>" \
-H "Content-Type: application/json" \
-d @job.json
curl -X POST "https://api.truto.one/sync-job-run" \
-H "Authorization: Bearer <API token>" \
-H "Content-Type: application/json" \
-d '{
"sync_job_id": "<sync_job_id>",
"args": {
"integrated_account_id": "<integrated_account_id>",
"webhook_id": "<webhook_id>",
"start_date": "2026-01-01"
}
}'How V4 differs from versions 1 to 3
| Versions 1 to 3 | Version 4 | |
|---|---|---|
Node name and type |
Optional on requests. A node without type is a request. |
Required on every node. |
| Where output goes | The run picks one destination: webhook_id, datastore_id, super_query or daemon_group_key (a daemon group). |
destination nodes in the job, with targets from args. A job can write to several. |
| What gets sent | Requests marked persist: true, and transforms marked persist. |
The nodes listed in each destination's resources_to_persist. |
| Integrated account | The run's integrated_account_id. |
Each request's integrated_account_id, usually from args. One run can read several accounts. |
| Datastores | MongoDB Data API, one collection per resource (versions 2 and 3). | Any datastore type with methods, called by name. |
| Incremental state | Not available as nodes. | get_state, update_state, delete_state and event nodes. |
| Response format | response_format on a request: raw, unified or normalized. |
Not a node field. |
A job runs on the version in its default_runtime_version.
Next
For the dashboard screens that create, run and inspect these jobs, see Using the Sync Jobs UI. To start a V4 run each time an account connects, use a workflow.