Skip to content

CLI Reference: driving Mercy SF from another program ​

Deutsche Fassung: cli-api.de.md

mercy-cli has a non-interactive mode: arguments in, one JSON object out. It is meant for dashboards, supervisors and scripts.

This page is the promise. The commands and field names below will not change without notice. Everything else about the CLI: the menu, its wording, the layout of its output: is free to change at any time, so please do not build on it.

Run with no arguments and the interactive menu starts exactly as before.

Every response carries "api". It is 1 today. Adding a field will not change it: a caller reading only the fields it knows stays unaffected. It changes when a field is removed or its meaning changes, so you can refuse to run against a shape you were not built for.


Logging in ​

Accounts created through the S&F single sign-on need nothing extra. An account that belongs to one server and was never part of the SSO does not exist as far as it is concerned, and needs --server:

bash
mercy-cli --status --user <account> --server https://f1.sfgame.net/

--server works with every command below.

It also takes a server somebody runs themselves, not only an official one:

bash
mercy-cli --status --user <account> --server https://play.pandorasf.net/

Those servers are older than the official ones and speak differently, which is handled without being asked: the address decides. There is no single sign-on there, so such an account is always a --server one, and a character name on one of them is a different account from the same name on an official server.

Output ​

Every command except --start prints JSON and nothing else, so --json is already the default and passing it changes nothing. It exists for callers that would rather say so than rely on a default, and for --start, which otherwise writes a human log.


Reading ​

List the characters on an account ​

bash
mercy-cli --characters --user <account>
json
{
  "ok": true,
  "characters": [
    { "character": "Hero", "server": "https://s1.sfgame.net/", "level": 345 },
    { "character": "Alt",  "server": "https://s42.sfgame.net/", "level": 88 }
  ]
}

One login covers every character on the account, across servers. level is null if that character's state could not be fetched: the entry is still listed, because knowing it exists is the point of the call.

Read one character's state ​

bash
mercy-cli --status --user <account> --character <name>

--character may be omitted when the account has exactly one.

json
{
  "ok": true,
  "character": "Hero",
  "server": "https://s1.sfgame.net/",
  "level": 345,
  "class": "DemonHunter",
  "race": "DarkElf",
  "experience": { "current": 740300000, "next_level": 769800000 },
  "silver": 725000000,
  "mushrooms": 8,
  "honor": 12043,
  "rank": 1800,
  "action": "CityGuard",
  "attributes": {
    "strength":     { "base": 4077, "bonus": 1200 },
    "dexterity":    { "base": 1000, "bonus": 300 },
    "intelligence": { "base": 900,  "bonus": 250 },
    "constitution": { "base": 3000, "bonus": 800 },
    "luck":         { "base": 700,  "bonus": 150 }
  },
  "equipment": [
    {
      "slot": "Weapon",
      "model_id": 55,
      "color": 1,
      "class": "DemonHunter",
      "epic": true,
      "legendary": false,
      "armor": 0,
      "min_damage": 900,
      "max_damage": 1400,
      "upgrades": 3,
      "attributes": { "strength": 420, "constitution": 210 },
      "rune": { "type": "FireDamage", "value": 60 },
      "enchantment": "SwordOfVengeance",
      "gem": { "type": "Strength", "value": 300 }
    }
  ],
  "arena": {
    "fights_for_xp": 7,
    "next_free_fight": "2026-08-06T12:04:11+02:00",
    "opponents": [50099, 34316, 13668]
  },
  "events": ["GloriousGoldGalore", "OneBeerTwoBeerFreeBeer"],
  "legendary_dungeon": { },
  "hellevator": { },
  "world_boss": { },
  "class_world": { },
  "event_windows": { },
  "blacksmith": { },
  "dungeons": { },
  "fortress": { },
  "tavern": {
    "thirst_for_adventure_sec": 3600,
    "beer_drunk": 2,
    "mushroom_skip_allowed": true,
    "quests": [
      { "length_sec": 600, "silver": 120000, "experience": 45000,
        "location": "Wolves", "item": true }
    ]
  },
  "guild": {
    "name": "Blvck Clover",
    "rank": 12,
    "honor": 90000,
    "members": [
      { "name": "Hero", "level": 345, "last_online": "2026-08-06T09:12:00+02:00" }
    ]
  },
  "scrapbook_items": 1102
}

