Wait for a slow job on a board
By the end of this tutorial you will have a board column that waits for an asynchronous job to finish by itself — checking in on an interval until a condition is met, instead of you (or a person, or an external scheduler) polling it by hand.
Four steps:
- Start a slow job — an orchestration run standing in for a real one: a video render, a batch export, anything that takes a while and has no push/webhook completion signal.
- Create a tool that checks on it — what the poll column calls each attempt.
- Define the board — one
pollcolumn, one terminal column. - Watch it wait, then finish.
Every step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
A nat_sk_… API key exported as NATURALI_TOKEN,
and a project. Both come from step 1 of
Create a provider — you do not need a provider or
an agent for this tutorial, since nothing here calls a model.
Arrive with:
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_V1StGXR8Z5jdHi6B
Client setup is the same as every tutorial:
- CLI
- SDK
- curl
pnpm add -g @naturali/cli
The CLI reads NATURALI_TOKEN from the environment. See the
CLI guide.
pnpm add @naturali/sdk
import { NaturaliClient } from '@naturali/sdk';
const naturali = new NaturaliClient({ token: process.env.NATURALI_TOKEN });
Every call resolves to { data, error } and never throws on a non-2xx — see
the SDK guide. The snippets read data directly for
brevity.
export NATURALI_API=https://api.naturali.ai/v1
1. Start a slow job
A poll column needs something slow to check on. Real ones — a render, a
batch export, a third-party job — take a while to set up, so this tutorial
stands one in with an orchestration whose
only node is a delay: it starts, sits sleeping for 15 seconds, then
succeeds. Nothing about the poll column that follows cares which kind of slow
job it is — only that something else, out of naturali's control, finishes on
its own time.
- CLI
- SDK
- curl
naturali create-orchestration \
--project-id "$PROJECT" \
--name "Stand-in slow job" \
--nodes '[{"id":"wait","type":"delay","duration":"15s"}]' \
--edges '[]'
const { data: orchestration } =
await naturali.orchestrations.createOrchestration({
path: { project_id: PROJECT },
body: {
name: 'Stand-in slow job',
nodes: [{ id: 'wait', type: 'delay', duration: '15s' }],
edges: [],
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/orchestrations" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Stand-in slow job",
"nodes": [{ "id": "wait", "type": "delay", "duration": "15s" }],
"edges": []
}'
export ORCHESTRATION=orch_V1StGXR8Z5jdHi6B
Start a run of it. This is fire-and-forget — the response comes back
queued immediately, and the run finishes 15 seconds later with nobody
watching it:
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id "$PROJECT" \
--orchestration-id "$ORCHESTRATION"
const { data: run } = await naturali.orchestrations.startOrchestrationRun({
path: { project_id: PROJECT },
body: { orchestration_id: ORCHESTRATION },
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/orchestration-runs" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "orchestration_id": "'"$ORCHESTRATION"'" }'
{ "id": "orch_run_V1StGXR8Z5jdHi6B", "status": "queued" }
export RUN=orch_run_V1StGXR8Z5jdHi6B
2. Create a tool that checks on it
The poll column repeats one tool call, so the tool is what does the checking.
Point it at
GET /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}
— the same read a person would make by hand — carrying the run's own auth,
the same self-referential pattern
Split a library between agents uses
for its own tools:
- CLI
- SDK
- curl
naturali create-tool \
--project-id "$PROJECT" \
--name check-slow-job \
--type http \
--description 'Reports the status of the stand-in slow job.' \
--execute '{"url":"'"$NATURALI_API"'/projects/'"$PROJECT"'/orchestration-runs/'"$RUN"'","method":"GET","headers":{"Authorization":"Bearer '"$NATURALI_TOKEN"'"}}'
const { data: tool } = await naturali.tools.createTool({
path: { project_id: PROJECT },
body: {
name: 'check-slow-job',
type: 'http',
description: 'Reports the status of the stand-in slow job.',
execute: {
url: `${process.env.NATURALI_API}/projects/${PROJECT}/orchestration-runs/${RUN}`,
method: 'GET',
headers: { Authorization: `Bearer ${process.env.NATURALI_TOKEN}` },
},
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"name\": \"check-slow-job\",
\"type\": \"http\",
\"description\": \"Reports the status of the stand-in slow job.\",
\"execute\": {
\"url\": \"$NATURALI_API/projects/$PROJECT/orchestration-runs/$RUN\",
\"method\": \"GET\",
\"headers\": { \"Authorization\": \"Bearer $NATURALI_TOKEN\" }
}
}"
export TOOL=tool_V1StGXR8Z5jdHi6B
Its url has $RUN baked in, because a naturali HTTP tool's URL is fixed at
creation — it is not templated per call the way input_mapping fills in a
request body. A board that needs to poll a different run or job per card
creates a tool (or a target endpoint) that takes the identifier as a query
parameter or body field, not a path segment. This tutorial keeps to one job so
the moving parts stay countable.
3. Define the board
Two columns: waiting polls the tool until the job is no longer running,
job_done closes the card. A poll dispatch looks like a tool dispatch
with three more fields — exit_condition, interval, max_iterations — and
an optional on_timeout:
- CLI
- SDK
- curl
naturali create-board \
--project-id "$PROJECT" \
--name "Slow job tracker" \
--states '[{"name":"waiting","initial":true,"on_enter":{"dispatch":{"kind":"poll","tool_id":"'"$TOOL"'","exit_condition":{"==":[{"var":"status"},"succeeded"]},"interval":"5s","max_iterations":12,"on_timeout":"fail"},"on_complete":[{"when":true,"transition":"finish"}],"on_failure":"give_up"}},{"name":"job_done","terminal":true},{"name":"timed_out","terminal":true}]' \
--transitions '[{"name":"finish","from":["waiting"],"to":"job_done"},{"name":"give_up","from":["waiting"],"to":"timed_out"}]'
const { data: board } = await naturali.boards.createBoard({
path: { project_id: PROJECT },
body: {
name: 'Slow job tracker',
states: [
{
name: 'waiting',
initial: true,
on_enter: {
dispatch: {
kind: 'poll',
tool_id: TOOL,
exit_condition: { '==': [{ var: 'status' }, 'succeeded'] },
interval: '5s',
max_iterations: 12,
on_timeout: 'fail',
},
on_complete: [{ when: true, transition: 'finish' }],
on_failure: 'give_up',
},
},
{ name: 'job_done', terminal: true },
{ name: 'timed_out', terminal: true },
],
transitions: [
{ name: 'finish', from: ['waiting'], to: 'job_done' },
{ name: 'give_up', from: ['waiting'], to: 'timed_out' },
],
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/boards" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Slow job tracker",
"states": [
{ "name": "waiting", "initial": true,
"on_enter": {
"dispatch": {
"kind": "poll",
"tool_id": "'"$TOOL"'",
"exit_condition": { "==": [{ "var": "status" }, "succeeded"] },
"interval": "5s",
"max_iterations": 12,
"on_timeout": "fail"
},
"on_complete": [{ "when": true, "transition": "finish" }],
"on_failure": "give_up"
} },
{ "name": "job_done", "terminal": true },
{ "name": "timed_out", "terminal": true }
],
"transitions": [
{ "name": "finish", "from": ["waiting"], "to": "job_done" },
{ "name": "give_up", "from": ["waiting"], "to": "timed_out" }
]
}'
export BOARD=brd_V1StGXR8Z5jdHi6B
exit_conditionis JSON Logic read against{ response, attempt }—responseis the tool's latest result, which here is the orchestration run body itself, soresponse.statusis the run's ownstatusfield. It stops polling the moment it turnssucceeded.interval: "5s"is the wait between attempts. This is what makes the column a poll and not a hot loop: the tool is called at most once every five seconds, however long the job takes.max_iterations: 12caps it at roughly a minute (5s × 12) — comfortably past the 15-second delay.on_timeout: "fail"means reaching that cap with the job still notsucceededfails the dispatch instead of completing with the condition unmet;on_failure: "give_up"is where that failure routes.
4. Watch it wait, then finish
Put a card on the board. It lands in waiting, which fires the poll
immediately:
- CLI
- SDK
- curl
naturali create-task \
--project-id "$PROJECT" \
--board-id "$BOARD" \
--title "Track the slow job"
const { data: card } = await naturali.tasks.createTask({
path: { project_id: PROJECT },
body: { board_id: BOARD, title: 'Track the slow job' },
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tasks" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "board_id": "'"$BOARD"'", "title": "Track the slow job" }'
export CARD=task_V1StGXR8Z5jdHi6B
Read it back right away, and the card is mid-poll — nothing has finished yet:
- CLI
- SDK
- curl
naturali get-task --project-id "$PROJECT" --task-id "$CARD"
const { data: card } = await naturali.tasks.getTask({
path: { project_id: PROJECT, task_id: CARD },
});
curl -sS "$NATURALI_API/projects/$PROJECT/tasks/$CARD" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"state": "waiting",
"status": "open",
"automation_status": "running",
"active_dispatch": { "kind": "poll", "id": "tool_V1StGXR8Z5jdHi6B", "status": "running" }
}
Wait about 20 seconds — past the delay's 15 — and read it again. The card has moved itself:
{
"state": "job_done",
"status": "closed",
"automation_status": null,
"active_dispatch": null
}
Nobody fired finish, and nothing hammered the status endpoint every second
either — the platform checked in on its own schedule, durably: the wait is
scheduler-driven, not a request your client held open, so it would have
survived the platform restarting under it just as it survived you closing
your terminal between the two reads above.
waiting past a minute means the timeout firedIf the card is in timed_out instead, max_iterations was reached before
exit_condition turned true — check the run directly
(GET /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id})
to see what it actually reported. With the 15-second delay and a 5-second ×
12-attempt budget above, that should not happen; it is more likely a sign the
tool's execute.url or headers were not exactly what step 2 built.
What's next
- Swap the stand-in delay for a real asynchronous job — anything with a status endpoint your tool can call — and the same board waits for it the same way.
- For a cooldown or a throttle with no status to check at all, use
kind: "delay"instead ofkind: "poll"— see Poll and delay columns. - Chain what the poll column found into a later column the same way a tool
column does, with
payload_writes— Carrying a field past a column that doesn't return it. - Everything else a board can express is on the Boards module page; the run states a poll or delay column parks on are in Orchestrations → Run lifecycle.