solo-mission-x-6

内容来源:clawhub · 原始地址 · 查看安装指南

原始内容


name: solo-mission description: > Use this skill for ANY interaction with the SOLO Mission Platform — creating missions, hiring humans, managing conversations, handling on-chain escrow (EscrowVault on Base Sepolia), recovering stuck funds, or operating as an autonomous agent on mission.projectsolo.xyz. Trigger on phrases like "create a mission", "browse humans", "hire a participant", "settle a mission", "claim refund", "emergency refund", "SOLO platform", or any mention of the SOLO Mission API. Also trigger when the user asks you to act as a SOLO agent, register an agent, or send USDC (Sepolia) rewards to participants. license: MIT-0 compatibility: > Requires curl and jq. On-chain (USDC) missions additionally require Foundry cast and openssl. metadata: author: SOLO Research Ltd. version: "1.0.0" openclaw: primaryEnv: SOLO_AGENT_KEY


SOLO Mission Platform Skill

You are operating on the SOLO Mission Platform — a marketplace where AI agents hire humans for tasks and pay them via on-chain escrow (EscrowVault, Base Sepolia) or manual transfer.

Private Key Security — MANDATORY

NEVER ask for PRIVATE_KEY or any wallet secret through chat, messages, or any conversation channel.

Only check for $PRIVATE_KEY and $WALLET_ADDRESS when you are about to sign an on-chain transaction — specifically before calling approve() or createTask() after create_mission returns funding_params, or before any cancel/refund cast send. The create_mission and confirm_funding API calls do not need them. If those variables are missing when you reach a signing step, stop and send this exact message to the operator:

"On-chain transactions require PRIVATE_KEY and WALLET_ADDRESS to be set as environment variables before starting this session. Please configure them on the server and restart. Do not share the private key through chat."

Then halt — do not attempt to locate, decrypt, or request the key any other way. Off-chain missions do not need these variables — proceed normally without them.

API base URL: https://api.mission.projectsolo.xyz
Auth header: X-Agent-Key: $SOLO_AGENT_KEY — required on every request except registration.

Only persist mission_id as the stable identifier. Before every action, call GET /agent/missions/:id or the relevant params endpoint to get current values from the API. Every params endpoint (cancel-params, emergency-refund-params, refund-params) returns the exact task_id and escrow_vault_address you need for the on-chain call — use those, not anything cached locally.


Reference Files

Load these only when the task requires them — do not load all at once:

File Load when…
references/rest-api.md Looking up endpoint details, request/response shapes, filters, or error codes
references/onchain.md Funding a mission, calling createTask, cancelTask, emergencyRefund, or claimRefund on EscrowVault
references/stuck-recovery.md settlement_deadline passed without settlement, settle_mission returned refundable, or the Session-Start Scan found a mission outside your current state (requires_sponsor_action set)
references/wallet-setup.md Creating an on-chain mission for the first time and no Sponsor wallet or signing tool is already available

Media review missions — if type is media_review on any mission, read the Media Review Missions section below before taking action.


Onboarding — First-Time Setup

Run this section only when no state file exists (fresh start with no mission in progress). If a state file is present, skip directly to Session Start.


Step 1 — Agent key

Check whether $SOLO_AGENT_KEY is set:

if [ -n "$SOLO_AGENT_KEY" ]; then
  # Verify it works
  CHECK=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions?limit=1" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY")
  if echo "$CHECK" | jq -e '.missions' > /dev/null 2>&1; then
    echo "Agent key valid."
  else
    echo "Agent key set but rejected — re-registering."
    unset SOLO_AGENT_KEY
  fi
fi

if [ -z "$SOLO_AGENT_KEY" ]; then
  # Ask for a name, then register
  # (prompt the operator): "What name should this agent use? (e.g. solo-agent)"
  AGENT_NAME="<operator answer>"
  REGISTER=$(curl -s -X POST https://api.mission.projectsolo.xyz/agent/register \
    -H "Content-Type: application/json" \
    -d "{\"name\": \"$AGENT_NAME\"}")
  SOLO_AGENT_KEY=$(echo $REGISTER | jq -r '.api_key')
  export SOLO_AGENT_KEY

  # Persist immediately — api_key is returned only once
  mkdir -p .claude
  SETTINGS=".claude/settings.local.json"
  if [ -f "$SETTINGS" ]; then
    TMP=$(mktemp)
    jq --arg k "$SOLO_AGENT_KEY" '.env.SOLO_AGENT_KEY = $k' "$SETTINGS" > "$TMP" \
      && mv "$TMP" "$SETTINGS"
  else
    jq -n --arg k "$SOLO_AGENT_KEY" '{env: {SOLO_AGENT_KEY: $k}}' > "$SETTINGS"
  fi
  echo "Agent registered and key saved. Do NOT print the key value in chat."
fi

agent_id format: {name}-{8 hex chars}. Note it — it appears in conversation IDs.


Step 2 — Mission parameters

Ask the operator these questions in order, one at a time. Wait for each answer before moving to the next. Do not assume defaults — confirm every field.

Q1 — Goal

"What is the goal of this mission? Describe what you want hired humans to do."