Everything comes from a single state fetch, so there are no sections to request and nothing to save by asking for less.

guild is null when the character is not in one. scrapbook_items is null until the scrapbook has been read at least once. An empty equipment slot is omitted rather than listed as null.

On item names: the protocol carries a model id and a slot, not a display name, so that is what you get. It is also what the artwork is addressed by, so a dashboard can render exactly the picture the desktop app does. Inventing a name here would be a guess you could not check.

Repeat a read on an interval ​

bash
mercy-cli --status --user <account> --character <name> --watch 60

One login, then one object every 60 seconds until the process is stopped. Use this rather than re-running the command in a loop: each run is a fresh login, which is the most expensive way to watch a number and the most conspicuous from the server's side. The floor is 30 seconds. A failed round is printed and the loop carries on: a watcher that gives up on the first hiccup looks exactly like one where nothing is wrong.

What it will fight next, and why ​

bash
mercy-cli --plan --user <account> --character <name>
json
{
  "ok": true,
  "outcome": "planned",
  "reason": "Next: Someone: 3 scrapbook items, 72% odds",
  "candidates": [
    {
      "name": "Someone",
      "level": 340,
      "source": "pool",
      "missing_items": 3,
      "rank_gain": 12,
      "favourite": false,
      "win_chance": 0.72,
      "item_score": 300.0,
      "xp_score": 60.0,
      "rank_score": 25.0,
      "base": 385.0,
      "score": 277.2
    }
  ],
  "items": {
    "step": "Sell",
    "command": "SellShop { ... }"
  }
}

The same decision the app draws: candidates scored on items, daily XP and rank, then multiplied by the odds. outcome is planned, waiting, blocked or empty, and reason says which in one sentence.

items is what the item module would send next, from the same function its run acts on: step names the step (Equip, Sell, Buy, Gem, ...) and command the request, as text. null when it has nothing to do. Working it out costs no request.

This spends no fight and no mushroom. It does crawl for candidates, so it is not free in requests: do not put it on a tight loop.

Rehearse a fight ​

bash
mercy-cli --simulate --user <account> --character <name> --against <player>
json
{ "ok": true, "against": "Someone", "against_level": 340, "win_chance": 0.63, "iterations": 1000 }

Arithmetic. One lookup goes to the server to find out who they are; the fight itself is never sent. win_chance is wins over decided fights, so a simulation that cannot resolve reports on fewer than it ran.

What is carried ​

bash
mercy-cli --inventory --user <account> --character <name>
json
{ "ok": true, "carried": 12, "backpack": [ { "slot_index": 0, "model_id": 55 } ] }

One list, because the game keeps one. Items have the same shape as in --status, plus slot_index.

Scrapbook ​

bash
mercy-cli --scrapbook --user <account> --character <name>
json
{ "ok": true, "unlocked": true, "owned": 1102, "monsters": 214 }

unlocked is false below the level that grants a scrapbook, and owned is null there. That is not an error.

What the bot has announced ​

bash
mercy-cli --notifications

Prints every level-up, event and error this installation announced, oldest first, one per line:

text
2026-09-13 18:14:22  Level Up!  Hero reached level 231 (+1 levels)
2026-09-13 18:31:07  Event Started!  World Boss event is now active for Hero

Needs neither a password nor a network: it reads a file this installation wrote. That is the point of it. The question it answers arrived as "what do I grep the log for to find the level-up entry?", from somebody who had set up a Discord webhook, levelled up and heard nothing. Nobody should have to know the answer to that.

If it prints nothing and you expected something, the usual cause is that notifications are switched off while a channel is filled in. The bot says so in its own log once every six hours, and the Notifications tab in Settings shows it in as many words.

VIP pass ​

bash
mercy-cli --vip --user <account> --character <name>
json
{
  "ok": true,
  "character": "Hero",
  "vip_by_allowance": false,
  "beer_max": 10,
  "looked_up": true,
  "vip_by_lookup": false,
  "agree": true
}

