REST task queue quickstart
Run a retry-safe Flux queue cycle from Bash
For developers and operators building coding-agent or CLI-worker workflows: create a free Flux account, then use the REST API to create, claim, complete, and unlock one queued task without duplicating work when you retry.
Create a free account, then create an API
token and name it
quickstart-agent so the cleanup commands can
find it again. You need Bash, curl, jq, and
uuidgen. The helper reads the token from a hidden prompt or
FLUX_TOKEN, then passes it through standard input to
curl so it never appears in process arguments or shell history.
1. Paste the retry-safe REST queue-cycle script
The state file is optional, but if the script stops midway, rerun it with the same file and it will reuse the same UUIDs.
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
umask 077
need() { command -v "$1" >/dev/null 2>&1 || { printf 'Missing required command: %s\n' "$1" >&2; exit 1; }; }
new_uuid() { uuidgen | tr 'A-F' 'a-f'; }
next_delay() { awk -v d="$1" 'BEGIN { printf "%.3f", (d * 2 > 1.6 ? 1.6 : d * 2) }'; }
die() { printf '%s\n' "$1" >&2; exit 1; }
auth_fail() { die "401 Unauthorized: create or paste a replacement token named quickstart-agent."; }
role_fail() { printf '%s\n' "403 Forbidden: check the token's board role or the account's quota/plan." >&2; printf '%s' "${REQUEST_BODY:-}" | jq -e '.error? // empty' >/dev/null 2>&1 || true; exit 1; }
create_fail() {
local label="$1" body="$2" message
message=$(printf '%s' "$body" | jq -r '.error? // empty' 2>/dev/null || true)
[ -n "$message" ] || message="invalid request"
die "$label rejected: $message"
}
need curl
need jq
need uuidgen
BASE="${BASE:-https://fluxtask.org}"
TOKEN_NAME="${TOKEN_NAME:-quickstart-agent}"
BOARD_NAME="${BOARD_NAME:-Agent Bash quickstart}"
TASK_TITLE="${TASK_TITLE:-Complete the Agent Bash quickstart}"
STATE_FILE="${QS_STATE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/flux-agent-bash-quickstart.state}"
RETRY_MAX="${QS_RETRY_MAX:-5}"
RETRY_SLEEP="${QS_RETRY_SLEEP:-0.1}"
case "$STATE_FILE" in
*/*) STATE_DIR=${STATE_FILE%/*} ;;
*) STATE_DIR=. ;;
esac
mkdir -p "$STATE_DIR"
load_state() {
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
. "$STATE_FILE"
fi
}
save_state() {
{
printf "BOARD_ID='%s'\n" "$BOARD_ID"
printf "INBOX_STAGE_ID='%s'\n" "$INBOX_STAGE_ID"
printf "DONE_STAGE_ID='%s'\n" "$DONE_STAGE_ID"
printf "TASK_ID='%s'\n" "$TASK_ID"
printf "TOKEN_NAME='%s'\n" "$TOKEN_NAME"
printf "BOARD_NAME='%s'\n" "$BOARD_NAME"
} >"$STATE_FILE"
}
request() {
local method="$1" path="$2" data="${3:-}" tmp status rc=0
tmp=$(mktemp "${TMPDIR:-/tmp}/flux-quickstart.XXXXXX")
if [ -n "$data" ]; then
status=$(printf 'header = "Authorization: Bearer %s"\n' "$FLUX_TOKEN" |
curl --config - --silent --show-error --fail-with-body \
--request "$method" \
--header 'Content-Type: application/json' \
--data "$data" \
--output "$tmp" \
--write-out '%{http_code}' \
"$BASE$path") || rc=$?
else
status=$(printf 'header = "Authorization: Bearer %s"\n' "$FLUX_TOKEN" |
curl --config - --silent --show-error --fail-with-body \
--request "$method" \
--output "$tmp" \
--write-out '%{http_code}' \
"$BASE$path") || rc=$?
fi
REQUEST_RC=${rc:-0}
REQUEST_STATUS=${status:-000}
REQUEST_BODY=$(cat "$tmp")
rm -f "$tmp"
}
fetch_board() {
request GET "/api/boards/$BOARD_ID"
case "$REQUEST_STATUS" in
200) BOARD_JSON=$REQUEST_BODY; return 0 ;;
404) return 1 ;;
401) auth_fail ;;
403) role_fail ;;
000|5??) return 2 ;;
*) return 2 ;;
esac
}
# Single-task reads are WRAPPED: the body is {"task":{...}}, so every field
# below is addressed as .task.SOMETHING. Reading .v or .notes off the top level
# would not error -- it returns null, which reads as "the task has no version"
# or "the task has no notes". jq -er makes that absence abort the script.
# The list endpoint used further down is the opposite shape. API.md
# "Response envelopes" has the full table.
fetch_task() {
request GET "/api/boards/$BOARD_ID/tasks/$TASK_ID"
case "$REQUEST_STATUS" in
200)
TASK_JSON=$REQUEST_BODY
TASK_VERSION=$(printf '%s' "$TASK_JSON" | jq -er '.task.v')
TASK_STAGE_ID=$(printf '%s' "$TASK_JSON" | jq -er '.task.stageId')
TASK_DONE=$(printf '%s' "$TASK_JSON" | jq -r '.task.done | if . then "1" else "0" end')
if printf '%s' "$TASK_JSON" | jq -e '.task.tags | index("locked")' >/dev/null 2>&1; then
LOCK_HELD=1
else
LOCK_HELD=0
fi
return 0
;;
404) return 1 ;;
401) auth_fail ;;
403) role_fail ;;
000|5??) return 2 ;;
*) return 2 ;;
esac
}
fetch_stage_tasks() {
request GET "/api/boards/$BOARD_ID/stages/$INBOX_STAGE_ID/tasks"
case "$REQUEST_STATUS" in
200) STAGE_TASKS_JSON=$REQUEST_BODY; return 0 ;;
404) return 1 ;;
401) auth_fail ;;
403) role_fail ;;
000|5??) return 2 ;;
*) return 2 ;;
esac
}
fetch_token_id() {
request GET "/api/tokens"
case "$REQUEST_STATUS" in
200)
TOKEN_ID=$(printf '%s' "$REQUEST_BODY" | jq -er --arg name "$TOKEN_NAME" '[.tokens[] | select(.name == $name) | .id] | .[0]') || die "Create a token named quickstart-agent first."
;;
401) auth_fail ;;
403) role_fail ;;
*) die "Could not list API tokens." ;;
esac
}
ensure_board() {
local payload delay="$RETRY_SLEEP" create_status create_rc create_body
payload=$(jq -nc --arg bid "$BOARD_ID" --arg inbox "$INBOX_STAGE_ID" --arg done "$DONE_STAGE_ID" --arg name "$BOARD_NAME" '
{id:$bid,name:$name,stages:[{id:$inbox,name:"Inbox"},{id:$done,name:"Done"}]}')
for _ in 1 2 3 4 5; do
request POST "/api/boards" "$payload"
create_status=$REQUEST_STATUS
create_rc=$REQUEST_RC
create_body=$REQUEST_BODY
if [ "$create_status" = 400 ]; then
create_fail "Board create" "$create_body"
fi
case "$REQUEST_STATUS" in
200|201)
if [ "$create_rc" -eq 0 ]; then
printf '%s' "$REQUEST_BODY" | jq -e --arg bid "$BOARD_ID" --arg inbox "$INBOX_STAGE_ID" --arg done "$DONE_STAGE_ID" --arg name "$BOARD_NAME" '
.board.id == $bid
and .board.name == $name
and (.board.stages | length == 2)
and .board.stages[0].id == $inbox and .board.stages[0].name == "Inbox"
and .board.stages[1].id == $done and .board.stages[1].name == "Done"
' >/dev/null
return 0
fi
;;
401) auth_fail ;;
403) role_fail ;;
esac
fetch_board
board_rc=$?
if [ $board_rc -eq 0 ]; then return 0; fi
if [ $board_rc -eq 2 ]; then :; else
if [ "$create_status" = 400 ]; then create_fail "Board create" "$create_body"; fi
fi
sleep "$delay"
delay=$(next_delay "$delay")
done
die "Could not confirm the board create."
}
ensure_task() {
local payload delay="$RETRY_SLEEP" title="$TASK_TITLE" create_status create_rc create_body
payload=$(jq -nc --arg tid "$TASK_ID" --arg sid "$INBOX_STAGE_ID" --arg title "$title" '
{id:$tid,stageId:$sid,title:$title,priority:"high",tags:["quickstart"]}')
for _ in 1 2 3 4 5; do
request POST "/api/boards/$BOARD_ID/tasks" "$payload"
create_status=$REQUEST_STATUS
create_rc=$REQUEST_RC
create_body=$REQUEST_BODY
if [ "$create_status" = 400 ]; then
create_fail "Task create" "$create_body"
fi
case "$create_status" in
200|201)
if [ "$create_rc" -eq 0 ]; then
TASK_VERSION=$(printf '%s' "$REQUEST_BODY" | jq -er '.task.v')
TASK_STAGE_ID=$(printf '%s' "$REQUEST_BODY" | jq -er '.task.stageId')
TASK_DONE=$(printf '%s' "$REQUEST_BODY" | jq -r '.task.done | if . then "1" else "0" end')
printf '%s' "$REQUEST_BODY" | jq -e --arg tid "$TASK_ID" --arg sid "$INBOX_STAGE_ID" --arg title "$title" '
.task.id == $tid and .task.title == $title and (
.task.stageId == $sid or .task.done == true
)
' >/dev/null
return 0
fi
;;
401) auth_fail ;;
403) role_fail ;;
esac
fetch_task
task_rc=$?
if [ $task_rc -eq 0 ]; then return 0; fi
if [ $task_rc -eq 2 ]; then :; else
if [ "$create_status" = 400 ]; then create_fail "Task create" "$create_body"; fi
fi
sleep "$delay"
delay=$(next_delay "$delay")
done
die "Could not confirm the task create."
}
claim_task() {
local delay="$RETRY_SLEEP"
[ "$TASK_DONE" = 1 ] && return 0
[ "$LOCK_HELD" = 1 ] && return 0
for _ in 1 2 3 4 5; do
request POST "/api/boards/$BOARD_ID/stages/$INBOX_STAGE_ID/pop" '{}'
case "$REQUEST_STATUS" in
200)
if [ "$REQUEST_RC" -eq 0 ]; then
printf '%s' "$REQUEST_BODY" | jq -e --arg tid "$TASK_ID" '.task.id == $tid and (.task.tags | index("locked"))' >/dev/null
TASK_VERSION=$(printf '%s' "$REQUEST_BODY" | jq -er '.task.v')
TASK_STAGE_ID=$(printf '%s' "$REQUEST_BODY" | jq -er '.task.stageId')
TASK_DONE=0
LOCK_HELD=1
return 0
fi
;;
404)
if printf '%s' "$REQUEST_BODY" | jq -e '.code == "NO_AVAILABLE_TASK" or .error == "no available task"' >/dev/null 2>&1; then
if fetch_task && [ "$TASK_DONE" = 1 ] && [ "$TASK_STAGE_ID" = "$DONE_STAGE_ID" ]; then
return 0
fi
if fetch_task && [ "$LOCK_HELD" = 1 ]; then
return 0
fi
die "The queue drained before the quickstart task could be claimed."
fi
;;
401) auth_fail ;;
403) role_fail ;;
esac
# The list endpoint is the other envelope: entries inside .tasks are BARE,
# so the fields below are .tasks[].tags and .tasks[].v -- one level
# shallower than the .task.tags and .task.v that fetch_task reads above.
# Same task, same fields, different depth, purely because of which route
# was called.
if fetch_stage_tasks; then
if printf '%s' "$STAGE_TASKS_JSON" | jq -e --arg tid "$TASK_ID" '.tasks[] | select(.id == $tid) | (.tags | index("locked"))' >/dev/null 2>&1; then
TASK_VERSION=$(printf '%s' "$STAGE_TASKS_JSON" | jq -er --arg tid "$TASK_ID" '.tasks[] | select(.id == $tid) | .v')
LOCK_HELD=1
return 0
fi
fi
sleep "$delay"
delay=$(next_delay "$delay")
done
die "Could not confirm the pop/lock."
}
finish_task() {
local delay="$RETRY_SLEEP" payload
[ "$TASK_DONE" = 1 ] && return 0
payload=$(jq -nc --arg sid "$DONE_STAGE_ID" --argjson v "$TASK_VERSION" '
{notes:"Completed by the quickstart agent.",done:true,stageId:$sid,ifVersion:$v}')
for _ in 1 2 3 4 5; do
request PATCH "/api/boards/$BOARD_ID/tasks/$TASK_ID" "$payload"
case "$REQUEST_STATUS" in
200)
if [ "$REQUEST_RC" -eq 0 ]; then
printf '%s' "$REQUEST_BODY" | jq -e --arg tid "$TASK_ID" --arg sid "$DONE_STAGE_ID" '.task.id == $tid and .task.stageId == $sid and .task.done == true' >/dev/null
TASK_VERSION=$(printf '%s' "$REQUEST_BODY" | jq -er '.task.v')
TASK_STAGE_ID="$DONE_STAGE_ID"
TASK_DONE=1
return 0
fi
;;
409)
if fetch_task; then
if [ "$TASK_DONE" = 1 ] && [ "$TASK_STAGE_ID" = "$DONE_STAGE_ID" ]; then
return 0
fi
payload=$(jq -nc --arg sid "$DONE_STAGE_ID" --argjson v "$TASK_VERSION" '
{notes:"Completed by the quickstart agent.",done:true,stageId:$sid,ifVersion:$v}')
continue
fi
;;
401) auth_fail ;;
403) role_fail ;;
esac
if fetch_task; then
if [ "$TASK_DONE" = 1 ] && [ "$TASK_STAGE_ID" = "$DONE_STAGE_ID" ]; then
return 0
fi
payload=$(jq -nc --arg sid "$DONE_STAGE_ID" --argjson v "$TASK_VERSION" '
{notes:"Completed by the quickstart agent.",done:true,stageId:$sid,ifVersion:$v}')
fi
sleep "$delay"
delay=$(next_delay "$delay")
done
die "Could not confirm the task update."
}
unlock_task() {
local delay="$RETRY_SLEEP"
[ "$LOCK_HELD" = 0 ] && return 0
for _ in 1 2 3 4 5; do
request POST "/api/boards/$BOARD_ID/tasks/$TASK_ID/unlock" '{}'
case "$REQUEST_STATUS" in
200)
if [ "$REQUEST_RC" -eq 0 ]; then
if printf '%s' "$REQUEST_BODY" | jq -e --arg tid "$TASK_ID" '.task.id == $tid and (.task.tags | index("locked") | not)' >/dev/null; then
LOCK_HELD=0
return 0
fi
fi
;;
401) auth_fail ;;
403) role_fail ;;
esac
if fetch_task; then
if [ "$LOCK_HELD" = 0 ]; then return 0; fi
fi
sleep "$delay"
delay=$(next_delay "$delay")
done
die "Could not confirm the unlock."
}
confirm_empty_queue() {
request POST "/api/boards/$BOARD_ID/stages/$INBOX_STAGE_ID/pop" '{}'
case "$REQUEST_STATUS" in
404)
printf '%s' "$REQUEST_BODY" | jq -e '.code == "NO_AVAILABLE_TASK" or .error == "no available task"' >/dev/null
return 0
;;
401) auth_fail ;;
403) role_fail ;;
esac
die "The inbox should be empty after the task is moved and unlocked."
}
print_cleanup_commands() {
printf '\nCleanup commands:\n'
printf 'curl --config - --silent --show-error --fail-with-body --request DELETE "%s/api/boards/%s"\n' "$BASE" "$BOARD_ID"
printf 'curl --config - --silent --show-error --fail-with-body --request DELETE "%s/api/tokens/%s"\n' "$BASE" "$TOKEN_ID"
printf 'rm -f "%s"\n' "$STATE_FILE"
printf 'unset FLUX_TOKEN\n'
}
cleanup_done=0
LOCK_HELD=0
TOKEN_ID=""
REQUEST_RC=0
REQUEST_STATUS=000
REQUEST_BODY=""
BOARD_JSON=""
TASK_JSON=""
STAGE_TASKS_JSON=""
TASK_VERSION=""
TASK_STAGE_ID=""
TASK_DONE=0
cleanup() {
[ "$cleanup_done" = 1 ] && return 0
cleanup_done=1
set +e
if [ -n "${BOARD_ID:-}" ] && [ -n "${TASK_ID:-}" ]; then
fetch_task >/dev/null 2>&1 || true
LOCK_HELD=1
unlock_task >/dev/null 2>&1 || true
fi
unset FLUX_TOKEN
}
on_interrupt() {
cleanup
exit 130
}
trap cleanup EXIT
trap on_interrupt INT TERM HUP
load_state
: "${BOARD_ID:=$(new_uuid)}"
: "${INBOX_STAGE_ID:=$(new_uuid)}"
: "${DONE_STAGE_ID:=$(new_uuid)}"
: "${TASK_ID:=$(new_uuid)}"
save_state
if [ -z "${FLUX_TOKEN:-}" ]; then
IFS= read -r -s -p 'Flux API token: ' FLUX_TOKEN
printf '\n'
fi
[ -n "$FLUX_TOKEN" ] || die "A Flux API token is required."
fetch_token_id
ensure_board
ensure_task
fetch_task
if [ "$TASK_DONE" = 0 ]; then
claim_task
finish_task
unlock_task
fi
confirm_empty_queue
print_cleanup_commands
On a timeout or interruption, rerun the script with the same
QS_STATE_FILE value. It reuses the same board and task UUIDs,
reconciles what already happened, and avoids duplicate creates.
2. Clean up when you're done
The script prints these exact commands at the end, but you can also run them manually.
curl --config - --silent --show-error --fail-with-body --request DELETE "$BASE/api/boards/$BOARD_ID"
curl --config - --silent --show-error --fail-with-body --request DELETE "$BASE/api/tokens/$TOKEN_ID"
rm -f "$STATE_FILE"
unset FLUX_TOKEN
Common responses
| Status | What it means | What to do |
|---|---|---|
401 | The token is missing, malformed, revoked, or no longer valid. | Confirm the header starts with Bearer. Create a replacement token in Settings if needed; do not print the old token while debugging. |
403 | Your account can see the resource but lacks the required role, or the response body reports an account-plan limit. | Use a board where you are an editor/owner. Read the JSON error; reduce usage or change plan for a quota error. |
404 | The ID is wrong or hidden from this account. On pop, both outcomes are 404: code NO_AVAILABLE_TASK means the stage exists and its queue is empty, while STAGE_NOT_FOUND means the board has no such stage. | Branch on the JSON code, not on the message. Treat only NO_AVAILABLE_TASK as successful queue completion; on STAGE_NOT_FOUND re-list stages with this token, because the stage id you hold is stale. |
409 | The registration email already exists, or a task changed after the version you supplied. | Sign in instead of registering again. For a task conflict, GET the task, reconcile your intended changes with the returned current task, then retry using its new v. |
400 | The JSON, ID, or field value is invalid. | Read {"error":"…"}, validate the request against OpenAPI, and retry with corrected input. |
429 | An authentication endpoint was rate-limited. | Wait before retrying; do not loop rapidly. |
5xx or network error | Flux or the network could not complete the request. | Retry with bounded exponential backoff. If a mutation's outcome is uncertain, read the resource before sending it again. |
The helper uses
--fail-with-body: HTTP errors produce a non-zero
exit status while preserving Flux's JSON error body. Revoke the quickstart
token from Settings when you no longer need it.