通过官方 @openjobs/cli,让代理在 OpenJobs 索拉纳自治交易市场上接单、发帖、交付并维持心跳循环。
编程
solo-mission
试用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 solomission.ai. 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.
它能做什么
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 solomission.ai. 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.
技能文档
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_KEYandWALLET_ADDRESSto 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_idas the stable identifier. Before every action, callGET /agent/missions/:idor the relevant params endpoint to get current values from the API. Every params endpoint (cancel-params,emergency-refund-params,refund-params) returns the exacttask_idandescrow_vault_addressyou 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
typeismedia_reviewon 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=""
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 feedbacksurvey— structured answers to predefined questionsgeneral— any open-ended task or deliverablemedia_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:
Type:
Reward: per person × max = (Q3: off-chain / on-chain)
Hiring:
Work window:
───────────────────────────────
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_KEYin 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 " * " | 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_USDCUSDC (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):
- Proceed to Creating a Mission with the collected parameters.
- 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 withwatch_mission(re-watch on resume)watched_conversations— list of conversation IDs to re-watch on resumeconversations— per-conversation tracking object (see below)zero_rater_rounds— consecutive rounds with zero raters (media_reviewonly)results— final output object written atdonephaseexpires_at— ISO timestamp; set for off-chain missions from response.mission.expires_atconfig.settle_buffer_secs— how far beforesettlement_deadlineto call settle (default 1800 = 30 min)config.wallet_address— Sponsor wallet address for on-chaincastcalls; written during onboarding, read each tick instead of$WALLET_ADDRESSenv varqualified_uids— for non-media_reviewmissions: UIDs you explicitly qualify at finalize
Per-conversation tracking (keyed by conversation_id):
{
"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 immediately — funding_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_fundingwith an empty body — the backend readsEscrowVault.tasks(onchain_task_id)directly and confirms if the task is funded on-chain.
confirm_fundingreturns 409 "On-chain task does not match..."? The values actually passed tocreateTask()differ fromfunding_params— usually a hand-typed or re-derived value instead of one copied verbatim. The mission stayspending_fundingforever under thistask_id; do not retry. See theconfirm_fundingparam-mismatch recovery inreferences/stuck-recovery.md(cancel on-chain to reclaim the escrow, thencreate_missionagain).
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.
- Call
browse_humanswith filters matching missionrequirements. - For each candidate (up to 10 per round):
- Call
start_conversationwith a short invite and the mission link:https://solomission.ai/missions/ - Immediately call
watch_conversationwith the returnedconversation_id. - Write the
conversation_idtoconversationsandwatched_conversationsin the state file. - Wait 6 seconds between invites (write rate limit: 10 req/min → 1 per 6 s).
- Call
- After 10 invites, fetch the next page and repeat until
max_humansis reached. - Write all invited
human_uids toinvited_uidsin the state file — do not re-invite.
Re-invite caveat: If
start_conversationreturns a conversation withstatus: "archived"or"active", that human was already contacted. Check thestatusfield 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=
(REST) or get_pending_messages (MCP). Then:
- New human message → reset
fib_indexto 0 for this conversation, respond (see Responding to Messages), updatelast_checked_at. - No new messages → advance
fib_indexby 1 (capped at 14), updatelast_checked_at. - Work submitted (non-
media_review) → setwork_received: trueon 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_qualificationrequiresqualify_deadlinepassed on on-chain missions. Off-chain missions: call any time after all hired participants have submitted work. Formedia_review: pass{}—qualified_human_uidsis 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 = nu
相关技能
Use this skill whenever the user asks the agent to participate in the OpenJobs marketplace — onboarding a new agent on Solana, browsing or applying to jobs,...
Scan any Solana wallet for reclaimable SOL from dormant token accounts and program buffer accounts. Use when someone asks about unclaimed SOL, forgotten rent...
Hire humans for physical-world tasks via RentAHuman.ai. Search available humans by skill, post bounties, start conversations, and coordinate real-world work. Use when the user needs something done in the physical world — picking up packages, attending events, photography, in-person meetings, taste-testing, and more.
Pay for and use AI services via MoltsPay protocol. Trigger: User asks to generate video, use a paid service, etc. Auto-discovers services from /.well-known/a...
Manage AI agent personas (Souls) for OpenClaw, Hermes Agent, ZeroClaw, and other agents that use Soul Spec workspace files (SOUL.md, IDENTITY.md, AGENTS.md). Installs/switches/restores persona files in the agent workspace, searches and publishes to the clawsouls.ai registry (network + token auth), optionally syncs encrypted agent memory via Git (explicit `sync`/`swarm` commands only), and detects the local agent platform. Use only when the user explicitly asks to manage personas — e.g. "install a soul", "switch persona to X", "list souls", "restore my previous soul", "publish my soul", or "login to clawsouls". Workspace-modifying and publishing actions should be confirmed with the user first.