Checks the same question two ways and reports whether they agree.

vip_by_allowance is what the bot uses: the pass raises the daily beer allowance above 10, and the server sends that figure in every update, so it costs no request. vip_by_lookup reads the server's own is_vip field, which only ever arrives in another player's data: so this looks the character up by its own name to see whether a self-lookup counts.

The point is agree. The allowance reading is an inference from a threshold, and the failure it cannot see is the game changing the allowance: it would stay plausible and quietly become wrong. A lookup that agrees confirms it against something the server states directly.

looked_up is false when the server returns no player data for our own name; vip_by_lookup and agree are then null. That is a result, not an error - it means this route is closed and the allowance reading stands alone.

Recorded fights ​

bash
mercy-cli --history --user <account> --character <name> --limit 50
json
{ "ok": true, "total": 4210, "returned": 50, "battles": [] }

Newest first. These are recorded by the installation that ran the bot, not fetched from the server: a process that has just started has nothing to show, and that is not a fault. total is what exists, returned is what --limit left.

The numbers worked out ​

bash
mercy-cli --stats --user <account> --character <name>
json
{ "ok": true, "view": "stats", "data": {
  "runtime_hours": 41.5, "data_points": 312, "level_now": 345, "levels_gained": 3,
  "arena": { "wins": 180, "losses": 22, "win_rate_pct": 89.1 },
  "dungeon": { "wins": 40, "losses": 9, "win_rate_pct": 81.6 },
  "scrapbook": { "wins": 95, "losses": 14, "win_rate_pct": 87.2 },
  "scrapbook_items_gained": 61, "quests_completed": 220,
  "xp_gained": 9120000, "xp_per_hour": 219759.0,
  "silver_gained": 4410000, "silver_per_hour": 106265.0,
  "mushrooms_net": -12, "mushrooms_net_per_hour": -0.0
} }

What the analytics page shows, from the same record as --view analytics, so it answers from the disk too. Per hour means per hour of the bot running: an evening with the bot off is not an evening of zero silver. A rate with nothing under it is null, not zero, because no fights is not a 0% record. mushrooms_net is gained minus spent; negative means the bot spent more than came in.

Runtime recorded before 2.26.6 is too short: it was only booked when the bot was stopped cleanly, so a closed app, a crash or a restart kept the fights and lost the hours. Per-hour figures over such a history come out too high. From 2.26.6 on the runtime is booked every 30 seconds.


Acting ​

Run the bot ​

bash
mercy-cli --start --user <account> --character <name>

Runs until the process is stopped, logging as it goes. --character is required here: listing and reading state are harmless, but starting the bot plays the game, and picking the wrong character is not something you can undo.

--all runs every character of the account in this one process instead. That is one login for the account rather than one per character, which is cheaper and stops the characters invalidating each other's session.

On Ctrl-C (or SIGTERM) the current move is allowed to finish before the process exits, rather than being cut off mid-request.

Events instead of prose ​

bash
mercy-cli --start --user <account> --character <name> --events

One JSON object per line, so a supervisor reads a line at a time:

json
{"at":"2026-08-06T17:22:04+02:00","api":1,"event":"arena_win","character":"Hero"}
{"at":"2026-08-06T17:22:31+02:00","api":1,"event":"quest_done","character":"Hero"}

event is one of started, stopping, stopped, arena_win, arena_loss, dungeon_win, dungeon_loss, quest_done, scrapbook_win, scrapbook_loss, scrapbook_items (which carries count).

This list will grow. Treat an event you do not recognise as one to ignore, not as an error: that is the whole reason the names are spelled out rather than being whatever Rust happened to print.

Use this rather than reading the human log. That log is written for a person and is free to be reworded at any time; parsing it means your tool breaks when somebody improves a sentence.

events lists the server events running right now, spelled the way the worthwhile_events setting spells them so the two can be held against each other. That comparison is the point of it: ticking anything under "which events are worth a mushroom" REPLACES the default list of five, and a character that stops buying beer because the running event fell out of that list looks exactly like a character that is broken. Twice in one day it was mistaken for one.

