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

StatusWhat it meansWhat to do
401The 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.
403Your 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.
404The 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.
409The 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.
400The JSON, ID, or field value is invalid.Read {"error":"…"}, validate the request against OpenAPI, and retry with corrected input.
429An authentication endpoint was rate-limited.Wait before retrying; do not loop rapidly.
5xx or network errorFlux 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.