Skip to main content

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:

  1. 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.
  2. Create a tool that checks on it — what the poll column calls each attempt.
  3. Define the board — one poll column, one terminal column.
  4. 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:

pnpm add -g @naturali/cli

The CLI reads NATURALI_TOKEN from the environment. See the CLI guide.

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.

naturali create-orchestration \
--project-id "$PROJECT" \
--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:

naturali start-orchestration-run \
--project-id "$PROJECT" \
--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:

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"'"}}'
export TOOL=tool_V1StGXR8Z5jdHi6B
This tool checks one specific run

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:

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"}]'
export BOARD=brd_V1StGXR8Z5jdHi6B
  • exit_condition is JSON Logic read against { response, attempt }response is the tool's latest result, which here is the orchestration run body itself, so response.status is the run's own status field. It stops polling the moment it turns succeeded.
  • 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: 12 caps it at roughly a minute (5s × 12) — comfortably past the 15-second delay. on_timeout: "fail" means reaching that cap with the job still not succeeded fails 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:

naturali create-task \
--project-id "$PROJECT" \
--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:

naturali get-task --project-id "$PROJECT" --task-id "$CARD"
{
"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.

A card stuck in waiting past a minute means the timeout fired

If 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 of kind: "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_writesCarrying 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.