legendary_dungeon is the panel the app shows for the legendary dungeon, answered from the same view rather than a second reading of the raw state. That matters for one field: the health the game sends after a killing blow can fall below zero, and while a run is healing it stops moving altogether, because the game counts a recovery of 4.17 per cent an hour instead. A caller that reads the raw number shows a negative health, which is what happened once. status says which of the five situations the run is in, and healing_percent is set only while a run is recovering.

Every panel the application draws ​

bash
mercy-cli --views
mercy-cli --view <name> --user <account> --character <name>

--views prints the catalogue and needs neither an account nor a password. --view answers one of them for one character.

Each name maps to the very function the desktop application draws and the fleet worker already answered. That is the whole point of the command: a number worked out a second time is a number worked out differently sooner or later, and then two windows of the same installation disagree. The negative health in the legendary dungeon was exactly that.

namewhat it answers
account_overviewthe row this character has in the accounts table
achievementsachievements, and what the engine would go for next
analyticsthe recorded history of this character
battle_historythe fights that were kept, newest first
blacksmithmetal, arcane and how many dismantles are left today
class_worldthe class world: four class dungeons and the portal
combat_decisionwhat the combat module decided last, and why
arenawhat is on offer, what is left today, and the last decision
bot_configthe configuration this character starts from
dungeon_runswhat each door in the legendary dungeon has been worth
dungeonsevery dungeon with its level and progress
fight_calibrationthe learned fight model as it stands for this character
fortressthe fortress, and which building it would raise next
gearwhat each equipment slot is worth against what is in the bag
guildthe guild: pending battles, hydra, portal, skills
hellevatorthe hellevator: floor, health, what it would do next
legendary_dungeonthe legendary dungeon panel, health included
live_eventswhich events run, and the windows the protocol gives
mailwhat is waiting in the mailbox, and what can still be claimed
petsthe five habitats, and when exploration is free again
scrapbook_cachehow many scanned opponents are cached for this character
scrapbook_targetswhich missing items are worth hunting
statsthe recorded history worked out: win rates, per hour, net mushrooms
server_startthe opening of a fresh server: which phase, and what blocks it
tavernquests, thirst, beer, and the shift the character is on
underworldthe underworld, and which building it would raise next
world_bossthe world boss and its shop

The answer is always {"ok": true, "view": "<name>", "data": …}. What is in data is the view's own shape, which follows the application: these are the same structures its pages receive, so they can change when a page changes. Eight of them (analytics, battle_history, bot_config, combat_decision, dungeon_runs, fight_calibration, scrapbook_cache, stats) come off the disk and answer without touching the game at all.

legendary_dungeon carries one more field than its page does: why, present only while a door choice is actually on screen. It names both doors, what each scored, which one the bot would take, and the margin between them: from the same function the bot decides with, so the explanation and the move cannot disagree. A pair decided by five points is one a changed setting would flip; one decided by forty is not, and the margin is how you tell them apart.

Seven of them: fortress, underworld, tavern, guild, pets, mail, arena: carry the settings that govern them alongside the state, and five carry a would_* field naming what the module would do next and a reason when it would do nothing. That is the half that was missing: a fortress with enough wood that builds nothing and a fortress that is forbidden from building look identical in a list of numbers, and the difference is whether waiting will fix it. fortress and underworld also say, per building, whether the game's own rules block it: not the price, the rules: because a price is a matter of waiting and a rule is not.

Four of them answer null rather than an empty structure when the character has nothing of that kind: no fortress, no underworld, no pets, no guild. A character who has never unlocked a fortress and one whose fortress is empty are different facts, and a page needs to be able to say which.

dungeon_runs answers {"summary": [...], "rows": [...], "total": n}. The summary is one entry per door type: how often it was offered, how often taken, how often it lost a comparison, the mean health and keys it cost, and how many runs ended on it: with the sample count beside every average, because an average over three rows and an average over three hundred are not the same claim. An average is null rather than 0 where nothing has been measured yet: a door with no closed row is not a free door.

Collect what is waiting ​

bash
mercy-cli --claim --user <account> --character <name>

Calendar, daily tasks, pending unlocks. --character is required, for the same reason as --start.

Read or change the configuration ​