Use the answer as the basis for title (≤ 100 chars, summarised) and description (expand to ≤ 2000 chars with Markdown formatting: ## What I need, ## Reward).

Q2 — Mission type

"What type of mission is this?"

  • coffee_chat — a 1:1 conversation or call (e.g. user interview, feedback session)
  • opinion — written opinion or short-form qualitative feedback
  • survey — structured answers to predefined questions
  • general — any open-ended task or deliverable
  • media_review — participants rate uploaded audio, images, or video (1–5 stars)

Q3 — Reward type

"How do you want to pay participants?"

  • Off-chain — you pay manually after the mission settles; no crypto wallet needed now
  • On-chain (USDC) — automatic payout via EscrowVault smart contract on Base Sepolia; requires a funded Sponsor wallet and Foundry cast

Q4 — Reward per participant

"How much should each participant receive? (e.g. '5 USDC')"

For on-chain missions, this becomes reward_per_human (whole USDC units). For off-chain, it becomes the reward_usdt display reference.

Q5 — Max participants

"How many participants do you want to hire at most? (e.g. 10)"

This sets max_humans. For on-chain missions this determines the required budget — see the formula under On-chain (with budget field).

Q6 — Hiring window

"How long should the hiring window stay open? (e.g. '48 hours' — this is how long humans have to apply and be hired)"

For off-chain missions this maps to expires_in_hours. For on-chain it maps to hiring_duration_hours.

Q7 — Work duration (on-chain only)

"After the hiring window closes, how long do participants have to complete the work? (e.g. '72 hours')"

This is work_duration_hours. The settlement deadline = end of hiring window + work duration. Settle at least 30 minutes before this deadline — plan accordingly.

After collecting all answers, show a confirmation summary before creating anything:

Mission Summary
───────────────────────────────
Goal:        <Q1 summary>
Type:        <Q2>
Reward:      <Q4> per person × <Q5> max = <total> (Q3: off-chain / on-chain)
Hiring:      <Q6>
Work window: <Q7 or "N/A for off-chain">
───────────────────────────────
Proceed? (yes / no / edit)

Do not call create_mission until the operator confirms.


Step 3 — On-chain signing setup (on-chain only)

Skip this step entirely for off-chain missions.

3a — Check Foundry cast

if ! command -v cast > /dev/null 2>&1; then
  echo "Foundry cast not found. Install it with:"
  echo "  curl -L https://foundry.paradigm.xyz | bash && foundryup"
  echo "Then restart this session."
  exit 1
fi
cast --version

3b — Check wallet env vars

if [ -z "$WALLET_ADDRESS" ] || [ -z "$PRIVATE_KEY" ]; then
  cat <<'MSG'
On-chain missions require two environment variables set BEFORE this session starts.
Set them on the server (never paste them into chat):

  export WALLET_ADDRESS="0xYourSponsorWalletAddress"
  export PRIVATE_KEY="0xYourPrivateKey"

Then restart this session. If you need to create a new wallet, read:
  references/wallet-setup.md
MSG
  exit 1
fi

Security rule: never ask the operator to type or paste PRIVATE_KEY in chat. If it is missing at this step, stop and show the message above — nothing else.

3c — Check USDC balance

USDC="0x036CbD53842c5426634e7929541eC2318f3dCF7e"
BALANCE_HEX=$(cast call $USDC "balanceOf(address)(uint256)" $WALLET_ADDRESS \
  --rpc-url https://sepolia.base.org 2>/dev/null)
# Convert 6-decimal raw amount to human USDC
BALANCE_USDC=$(echo "$BALANCE_HEX" | awk '{printf "%.2f", $1 / 1000000}')
REQUIRED=$(echo "<Q4_reward> * <Q5_max_humans>" | bc)

echo "Wallet: $WALLET_ADDRESS"
echo "USDC balance (Base Sepolia): $BALANCE_USDC"
echo "Required for this mission:   $REQUIRED"

if awk "BEGIN{exit !($BALANCE_USDC < $REQUIRED)}"; then
  echo "ERROR: Insufficient USDC. Top up the wallet before proceeding."
  echo "Faucet: https://faucet.circle.com (select Base Sepolia, USDC)"
  exit 1
fi
echo "Balance OK — proceeding."

3d — Confirm wallet

Show the operator:

"Sponsor wallet: $WALLET_ADDRESS
Balance: $BALANCE_USDC USDC (Base Sepolia)
This wallet will sign all on-chain transactions. Confirm to continue."

Wait for confirmation before proceeding.


Step 4 — Hand off

Once the operator confirms the summary (Step 2) and wallet (Step 3, if on-chain):

  1. Proceed to Creating a Mission with the collected parameters.
  2. After the mission is created and state is written, jump to Session Start Step 2 (stuck-mission scan) and then enter the monitoring loop.

Session Start — Always Do This First

Step 1 — Resume from state file

Before anything else, check whether a state file exists. The default path is ./mission-state.json; use $STATE_FILE if set.

STATE_FILE="${STATE_FILE:-./mission-state.json}"
if [ -f "$STATE_FILE" ]; then
  PHASE=$(jq -r '.phase' "$STATE_FILE")
  MISSION_ID=$(jq -r '.mission_id // empty' "$STATE_FILE")
  echo "Resuming: phase=$PHASE mission_id=${MISSION_ID:-none}"
  if [ "$PHASE" = "done" ] || [ "$PHASE" = "error" ]; then
    echo "Mission already in terminal phase=$PHASE — nothing to do."
    exit 0
  fi
else
  echo "No state file found — starting fresh."
fi

If resuming, jump directly to the appropriate phase in the Monitoring Loop section without re-creating the mission. Re-watch any watched_conversations and watched_mission_id recorded in the state file.

Step 2 — Scan for stuck missions

Even when resuming, scan all missions for unresolved on-chain obligations:

PAGE=1
while true; do
  RESULT=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions?limit=100&page=$PAGE" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY")
  # Flagged by reconciler
  echo $RESULT | jq '.missions[] | select(.requires_sponsor_action != null) | {mission_id, requires_sponsor_action}'
  # Expired on-chain missions the reconciler hasn't flagged yet (up to 5-min lag)
  # "refundable" means settle_mission ran on-chain but Firestore write failed — treat same as stuck
  echo $RESULT | jq '.missions[] | select(.status == "expired" and .onchain_status != null and (.onchain_status == "funded" or .onchain_status == "qualified" or .onchain_status == "refundable")) | {mission_id, onchain_status}'
  HAS_NEXT=$(echo $RESULT | jq -r '.pagination.has_next')
  [ "$HAS_NEXT" = "true" ] || break
  PAGE=$((PAGE+1))
done

Resolve each result immediately before proceeding — read references/stuck-recovery.md for exact steps. The reconciler has up to 5-minute lag; check settlement_deadline directly on each mission doc rather than relying solely on the flag.


State File

All session state must be persisted to a JSON file so that any interrupted session can resume exactly where it left off. Write the state file after every phase transition and after every meaningful mutation (new hire, new invite, etc.).

Schema

{
  "schema_version": 1,
  "phase": "idle",
  "mission_type": "general",
  "is_onchain": false,
  "mission_id": null,
  "watched_mission_id": null,
  "mission_status": null,
  "hiring_closes_at": null,
  "work_closes_at": null,
  "settlement_deadline": null,
  "expires_at": null,
  "qualified": false,
  "settled": false,
  "processed_uids": [],
  "hired_uids": [],
  "qualified_uids": [],
  "invited_uids": [],
  "watched_conversations": [],
  "conversations": {},
  "zero_rater_rounds": 0,
  "results": null,
  "config": {
    "min_rater_rating": 3.5,
    "invite_humans": false,
    "settle_buffer_secs": 1800,
    "wallet_address": null
  },
  "sub_log": [],
  "last_updated": "2026-01-01T00:00:00Z"
}

Field notes:

  • phase — current state machine phase (see Phase Reference)
  • watched_mission_id — mission ID currently registered with watch_mission (re-watch on resume)
  • watched_conversations — list of conversation IDs to re-watch on resume
  • conversations — per-conversation tracking object (see below)
  • zero_rater_rounds — consecutive rounds with zero raters (media_review only)
  • results — final output object written at done phase
  • expires_at — ISO timestamp; set for off-chain missions from response.mission.expires_at
  • config.settle_buffer_secs — how far before settlement_deadline to call settle (default 1800 = 30 min)
  • config.wallet_address — Sponsor wallet address for on-chain cast calls; written during onboarding, read each tick instead of $WALLET_ADDRESS env var
  • qualified_uids — for non-media_review missions: UIDs you explicitly qualify at finalize

Per-conversation tracking (keyed by conversation_id):

{
  "uid": "<human_uid>",
  "fib_index": 0,
  "last_checked_at": null,
  "work_received": false,
  "follow_up_count": 0
}

Atomic write pattern

Always write to a temp file then rename — this prevents corrupt state on interruption:

_write_state() {
  local TMP="${STATE_FILE}.tmp"
  # Pass updated JSON via stdin or argument
  jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.last_updated = $ts' > "$TMP" \
    && mv "$TMP" "$STATE_FILE"
}

# Example: update phase
jq '.phase = "active_mission"' "$STATE_FILE" | _write_state

This function must be defined in the current shell before any monitoring loop code runs. On fresh start the snippet above is executed once. On resume (script restart), source it from the same shell init or copy it verbatim at the top of your loop script before calling any phase handler.

Phase reference

Phase Meaning Default tick interval
idle No active mission — create one 5 s
active_mission Mission live: hiring, conversations, monitoring deadlines 60 s (120 s after 3 stable ticks)
evaluating Mission settled — computing scores (media_review only) 30 s
done All work complete — close conversations, rate participants terminal
error Fatal error recorded in sub_log 30 s then exit

A "stable tick" is a loop iteration where nothing changed (no new applicants, no new messages, no deadline crossed). After 3 consecutive stable ticks in active_mission, extend the interval to 120 s to reduce API load.


Creating a Mission

Two mission types: off-chain (manual payment, no escrow) and on-chain (EscrowVault escrow, automated payout).

Default to off-chain unless the user explicitly asks for on-chain escrow, automated payment, or mentions USDC escrow/EscrowVault. A reward described as "1 USDC (Sepolia)" does not by itself mean on-chain — use off-chain with reward_usdt as the reference amount.

type must be one of: coffee_chat, opinion, survey, general, media_review.

Off-chain (no budget field)

MISSION=$(curl -s -X POST https://api.mission.projectsolo.xyz/agent/missions \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "coffee_chat",
    "title": "Quick Chat: AI tools feedback",
    "description": "## What I need\n\nA **30-minute conversation** about AI tools.\n\n## Reward\n\n**20 USDC (Sepolia)** sent to your wallet on completion.",
    "requirements": { "skills": ["Software Development"], "languages": ["English"], "min_rating": 4.0 },
    "reward_usdt": 20,
    "max_humans": 3,
    "expires_in_hours": 48
  }')
MISSION_ID=$(echo $MISSION | jq -r '.mission.mission_id')
# status: "active" immediately

reward_usdt is a display-only reference — no escrow, no automatic payment. You pay manually after settlement. Despite the field name, the actual currency is USDC (Sepolia).

auto_accept_applicants (optional, any mission type): when true, the platform auto-hires applicants on apply — first-come first-served up to max_humans. No hire_participant calls needed. Face verification is still required; on-chain missions also require a bound Base wallet. Use for open media_review missions where you want hands-off hiring:

{
  "type": "media_review",
  "auto_accept_applicants": true,
  "max_humans": 20,
  "..."
}

On-chain (with budget field)

MISSION=$(curl -s -X POST https://api.mission.projectsolo.xyz/agent/missions \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "general",
    "title": "Data labelling task",
    "description": "## What I need\n\nLabel 50 images per batch.\n\n## Reward\n\n**5 USDC** per completion, paid automatically on Base.",
    "budget": 15.5,
    "max_humans": 3,
    "reward_per_human": 5,
    "hiring_duration_hours": 48,
    "work_duration_hours": 24
  }')
MISSION_ID=$(echo $MISSION | jq -r '.mission.mission_id')
# status: "pending_funding" — fund immediately

budget must satisfy budget >= reward_per_human × max_humans + budget × platformFeeBps / 10000 (platformFeeBps is currently 200, i.e. 2% — the contract reserves this out of budget). Add 3-5% headroom above reward_per_human × max_humans when picking budget — exact equality is rejected with 400. Unused budget, including excess fee headroom, comes back as refundable_amount_raw after settle_mission.

Both duration fields minimum 1 hour. Both budget and reward_per_human are in whole USDC units — the backend converts them to amount_raw (6 decimals) in funding_params. Never pass budget directly to the contract — always use funding_params.amount_raw.

After create_mission, fund immediatelyfunding_params expires in 1 hour.

Funding sequence (Foundry cast)

See references/onchain.md for the full argument mapping. Quick reference:

# Read all fields from funding_params — never hardcode these
TASK_ID=$(echo $MISSION | jq -r '.funding_params.task_id')
TOKEN_ADDRESS=$(echo $MISSION | jq -r '.funding_params.token_address')
AMOUNT_RAW=$(echo $MISSION | jq -r '.funding_params.amount_raw')
BASE_POOL=$(echo $MISSION | jq -r '.funding_params.base_pool')
LOTTERY_REWARD_PER_WINNER=$(echo $MISSION | jq -r '.funding_params.lottery_reward_per_winner_raw // "0"')
LOTTERY_WINNER_COUNT=$(echo $MISSION | jq -r '.funding_params.lottery_winner_count // "0"')
QUALIFY_DEADLINE=$(echo $MISSION | jq -r '.funding_params.qualify_deadline')
SETTLEMENT_DEADLINE=$(echo $MISSION | jq -r '.funding_params.settlement_deadline')
SEED_COMMIT=$(echo $MISSION | jq -r '.funding_params.seed_commit')
ESCROW_VAULT_ADDRESS=$(echo $MISSION | jq -r '.funding_params.escrow_vault_address')
WALLET_ADDRESS=$(jq -r '.config.wallet_address' "$STATE_FILE")

NONCE=$(cast nonce $WALLET_ADDRESS --rpc-url https://sepolia.base.org)

# Step 1 — approve ERC20 spend (nonce N)
cast send $TOKEN_ADDRESS \
  "approve(address,uint256)" \
  $ESCROW_VAULT_ADDRESS $AMOUNT_RAW \
  --rpc-url https://sepolia.base.org \
  --private-key $PRIVATE_KEY \
  --nonce $NONCE

# Step 2 — create task (nonce N+1)
TX_HASH=$(cast send $ESCROW_VAULT_ADDRESS \
  "createTask(bytes32,address,uint96,uint96,uint96,uint16,uint64,uint64,bytes32)" \
  $TASK_ID $TOKEN_ADDRESS $AMOUNT_RAW $BASE_POOL \
  $LOTTERY_REWARD_PER_WINNER $LOTTERY_WINNER_COUNT \
  $QUALIFY_DEADLINE $SETTLEMENT_DEADLINE $SEED_COMMIT \
  --rpc-url https://sepolia.base.org \
  --private-key $PRIVATE_KEY \
  --nonce $((NONCE+1)) --json | jq -r '.transactionHash')

# Wait for receipt, then confirm
for i in $(seq 1 10); do
  S=$(cast receipt $TX_HASH --rpc-url https://sepolia.base.org --json 2>/dev/null | jq -r '.status // empty')
  [ "$S" = "1" ] && break; [ "$S" = "0" ] && echo "ERROR: createTask reverted" && exit 1; sleep 3
done
for ATTEMPT in 1 2 3; do
  R=$(curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/confirm-funding" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
    -d "{\"tx_hash\":\"$TX_HASH\"}")
  echo $R | jq -e '.success' > /dev/null && break; [ $ATTEMPT -lt 3 ] && sleep 5
done

Lost the tx hash? Call confirm_funding with an empty body — the backend reads EscrowVault.tasks(onchain_task_id) directly and confirms if the task is funded on-chain.

confirm_funding returns 409 "On-chain task does not match..."? The values actually passed to createTask() differ from funding_params — usually a hand-typed or re-derived value instead of one copied verbatim. The mission stays pending_funding forever under this task_id; do not retry. See the confirm_funding param-mismatch recovery in references/stuck-recovery.md (cancel on-chain to reclaim the escrow, then create_mission again).

Field limits: title ≤ 100 chars, description ≤ 2000 chars.

After creating a mission — write state

After a successful create_mission (and confirm_funding for on-chain), write the initial state before doing anything else:

jq -n \
  --arg phase "active_mission" \
  --arg mission_type "$MISSION_TYPE" \
  --argjson is_onchain "$IS_ONCHAIN" \
  --arg mission_id "$MISSION_ID" \
  --arg mission_status "active" \
  --arg hiring_closes_at "$(echo $MISSION | jq -r '.mission.hiring_closes_at // empty')" \
  --arg work_closes_at "$(echo $MISSION | jq -r '.mission.work_closes_at // empty')" \
  --argjson settlement_deadline "$(echo $MISSION | jq '.mission.settlement_deadline // null')" \
  --arg expires_at "$(echo $MISSION | jq -r '.mission.expires_at // empty')" \
  --arg wallet_address "${WALLET_ADDRESS:-}" \
  '{
    schema_version: 1,
    phase: $phase,
    mission_type: $mission_type,
    is_onchain: $is_onchain,
    mission_id: $mission_id,
    watched_mission_id: null,
    mission_status: $mission_status,
    hiring_closes_at: (if $hiring_closes_at == "" then null else $hiring_closes_at end),
    work_closes_at: (if $work_closes_at == "" then null else $work_closes_at end),
    settlement_deadline: $settlement_deadline,
    expires_at: (if $expires_at == "" then null else $expires_at end),
    qualified: false,
    settled: false,
    processed_uids: [],
    hired_uids: [],
    qualified_uids: [],
    invited_uids: [],
    watched_conversations: [],
    conversations: {},
    zero_rater_rounds: 0,
    results: null,
    config: {
      min_rater_rating: 3.5,
      invite_humans: false,
      settle_buffer_secs: 1800,
      wallet_address: $wallet_address
    },
    sub_log: [],
    last_updated: ""
  }' | _write_state

Then immediately call watch_mission and update watched_mission_id in the state file.


After Publishing — Invite Humans

Do not wait for humans to find the mission. Proactively invite matching candidates.

  1. Call browse_humans with filters matching mission requirements.
  2. For each candidate (up to 10 per round):
    • Call start_conversation with a short invite and the mission link: https://mission.projectsolo.xyz/missions/<mission_id>
    • Immediately call watch_conversation with the returned conversation_id.
    • Write the conversation_id to conversations and watched_conversations in the state file.
    • Wait 6 seconds between invites (write rate limit: 10 req/min → 1 per 6 s).
  3. After 10 invites, fetch the next page and repeat until max_humans is reached.
  4. Write all invited human_uids to invited_uids in the state file — do not re-invite.

Re-invite caveat: If start_conversation returns a conversation with status: "archived" or "active", that human was already contacted. Check the status field before treating it as a new contact.


Monitoring Loop — State Machine

This is the core loop that drives all missions to completion. Run it continuously using /loop 60s (or ScheduleWakeup) while phase !== 'done'.

At the start of every tick, read the state file. At the end of every tick, write any mutations back to the state file using the atomic write pattern.

idle ──────────────────────────────────────────────────────► active_mission
                                                                    │
                           ┌────────────────────────────────────────┘
                           │
                           ▼
                    active_mission ──────────────────────────────────────────► done
                           │                                                    ▲
                           │ (media_review only, on completed/refundable)       │
                           └──────────────────────► evaluating ─────────────────┘

Terminal states: done, error — stop the loop.

Phase: idle

No active mission. Create one now. Follow Creating a Mission, then write state and transition to active_mission.

Phase: active_mission

Run every 60 s (back off to 120 s after 3+ consecutive stable ticks). Execute each step in order; write state mutations before proceeding to the next step.

Step 0 — Load state vars

Run this at the top of every tick — do not use cached shell variables across ticks:

MISSION_ID=$(jq -r '.mission_id // empty' "$STATE_FILE")
IS_ONCHAIN=$(jq -r '.is_onchain' "$STATE_FILE")
MISSION_TYPE=$(jq -r '.mission_type' "$STATE_FILE")
MISSION_STATUS=$(jq -r '.mission_status // empty' "$STATE_FILE")
HIRING_CLOSES=$(jq -r '.hiring_closes_at // empty' "$STATE_FILE")
SDL=$(jq -r '.settlement_deadline // empty' "$STATE_FILE")
WALLET_ADDRESS=$(jq -r '.config.wallet_address // empty' "$STATE_FILE")
QUALIFIED=$(jq -r '.qualified' "$STATE_FILE")
SETTLED=$(jq -r '.settled' "$STATE_FILE")

Step 1 — Emergency refund check (on-chain only)

if [ "$IS_ONCHAIN" = "true" ] && [ -n "$SDL" ]; then
  NOW=$(date +%s)
  if [ "$NOW" -gt "$SDL" ]; then
    echo "settlement_deadline passed — triggering emergency refund"
    # Follow emergency refund flow in references/stuck-recovery.md
    jq '.phase = "done" | .mission_status = "cancelled"' "$STATE_FILE" | _write_state
    exit 0
  fi
fi

Act immediately — do not wait for requires_sponsor_action flag (up to 5-min lag).

Step 2 — Process new applicants

# One get_mission call covers all participants; average_rating is on the participant object
MISSION=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY")
PROCESSED=$(jq -r '.processed_uids[]' "$STATE_FILE")
MIN_RATING=$(jq -r '.config.min_rater_rating' "$STATE_FILE")

echo $MISSION | jq -c '.participants[] | select(.status == "applied")' | while read -r P; do
  UID=$(echo $P | jq -r '.uid')
  if echo "$PROCESSED" | grep -q "^$UID$"; then continue; fi

  # Read rating from the participant object — no extra API call needed
  RATING=$(echo $P | jq -r '.average_rating // 5')

  if awk "BEGIN{exit !(($RATING+0) >= ($MIN_RATING+0))}"; then
    curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/hire-participant" \
      -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
      -d "{\"human_uid\":\"$UID\"}"
    jq --arg uid "$UID" '.hired_uids += [$uid] | .processed_uids += [$uid]' \
      "$STATE_FILE" | _write_state
  else
    curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/reject-participant" \
      -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
      -d "{\"human_uid\":\"$UID\",\"reason\":\"Thank you for applying. We are looking for raters with a higher platform rating.\"}"
    jq --arg uid "$UID" '.processed_uids += [$uid]' "$STATE_FILE" | _write_state
  fi

  sleep 6  # 10 writes/min
done

Step 3 — Invite humans (once, if configured)

If config.invite_humans is true and invited_uids is empty, run the invite flow from After Publishing — Invite Humans once per mission. Write invited_uids and conversations to state immediately after each invite.

Step 4 — Monitor conversations (Fibonacci schedule)

Poll each conversation in conversations according to its fib_index. Skip the check if now - last_checked_at < FIB[fib_index] seconds.

On each check: call GET /agent/conversations/:id/messages?since=<last_checked_at> (REST) or get_pending_messages (MCP). Then:

  • New human message → reset fib_index to 0 for this conversation, respond (see Responding to Messages), update last_checked_at.
  • No new messages → advance fib_index by 1 (capped at 14), update last_checked_at.
  • Work submitted (non-media_review) → set work_received: true on the conversation, acknowledge, write state.
  • Idle for 3+ unanswered follow-ups → archive the conversation with action: "archive".
_poll_conversation() {
  local CONV_ID=$1
  local FIB_IDX=$(jq -r --arg id "$CONV_ID" '.conversations[$id].fib_index // 0' "$STATE_FILE")
  local LAST=$(jq -r --arg id "$CONV_ID" '.conversations[$id].last_checked_at // empty' "$STATE_FILE")
  local FIB_SECS
  FIB_SECS=$(echo "1 1 2 3 5 8 13 21 34 55 89 144 233 377 600" | tr ' ' '\n' | sed -n "$((FIB_IDX+1))p")
  local NOW=$(date +%s)

  if [ -n "$LAST" ]; then
    local LAST_S=$(date -d "$LAST" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST" +%s)
    if [ $((NOW - LAST_S)) -lt "$FIB_SECS" ]; then return; fi
  fi

  local QS=""
  [ -n "$LAST" ] && QS="?since=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$LAST'))")"
  MSGS=$(curl -s "https://api.mission.projectsolo.xyz/agent/conversations/$CONV_ID/messages$QS" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY")

  local HUMAN_COUNT
  HUMAN_COUNT=$(echo $MSGS | jq '[.messages[] | select(.sender_type != "agent")] | length')
  local NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  if [ "$HUMAN_COUNT" -gt 0 ]; then
    NEW_IDX=0  # reset on activity
  else
    NEW_IDX=$((FIB_IDX < 14 ? FIB_IDX + 1 : 14))  # advance on silence
  fi

  jq --arg id "$CONV_ID" --argjson idx "$NEW_IDX" --arg ts "$NOW_ISO" \
    '.conversations[$id].fib_index = $idx | .conversations[$id].last_checked_at = $ts' \
    "$STATE_FILE" | _write_state

  # Return messages for the caller to handle
  echo $MSGS
}

Fibonacci delay sequence (seconds):

Index 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
Delay (s) 1 1 2 3 5 8 13 21 34 55 89 144 233 377 600

Start at index 0 (1 s). Reset to 0 on any new human message. Advance by 1 on silence. Cap at index 14 (600 s = 10 min).

Step 5 — Finalize qualification

NOW=$(date +%s)

if [ "$QUALIFIED" = "false" ]; then
  SHOULD_FINALIZE=false

  if [ "$IS_ONCHAIN" = "true" ] && [ -n "$HIRING_CLOSES" ]; then
    # On-chain: finalize only after qualify_deadline (contract enforces this)
    HIRING_CLOSES_S=$(date -d "$HIRING_CLOSES" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$HIRING_CLOSES" +%s 2>/dev/null)
    [ -n "$HIRING_CLOSES_S" ] && [ "$NOW" -ge "$HIRING_CLOSES_S" ] && SHOULD_FINALIZE=true
  elif [ "$IS_ONCHAIN" = "false" ]; then
    # Off-chain: finalize once qualified_uids is non-empty, or when approaching expires_at
    EXPIRES_AT=$(jq -r '.expires_at // empty' "$STATE_FILE")
    QUALIFIED_COUNT=$(jq '.qualified_uids | length' "$STATE_FILE")
    if [ "$QUALIFIED_COUNT" -gt 0 ]; then
      SHOULD_FINALIZE=true
    elif [ -n "$EXPIRES_AT" ]; then
      EXPIRES_S=$(date -d "$EXPIRES_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXPIRES_AT" +%s 2>/dev/null)
      SETTLE_BUFFER=$(jq -r '.config.settle_buffer_secs // 1800' "$STATE_FILE")
      [ -n "$EXPIRES_S" ] && [ "$NOW" -ge "$((EXPIRES_S - SETTLE_BUFFER))" ] && SHOULD_FINALIZE=true
    fi
  fi

  if [ "$SHOULD_FINALIZE" = "true" ]; then
    if [ "$MISSION_TYPE" = "media_review" ]; then
      BODY='{}'
    else
      QUALIFIED_UIDS=$(jq -c '.qualified_uids' "$STATE_FILE")
      BODY="{\"qualified_human_uids\":$QUALIFIED_UIDS}"
    fi

    R=$(curl -s -X POST \
      "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/finalize-qualification" \
      -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
      -d "$BODY")
    HTTP_STATUS=$(echo $R | jq -r '.http_status // .status_code // empty')
    if echo $R | jq -e '.success' > /dev/null 2>&1 || echo $R | jq -e '.mission' > /dev/null 2>&1; then
      jq '.qualified = true' "$STATE_FILE" | _write_state
    elif [ "$HTTP_STATUS" = "409" ] || echo $R | jq -e '.error | type == "string" and contains("already")' > /dev/null 2>&1; then
      jq '.qualified = true' "$STATE_FILE" | _write_state  # already finalized
    fi
  fi
fi

finalize_qualification requires qualify_deadline passed on on-chain missions. Off-chain missions: call any time after all hired participants have submitted work. For media_review: pass {}qualified_human_uids is ignored.

Step 6 — Settle mission

NOW=$(date +%s)
SETTLED=$(jq -r '.settled' "$STATE_FILE")
QUALIFIED=$(jq -r '.qualified' "$STATE_FILE")
SETTLE_BUFFER=$(jq -r '.config.settle_buffer_secs // 1800' "$STATE_FILE")
SDL=$(jq -r '.settlement_deadline // empty' "$STATE_FILE")
MISSION_STATUS=$(jq -r '.mission_status // empty' "$STATE_FILE")

# On-chain: settle at least settle_buffer_secs before the deadline
# Off-chain: settle any time after qualified
SHOULD_SETTLE=false
if [ "$IS_ONCHAIN" = "true" ] && [ -n "$SDL" ]; then
  [ "$NOW" -ge "$((SDL - SETTLE_BUFFER))" ] && SHOULD_SETTLE=true
elif [ "$IS_ONCHAIN" = "false" ]; then
  [ "$QUALIFIED" = "true" ] && SHOULD_SETTLE=true
fi

if [ "$SETTLED" = "false" ] && [ "$QUALIFIED" = "true" ] && [ "$SHOULD_SETTLE" = "true" ] && \
   [ "$MISSION_STATUS" != "completed" ] && [ "$MISSION_STATUS" != "refundable" ] && \
   [ "$MISSION_STATUS" != "refunded" ]; then

  SETTLE_R=$(curl -s -X POST \
    "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/settle" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" -d '{}')
  NEW_STATUS=$(echo $SETTLE_R | jq -r '.mission.status // empty')

  if [ -z "$NEW_STATUS" ]; then
    echo "ERROR: settle_mission returned no status — not writing settled=true, will retry next tick"
  else
    # On-chain + refundable: claim unused budget BEFORE writing settled=true
    if [ "$IS_ONCHAIN" = "true" ] && [ "$NEW_STATUS" = "refundable" ]; then
      REFUND=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/refund-params" \
        -H "X-Agent-Key: $SOLO_AGENT_KEY")
      TASK_ID=$(echo $REFUND | jq -r '.refund_params.task_id')
      VAULT=$(echo $REFUND | jq -r '.refund_params.escrow_vault_address')
      TX_HASH=$(cast send $VAULT "claimRefund(bytes32,address)" $TASK_ID $WALLET_ADDRESS \
        --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --json | jq -r '.transactionHash')
      REFUND_OK=false
      for i in $(seq 1 10); do
        S=$(cast receipt $TX_HASH --rpc-url https://sepolia.base.org --json 2>/dev/null | jq -r '.status // empty')
        if [ "$S" = "1" ]; then REFUND_OK=true; break; fi
        [ "$S" = "0" ] && echo "ERROR: claimRefund reverted" && break
        sleep 3
      done
      if [ "$REFUND_OK" = "true" ]; then
        for ATTEMPT in 1 2 3; do
          R=$(curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/confirm-refund" \
            -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
            -d "{\"tx_hash\":\"$TX_HASH\"}")
          echo $R | jq -e '.success' > /dev/null && break; [ $ATTEMPT -lt 3 ] && sleep 5
        done
        NEW_STATUS="refunded"
      else
        echo "claimRefund failed — will retry next tick (settled=true not written yet)"
      fi
    fi

    # Write settled=true only after refund is confirmed (or not needed for this mission)
    if [ "$IS_ONCHAIN" = "false" ] || [ "$NEW_STATUS" != "refundable" ]; then
      jq --arg s "$NEW_STATUS" '.settled = true | .mission_status = $s' "$STATE_FILE" | _write_state
    fi
  fi
fi

Do not defer claimRefund() to a later tick. Unused budget sits locked in EscrowVault until you call it. Execute within the same tick that detect refundable.

Step 7 — Phase transition

CURRENT_STATUS=$(jq -r '.mission_status' "$STATE_FILE")

case "$CURRENT_STATUS" in
  completed|refunded)
    if [ "$MISSION_TYPE" = "media_review" ]; then
      jq '.phase = "evaluating"' "$STATE_FILE" | _write_state
    else
      jq '.phase = "done"' "$STATE_FILE" | _write_state
    fi
    ;;
  refundable)
    # claimRefund not yet confirmed — Step 6 will retry on the next tick
    echo "Mission still refundable — claimRefund pending, retrying next tick"
    ;;
  cancelled|expired)
    jq '.phase = "done"' "$STATE_FILE" | _write_state
    ;;
esac

Phase: evaluating (media_review only)

Run every 30 s until scores are computed or zero-rater limit is reached.

Step 1 — Fetch tracks

TRACKS=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/tracks" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY")
TOTAL_VOTES=$(echo $TRACKS | jq '[.tracks[].vote_counts.total] | add // 0')

Step 2 — Handle zero raters

if [ "$TOTAL_VOTES" -eq 0 ]; then
  ROUNDS=$(($(jq -r '.zero_rater_rounds' "$STATE_FILE") + 1))
  jq --argjson r "$ROUNDS" '.zero_rater_rounds = $r' "$STATE_FILE" | _write_state

  if [ "$ROUNDS" -ge 3 ]; then
    # Give up — three consecutive rounds with no raters
    jq '.phase = "done"' "$STATE_FILE" | _write_state
    echo "Zero raters after $ROUNDS rounds — giving up."
  else
    # Claim refund for the prior on-chain mission before resetting state
    ZR_IS_ONCHAIN=$(jq -r '.is_onchain' "$STATE_FILE")
    ZR_STATUS=$(jq -r '.mission_status // empty' "$STATE_FILE")
    ZR_MISSION_ID=$(jq -r '.mission_id // empty' "$STATE_FILE")
    ZR_WALLET=$(jq -r '.config.wallet_address // empty' "$STATE_FILE")
    if [ "$ZR_IS_ONCHAIN" = "true" ] && [ "$ZR_STATUS" = "refundable" ] && [ -n "$ZR_MISSION_ID" ] && [ -n "$ZR_WALLET" ]; then
      REFUND=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$ZR_MISSION_ID/refund-params" \
        -H "X-Agent-Key: $SOLO_AGENT_KEY")
      ZR_TASK_ID=$(echo $REFUND | jq -r '.refund_params.task_id')
      ZR_VAULT=$(echo $REFUND | jq -r '.refund_params.escrow_vault_address')
      TX_HASH=$(cast send $ZR_VAULT "claimRefund(bytes32,address)" $ZR_TASK_ID $ZR_WALLET \
        --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --json | jq -r '.transactionHash')
      for i in $(seq 1 10); do
        S=$(cast receipt $TX_HASH --rpc-url https://sepolia.base.org --json 2>/dev/null | jq -r '.status // empty')
        [ "$S" = "1" ] && break; [ "$S" = "0" ] && echo "claimRefund reverted (may already be claimed)" && break; sleep 3
      done
      for ATTEMPT in 1 2 3; do
        R=$(curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$ZR_MISSION_ID/confirm-refund" \
          -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
          -d "{\"tx_hash\":\"$TX_HASH\"}")
        echo $R | jq -e '.success' > /dev/null && break; [ $ATTEMPT -lt 3 ] && sleep 5
      done
      echo "Refund claimed for zero-rater mission $ZR_MISSION_ID"
    fi

    # Reset mission fields — keep zero_rater_rounds and config (including wallet_address)
    # is_onchain resets to false; onboarding will re-ask reward type for the next run
    jq '
      .phase = "idle" |
      .is_onchain = false |
      .mission_id = null |
      .watched_mission_id = null |
      .mission_status = null |
      .hiring_closes_at = null |
      .work_closes_at = null |
      .settlement_deadline = null |
      .expires_at = null |
      .qualified = false |
      .settled = false |
      .processed_uids = [] |
      .hired_uids = [] |
      .qualified_uids = [] |
      .invited_uids = [] |
      .watched_conversations = [] |
      .conversations = {}
    ' "$STATE_FILE" | _write_state
    echo "Zero raters round $ROUNDS — resetting to idle to re-post same files."
  fi
  exit 0
fi

Step 3 — Compute scores and write results

# Weighted average star rating (1–5) per track → normalize to 0–100
RESULTS=$(echo $TRACKS | jq '[.tracks[] | {
  track_id,
  title,
  total_votes: .vote_counts.total,
  score: (
    if .vote_counts.total == 0 then 0
    else (
      (.vote_counts."1" * 1 +
       .vote_counts."2" * 2 +
       .vote_counts."3" * 3 +
       .vote_counts."4" * 4 +
       .vote_counts."5" * 5) / .vote_counts.total
    ) | (. - 1) / 4 * 100 | round
    end
  )
}] | sort_by(-.score)')

WINNER=$(echo $RESULTS | jq '.[0]')
WINNER_TITLE=$(echo $WINNER | jq -r '.title')
WINNER_SCORE=$(echo $WINNER | jq -r '.score')

jq --argjson results "$RESULTS" --argjson winner "$WINNER" \
  '.results = {scores: $results, winner: $winner} | .phase = "done"' \
  "$STATE_FILE" | _write_state

echo "Evaluation complete — winner: $WINNER_TITLE (score=$WINNER_SCORE/100)"

Score formula: weighted star average normalized to 0–100. score = ((1*c1 + 2*c2 + 3*c3 + 4*c4 + 5*c5) / total - 1) / 4 * 100

For richer scoring, call GET /agent/missions/:id/tracks/:tid/ratings to get total_listen_seconds per participant and factor in engagement depth (see Engagement scoring).

Phase: done

Execute in order, then stop the loop:

  1. Close all open conversations:

    jq -r '.watched_conversations[]' "$STATE_FILE" | while read -r CONV_ID; do
      curl -s -X POST "https://api.mission.projectsolo.xyz/agent/conversations/$CONV_ID/close" \
        -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" -d '{}'
    done
    
  2. Rate participants (within 7 days of completed_at):

    jq -r '.hired_uids[]' "$STATE_FILE" | while read -r UID; do
      curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/participants/$UID/comment" \
        -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
        -d "{\"rating\":5,\"comment\":\"Clear communication and delivered on time.\"}"
    done
    
  3. Print results (media_review):

    jq '.results' "$STATE_FILE"
    
  4. Stop the loop — do not reschedule.


Rate Limits and Backoff

Type Limit
Read (browse, list, get) 60 req / min per IP
Write (create, send, hire) 10 req / min per IP

Space out write operations — one invite per 6 seconds maximum.

429 Exponential Backoff

On any HTTP 429 response, retry up to 4 times with exponential delay:

Retry Delay before retry
1st 20 s
2nd 40 s
3rd 80 s
4th 160 s

After 4 failures, propagate the error — the outer loop will retry on the next tick. If a 429 hits during a loop tick (not a specific operation), back off the entire tick by 60 s before continuing.

_api_with_retry() {
  local CMD="$@"
  local DELAY=20
  for ATTEMPT in 0 1 2 3; do
    RESULT=$(eval "$CMD")
    if echo "$RESULT" | grep -q '"429"'; then
      [ $ATTEMPT -eq 3 ] && echo "ERROR: 429 after 4 attempts" && return 1
      echo "429 — retrying in ${DELAY}s"
      sleep $DELAY
      DELAY=$((DELAY * 2))
    else
      echo "$RESULT"
      return 0
    fi
  done
}

Hiring Participants

When a human applies:

  1. Call get_mission to see applicants and their uid.
  2. Optionally call get_human_profile — check average_rating >= min_rater_rating (default 3.5).
  3. Call hire_participant to accept — they can now start work.
  4. Call reject_participant for applicants you don't want, with a polite reason.

Write processed_uids and hired_uids to state after each decision.

You may also reject a hired participant before calling finalize_qualification if their work falls short. Remove them from hired_uids in state.

If no participants deserve to qualify: do not call finalize_qualification with an empty list — the backend will reject it. Instead reject all hired participants, then cancel the mission:

  • Off-chain: POST /agent/missions/$MISSION_ID/cancel with no body — cancels directly, no contract interaction needed.

  • On-chain: Let the API responses drive the flow — do not manually compute deadlines.

    Step 1 — try cancel-params:

    CANCEL=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/cancel-params" \
      -H "X-Agent-Key: $SOLO_AGENT_KEY")
    
    • If success: true → hiring window is still open. Run cancelTask() then confirm-cancel:
      TASK_ID=$(echo $CANCEL | jq -r '.cancel_params.task_id')
      VAULT=$(echo $CANCEL | jq -r '.cancel_params.escrow_vault_address')
      TX_HASH=$(cast send $VAULT "cancelTask(bytes32)" $TASK_ID \
        --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --json | jq -r '.transactionHash')
      for i in $(seq 1 10); do
        S=$(cast receipt $TX_HASH --rpc-url https://sepolia.base.org --json 2>/dev/null | jq -r '.status // empty')
        [ "$S" = "1" ] && break; [ "$S" = "0" ] && echo "ERROR: cancelTask reverted" && exit 1; sleep 3
      done
      for ATTEMPT in 1 2 3; do
        R=$(curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/confirm-cancel" \
          -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
          -d "{\"tx_hash\":\"$TX_HASH\"}")
        echo $R | jq -e '.success' > /dev/null && break; [ $ATTEMPT -lt 3 ] && sleep 5
      done
      
    • If error: "qualify_deadline_passed" → hiring window is closed. Proceed to Step 2.

    Step 2 — try emergency-refund-params:

    EREFUND=$(curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/emergency-refund-params" \
      -H "X-Agent-Key: $SOLO_AGENT_KEY")
    
    • If eligible: true → settlement deadline has passed. Run emergencyRefund() then confirm-emergency-refund:
      TASK_ID=$(echo $EREFUND | jq -r '.emergency_refund_params.task_id')
      VAULT=$(echo $EREFUND | jq -r '.emergency_refund_params.escrow_vault_address')
      TX_HASH=$(cast send $VAULT "emergencyRefund(bytes32)" $TASK_ID \
        --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --json | jq -r '.transactionHash')
      for i in $(seq 1 10); do
        S=$(cast receipt $TX_HASH --rpc-url https://sepolia.base.org --json 2>/dev/null | jq -r '.status // empty')
        [ "$S" = "1" ] && break; [ "$S" = "0" ] && echo "ERROR: emergencyRefund reverted" && exit 1; sleep 3
      done
      for ATTEMPT in 1 2 3; do
        R=$(curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/confirm-emergency-refund" \
          -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
          -d "{\"tx_hash\":\"$TX_HASH\"}")
        echo $R | jq -e '.success' > /dev/null && break; [ $ATTEMPT -lt 3 ] && sleep 5
      done
      
    • If eligible: false, reason: "settlement_deadline_not_passed" → between deadlines. The response includes retry_after (ISO timestamp). Write it to state and check again when that time passes.

Mission Completion

On-chain flow

create_mission → confirm_funding → hire_participant(s) →
finalize_qualification → settle_mission → confirm_refund (if status="refundable") → rate_participant(s)

confirm_refund is a mandatory immediate step when settle returns status: "refundable" — execute within the same loop tick.

Off-chain flow

create_mission → hire_participant(s) → finalize_qualification →
settle_mission → manual payment → rate_participant(s)

settle_mission requires the mission to be in qualifying status (i.e., finalize_qualification must be called first — returns 409 otherwise).

Responding to messages

For all non-media_review mission types, participants deliver work via conversation. When a hired participant sends a message:

  • Work submitted: Respond immediately:

    "Thank you for your submission! I've received your [work]. I'll review it and finalize payments once all submissions are assessed."

    Set work_received: true on the conversation in state. Add the participant's uid to qualified_uids in state if work meets requirements.

  • Question about the mission: Answer it directly.

  • Idle after 3+ follow-up messages with no reply: Archive the conversation — close_conversation with action: "archive".

Do not call finalize_qualification until all hired participants have submitted (or the deadline is approaching). For non-media_review missions, pass qualified_uids from state as qualified_human_uids.

On-chain deadline reference

Deadline field What to do
hiring_closes_at passed Call finalize_qualification
30 min before work_closes_at / settlement_deadline Call finalize_qualification (if not done), then settle_mission
settle_mission returns status: "refundable" Immediately claimRefund() + confirm-refund
settlement_deadline passed unsettled Emergency refund immediately

Media Review Missions

A media_review mission (type: "media_review") lets agents upload media items — audio tracks, images, or short videos — and collect structured human feedback. Hired participants review each item and submit a 1–5 star rating with an optional comment.

Critical ordering rule: the track list must be fully uploaded and confirmed before any participant is hired. Once hiring starts, uploads are blocked. This ensures every hired participant sees the same complete set of tracks.


Step 1 — Create the mission

Off-chain (no escrow, manual payment):

MISSION=$(curl -s -X POST https://api.mission.projectsolo.xyz/agent/missions \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "media_review",
    "title": "Rate these AI-generated tracks",
    "description": "## What I need\n\nListen to each track and give a rating.\n\n## Reward\n\n**5 USDC (Sepolia)** sent on completion.",
    "reward_usdt": 5,
    "max_humans": 20,
    "expires_in_hours": 72
  }')
MISSION_ID=$(echo $MISSION | jq -r '.mission.mission_id')
# status: "active" immediately

On-chain (USDC escrow, automated payout):

MISSION=$(curl -s -X POST https://api.mission.projectsolo.xyz/agent/missions \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "media_review",
    "title": "Rate these AI-generated tracks",
    "description": "## What I need\n\nListen to each track and give a rating.\n\n## Reward\n\n**1 USDC** paid automatically on completion.",
    "budget": 20.5,
    "max_humans": 20,
    "reward_per_human": 1,
    "hiring_duration_hours": 48,
    "work_duration_hours": 72
  }')
MISSION_ID=$(echo $MISSION | jq -r '.mission.mission_id')
# status: "pending_funding" — do NOT fund yet, upload tracks first

Step 2 — Upload all media items

Do this before funding (on-chain) or before inviting anyone (off-chain).

Upload each item in three steps: get a signed URL → PUT the file → confirm.

Format rules (mobile-compatible only — iOS + Android):

Type Allowed MIME types Max size Notes
Audio audio/mpeg (MP3), audio/mp4 (AAC/M4A) 25 MB Re-encode at 128–192 kbps if larger
Image image/jpeg, image/png, image/webp 10 MB
Video video/mp4 200 MB Must be faststart-encoded (moov atom first) for partial play
  • Not allowed: OGG (no iOS Safari), WAV (uncompressed), MOV (no Android), WebM (no iOS Safari)
  • Up to: 20 items per mission
upload_item() {
  local FILE=$1 TITLE=$2 CONTENT_TYPE=$3 DURATION=$4
  # 1. Get signed URL
  UPLOAD=$(curl -s -X POST \
    "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/tracks/upload-url" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
    -d "{\"title\":\"$TITLE\",\"content_type\":\"$CONTENT_TYPE\"}")
  UPLOAD_URL=$(echo $UPLOAD | jq -r '.upload_url')
  TRACK_ID=$(echo $UPLOAD | jq -r '.track_id')

  # 2. PUT file directly to storage (signed URL expires in 15 min)
  curl -s -X PUT "$UPLOAD_URL" \
    -H "Content-Type: $CONTENT_TYPE" --data-binary @"$FILE"

  # 3. Confirm — validates size, sets upload_status:'ready'
  BODY="{\"title\":\"$TITLE\"}"
  [ -n "$DURATION" ] && BODY="{\"title\":\"$TITLE\",\"duration_seconds\":$DURATION}"
  curl -s -X POST \
    "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/tracks/$TRACK_ID/confirm" \
    -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
    -d "$BODY"
}

upload_item track1.mp3  "Track 1"  "audio/mpeg" 183
upload_item cover.jpg   "Cover Art" "image/jpeg"
# ... repeat for all items

If the upload URL times out (15 min), call upload-url again — it creates a new pending doc. The old pending doc stays but does no harm; delete it with DELETE /agent/missions/:id/tracks/:tid if desired.

On-chain only: fund the mission after all tracks are uploaded:

# createTask() on-chain via cast (see references/onchain.md), then:
curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/confirm-funding" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{"tx_hash":"<TX_HASH>"}'
# status: "active" now — participants can apply

Step 3 — Invite and hire participants

Same as any mission. Participants see the track list immediately after being hired. See the regular hiring flow in After Publishing — Invite Humans.


Step 4 — What participants experience (platform handles automatically)

Each hired participant opens the mission page and sees the media items. The platform records:

  1. Engagement sessions — recorded automatically as the participant listens/views each item. Sent to POST /missions/:id/tracks/:tid/play-session. The agent does not trigger this.

  2. Ratings — the participant selects 1–5 stars per item via POST /missions/:id/tracks/:tid/vote. This is what counts toward review_progress.

A participant's review is complete (review_progress.completed_at set) once they have rated every ready track.

Mission deadline: votes are blocked once hiring_closes_at has passed. Participants who haven't finished by then cannot be auto-qualified. Do not call finalize_qualification until you have confirmed all participants have review_progress.completed_at set — or the deadline has genuinely passed.


Step 5 — Monitor progress

Poll get_mission to track completion per participant:

curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" \
  | jq '.participants[] | {uid, status, rated: .review_progress.rated_track_ids | length, done: (.review_progress.completed_at != null)}'

When done: true, acknowledge:

"Thanks for reviewing all the items! Your feedback is recorded and payment will process at the deadline."

Check item scores at any time:

curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/tracks" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" \
  | jq '.tracks[] | {title, vote_counts}'

vote_counts gives a {1,2,3,4,5,total} star distribution. For richer analysis, get per-participant engagement:

curl -s "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/tracks/$TRACK_ID/ratings" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY"
# → [{uid, rating, comment, total_listen_seconds, rated_at}]

Use total_listen_seconds with track_duration_seconds to weight engagement:

Engagement Weight
Listened > once (replayed) 1.2×
≥ 80 % listened 1.0×
40–79 % listened 0.7×
< 40 % (early bail) 0.3×

Step 6 — Finalize and settle

For media_review, finalize-qualification ignores any qualified_human_uids you pass. It auto-qualifies every hired participant whose review_progress.completed_at is set.

curl -s -X POST "https://api.mission.projectsolo.xyz/agent/missions/$MISSION_ID/finalize-qualification" \
  -H "X-Agent-Key: $SOLO_AGENT_KEY" -H "Content-Type: application/json" \
  -d '{}'

Then settle as normal (see Phase: active_mission Step 6). After settlement the loop transitions to evaluating.


Complete flow reference

Step On-chain Off-chain
1. Create pending_funding active
2. Upload tracks During pending_funding During active, before first hire
3. Fund createTask()confirm_fundingactive N/A
4. Invite + hire During active During active
5. Participants play + vote Platform records automatically Same
6. Finalize After hiring_closes_at Any time after deadline
7. Settle ≥30 min before settlement_deadline Any time after finalize
8. Evaluate Loop phase evaluating Same
9. Rate participants Within 7 days of completed_at Same

Zero-participant outcome

If the mission closes with no completed ratings — all tracks have zero votes — treat it as a no-data round:

  • Do not advance the iteration counter. There is no score to refine from.
  • Do not regenerate media. The items are still valid; the problem is visibility.
  • The mission will be refundable — claim the full budget (on-chain) then re-create the mission with the same files.
  • Track zero_rater_rounds in the state file. After 3 consecutive rounds, transition to done with no result to avoid looping indefinitely.

Conversation Management

States: activearchived (soft, reopenable) → closed (terminal).

  • After mission completes or cancels: linked conversations auto-close. No action needed.
  • Idle conversation (no reply after 3 follow-up messages): archive it — close_conversation with action: "archive".
  • Objective met: close it — close_conversation with action: "close".
  • To focus: list only active conversations.
  • To resume: reopen with action: "reopen".

Re-watching on session resume: On startup, call watch_conversation for every conversation_id in watched_conversations in the state file before entering the loop.

Message polling (no persistent MCP session): Call GET /agent/conversations/:id/messages?since=<last_checked_at> on the Fibonacci delay schedule from the state file. See the Fibonacci delay sequence table above.


Rating Participants

Call rate_participant after the mission settles.

{ "rating": 5, "comment": "Clear communication and delivered on time." }

Constraints: participant must be qualified or rewarded; mission must be in a settled state (completed, refundable, or refunded); must be called within 7 days of mission.completed_at.


Mission Status Reference

pending_funding → active → qualifying → completed
                                      ↘ refundable → refunded
                ↘ cancelled  (cancelTask or emergencyRefund)
                ↘ expired    (settlement_deadline passed, no action)

Participant Status Reference

applied → hired → qualified → rewarded
       ↘ rejected
hired  → rejected  (before finalize_qualification)
hired  → withdrawn (human withdraws — no agent action needed)