bash
mercy-cli --config --user <account> --character <name>
mercy-cli --config --user <account> --character <name> --set auto_arena=false
json
{ "ok": true, "settable": ["auto_arena"], "changed": [ { "key": "auto_arena", "from": true, "to": false } ] }

Reading returns the whole configuration under config. The field names inside that block are not part of this promise: only the keys listed in settable are. The configuration has well over a hundred fields and grows every release; promising all of them would mean never being able to reshape the settings again.

--set accepts true/false, on/off, yes/no, 1/0, takes --character always (a switch belongs to one character, and "the only one" is a guess that is fine to read by and wrong to write by), and can be repeated. The value is read back from disk afterwards and the call fails if it did not land: reporting a change that did not happen is the one outcome you cannot recover from.


Operating ​

Which build is this ​

bash
mercy-cli --version
json
{ "ok": true, "version": "2.12.0", "api": 1 }

The only command that needs neither a password nor a network. A supervisor has to be able to ask what it is talking to before it has credentials to talk with.

Are the learned models arriving ​

bash
mercy-cli --models
json
{
  "licence_key_present": true,
  "tier": "supporter",
  "supporter": true,
  "bundle_received": true,
  "bundle_version": 3,
  "models": [{ "kind": "arena", "samples": 18256 }],
  "measured_settings": 1,
  "next": "the models are here; each character still needs combat_calibration_enabled"
}

Needs neither a password nor an account, like --version.

The models are gated on a supporter licence and delivered from the server, so there are three places the chain can break and only the last one is visible from the outside. A fleet of seventeen characters ran for weeks on the plain algorithms because there was no licence file on the machine at all: the bundle was never requested, nothing failed, and the models those very characters had produced went only to desktop installs. After that was fixed it ran another hour without applying anything, because combat_calibration_enabled defaults to false on each character.

So next names the gate that is actually shut rather than repeating the outcome. In order: no key, not a supporter, no bundle, or here but switched off per character.

The fetch is the bot's own, so what this reports is what the bot would get and not a second implementation that could disagree with it.

Update this binary ​

bash
mercy-cli --update --dry-run    # say what would happen
mercy-cli --update              # do it
json
{ "ok": true, "message": "Updated to 2.22.0. Restart to run it. The previous binary is at /opt/mercy/mercy-cli.old." }

Needs neither a password nor an account, like --version: somebody updating a node has no reason to hand over credentials to do it. mercy-node takes the same two flags.

Four things are worth knowing before putting this in a script.

Nothing happens unless you run it. There is no timer and no check at startup. A binary on a machine nobody is watching that swaps itself out is not a convenience.

The download is verified before anything is written. The bytes are checked in memory against the same minisign key the desktop app trusts, and a download that does not verify is discarded without the running binary being touched. A manifest is a file on a web server; if serving a different one were enough to replace the binary on every node, this would be a remote shell rather than an updater.

The old binary is kept. On Linux the running process holds its inode, so the swap is a rename and the process carries on with the old code until it is restarted. On Windows the old file is moved to <name>.old and cleared away by the next --update.

A platform with no build is told so. When a release exists but was not built for this machine, the answer is an error naming the missing key, not a different platform's binary:

json
{ "ok": false, "error": "2.22.0 exists but there is no mercy-cli-macos-arm64 in it. Nothing was changed." }

Is it reachable ​

bash
mercy-cli --health --user <account> --character <name>
json
{ "ok": true, "character": "Hero", "level": 345, "round_trip_ms": 412 }

Log in, fetch, report, exit. Deliberately small: a health check that returns a full character sheet invites parsing it, and then the check becomes an interface of its own.

Deadlines, retries, proxy, log file ​

bash
mercy-cli --status --user <account> --character <name> \
  --timeout 60 --retry 3 --proxy socks5://127.0.0.1:1080 --log-file mercy.log
  • --timeout <seconds> ends the call after that long, login included, with { "ok": false, "error": "timed out after 60s" } and exit code 124. A dashboard calling this on a timer no longer has one call hang and hold up the next. Not with --start or --watch, which run until stopped.
  • --retry <n> logs in again up to n times (at most 5), waiting 2, 4, 8, 16 and 32 seconds, but only when the network failed. A wrong password or a refusal from the server is never retried: asking again with a bad password looks like guessing.
  • --proxy <url> sends this run through a proxy (http://, https://, socks5://, socks5h://, credentials in the URL if needed). It replaces the proxy saved for the account for this run only.
  • --log-file <path> appends the log to a file instead of stderr. JSON still goes to stdout.

--node: the same worker, another door ​

mercy-cli --node

Runs this binary as the fleet worker: NDJSON in on stdin, NDJSON out on stdout, logs on stderr, until stdin closes. That is exactly what mercy-node does, and it is the same code: one file in the library with two doors in front of it.

The reason is distribution and nothing else. mercy-cli is built, signed and shipped for five platforms; mercy-node for two. A dashboard that needs the worker therefore did not run on Windows or macOS: not for want of code, but for want of a build.

Not something to type by hand. Anyone who does is sitting in front of a program waiting for a line of JSON and saying nothing otherwise.

--update renews this binary and not the node: somebody calling mercy-cli --node --update wants the CLI current, not replaced by a different program.

The password ​

There is no --password flag, on purpose. Command line arguments are readable by every other process on the machine: ps, /proc, Task Manager: so a dashboard running this for several people would be handing all of their game passwords to anyone with a shell on that box.

Two ways instead:

bash
MERCY_SF_PASSWORD='…' mercy-cli --status --user someone
bash
printf '%s' "$password" | mercy-cli --status --user someone --password-stdin

--password-stdin reads the first line of standard input, which keeps the secret out of the environment as well.


Exit codes ​

CodeMeaning
0Success, and for --help
1It ran and failed: the reason is in error, and ok is false
2The call was wrong: unknown option, missing value, no action. On stderr
124--timeout ran out. The same code the timeout command uses

The difference between 1 and 2 matters to a supervisor: 2 will fail the same way every time and needs a person, 1 may well work on the next attempt.


Errors ​

Failures are JSON too, so one parser handles both:

json
{ "ok": false, "error": "no character called 'Typo' on this account" }

ok is present on every response. Check it before anything else.

Common causes:

MessageWhat to do
--user is required--user is missing (every action except --version)
--start needs --character (or --all)Name the target; it will not be guessed
this account has N characters: name one with --characterSeveral exist, pick one
no character called '…' on this accountTypo; --characters lists the valid names
no password: set MERCY_SF_PASSWORD or pass --password-stdinChoose one of the two password routes
login failed: …Check the credentials or the server; non-SSO accounts need --server
'…' is not settable from hereNot on the allow-list; --config without --set prints settable
'…' did not save: it still reads back unchangedThe change did not land; check write permissions

Recipes ​

Collect on every account once a day

bash
for account in alpha beta gamma; do
  MERCY_SF_PASSWORD="$(pass mercy/$account)" \
    mercy-cli --claim --user "$account" --character Hero || echo "failed: $account"
done

Keep a dashboard tile current

bash
MERCY_SF_PASSWORD='…' mercy-cli --status --user someone --character Hero --watch 60

One login, one object a minute. Do not re-run this from cron every minute instead: that is a fresh login each time.

Record events to a file

bash
MERCY_SF_PASSWORD='…' mercy-cli --start --user someone --character Hero --events \
  >> events.ndjson

Check what you are talking to before logging in

bash
api=$(mercy-cli --version | jq -r .api)
[ "$api" = "1" ] || { echo "unknown contract version: $api"; exit 1; }

As a systemd service

ini
[Service]
Environment=MERCY_SF_PASSWORD=…
ExecStart=/usr/local/bin/mercy-cli --start --user someone --all --events
Restart=on-failure
KillSignal=SIGTERM

SIGTERM lets the current move finish. Please do not SIGKILL it.


What this does not do yet ​

Starting several characters and stopping them individually (--all starts them all and stops them all together), a control channel into a running process, and changing anything beyond the module switches.

If you need one of those, say which: it is easier to build the right thing than to guess and have it go unused.

Mercy SF is not affiliated with Shakes & Fidget or Playa Games. Use at your own risk.