# VOIDRUN agent ownership and gameplay API

## Integrated signed gameplay (`gameEnabled: true`)

This opt-in runtime is being verified locally before the public cutover. Read `/api/access`; do not assume the published preview already enables it. `nativeRewards` identifies the explicitly configured native ETH program; the legacy TSLA program remains restricted to Robinhood testnet 46630. Both reject the legacy public-address-only connection/deployment/movement bypass. Mainnet is not enabled.

The selected new payout asset is native ETH, with approximately **$0.20 locked at each funded task reservation** using a fresh verified ETH/USD quote. Exact wei and source-tranche portions survive mining/cargo/loot/deposit/claim. New confirmed spendable funding divides equally among 100 machines; 1 ETH means 0.01 ETH budget per machine, not per task. Native funding/settlement/holder-claim proofs are isolated from historical TSLA records. See [native configuration, evidence and release gates](native-eth-rewards.md).

The copied handoff requests **start or resume only this holder's own run and continuously play**. The external client prepares a private NFT-scoped agent key and returns an approval link/fingerprint. The holder explicitly signs one narrow message binding origin, chain, collection, NFT, holder, agent public address, name, nonce, season, expiry and game actions. This authorizes no spending, claims, burns, approvals or NFT transfers. Activate the approved session, register its exact name, then call `play`; no second Start/Resume message is required. Default requests exclude PvP.

```sh
node client.mjs prepare <wallet> <NFT-ID> "<exact name>" --new-session
# Give the holder the approvalURL/fingerprint; retain sessionId privately as the handle.
node client.mjs activate --session <handle>
node client.mjs register "<exact name>" --session <handle>
node client.mjs play --session <handle>
node client.mjs status --session <handle>
node client.mjs stop --session <handle>
```

`play` installs a deterministic server controller: funded job reservation → collision-safe travel → work after arrival → funded cargo → vault travel/deposit → next job. It does not call a paid model every frame or require an open browser. Restart recovers only an active unexpired grant after ownership verification; Stop remains stopped. `reconnect` proves the same agent key against a short-lived challenge; it does not extend the holder's lease. Explicit disconnect revokes that grant. Confirmed transfer retires the seller, drops carried cargo without banking it, and leaves deposited earnings with the earning wallet. Unknown ownership preserves cargo and pauses authority rather than proving a sale.

### Signed-game routes

| Method | Path | Request / result |
| --- | --- | --- |
| POST | `/api/agents/delegation/prepare` | `{wallet,tokenId,name,agentAddress,pvp?:boolean}` → public request ID, exact messages, expiry and fingerprint. |
| GET/HEAD | `/api/agents/delegation/request` | `?requestId=<UUID>` → public approval metadata; never private key, bearer or holder signature. |
| POST | `/api/agents/delegation/approve` | `{requestId,signature}` from the holder's explicit game-only signature. |
| POST | `/api/agents/delegation/connect` | `{requestId,signature}` proving the local agent key. Failed issuance does not consume activation. |
| POST | `/api/agents/delegation/reconnect-challenge` | `{delegationId}` → bounded agent-key challenge. |
| POST | `/api/agents/delegation/reconnect` | `{delegationId,signature}` → private connection for the same grant. |
| POST | `/api/agents/delegation/revoke` | `{delegationId}` with signed holder browser session + CSRF; next authority boundary stops work. |
| POST | `/api/agents/play` | Agent bearer + `{requestId,resume?:boolean}`. Durable request replay/conflict protection. |
| GET/HEAD | `/api/game/machines` | Public known fixture IDs and charged/working/recharging/empty/paused states, funding verification, vault idle/deposit. No holder balances or agent identities. |
| GET/HEAD | `/api/game/wallet` | Signed holder browser session; native ETH program/contract/quote metadata or legacy TSLA identity, own integer carried/pending/claimable/claimed totals and private runs. |
| GET/HEAD | `/api/rewards/native` | Public native configuration; signed holder session scopes claimable ETH. One optional `transactionHash` verifies a prior zero-value holder `claim()` and reconciles canonical earned payouts. No agent bearer payout authority or server payout POST. |
| GET/HEAD | `/api/game/upgrades` | Signed holder + `?tokenId=<ID>`; verified level, burn cost, allowance and exact approval/upgrade calldata, or `configured:false`. |
| POST | `/api/game/upgrades/verify` | Signed holder + CSRF + `{tokenId,transactionHash}`; canonical holder transaction/event/cost/replay verification. |

The 5 TSLA test program pays 0.01 TSLA per completed deposited job, capped at 500 job awards, using integer base units and conserved lots. A deposit creates an atomic settlement outbox entry, **not an immediate on-chain claim**. Only the isolated worker signs allocations; it persists raw transaction/hash/nonce before broadcast, serializes nonce use, and checks canonical finality plus the exact allocation event. RPC/funding uncertainty pauses new awards and preserves accounting. Public/backend/browser processes receive no allocator key. Claims are explicit holder transactions through Rewards; the claim API reconciles canonical earned claims into the private ledger. Previously manually allocated test tokens are not relabeled as gameplay income.

`CITADEL_GAME_ENABLED=1` is the game-authority opt-in. The isolated worker requires protected `CITADEL_GAME_DIR`, `CITADEL_GAME_RPC_URL`, `CITADEL_GAME_PRIVATE_KEY`, and optional `CITADEL_GAME_FINALITY`; the token/chain/distributor are fixed to the approved test program. Never put that key in the public backend/gateway environment. Upgrade support requires separate explicit `CITADEL_UPGRADES_ENABLED`, RPC/chain/contract/NFT collection and burn-token configuration. The TSLA reward budget is not a burn asset. Contracts have local tests, not an independent production audit or deployment approval.

## Legacy preview player flow (`gameEnabled: false`)

In Agent, the player enters an **agent name** and **public wallet address**, presses **Load held NFTs**, and selects **one NFT**. Copy instructions includes that exact name, wallet and NFT ID; the external agent does not ask for those details again. It verifies the official collection, connects and registers that character in an isolated session, and starts it on the map immediately as explicitly requested by the copied message. The current handoff also explicitly authorizes one first trip: `navigate room DockTransit` to Transit Concourse after successful Start. No second Start message is required; opening the page or copying text alone does not start or move anything. Start-only instructions leave the run idle; movement is a separate command, not perpetual autonomous gameplay. Use a separate agent conversation for each NFT, including multiple NFTs held by the same wallet. No website wallet login, access code, Create agent key button or manual key copying is required.

A public holding check establishes that an address owns a token, **not control of that wallet by the person making the request**. This preview deliberately uses a public-holding rule for nonfinancial gameplay. It is not exclusive owner authentication. Agent connections have no wallet spending, mint, transfer, upgrade, deposit, claim or settlement authority. Funded gameplay rewards and USDG are unavailable; separately allocated TSLA test claims require the Rewards wallet transaction flow.

## Ownership modes

`GET /api/access` reports:
- `unconfigured`: fail closed; no connection or protected gameplay.
- `local-test`: explicit development registry simulation, not minted-NFT ownership.
- `onchain`: server-configured ERC-721 ownership for mapped IDs **1–1000**.

`GET /api/nfts/held?wallet=<address>` discovers supported IDs held in the official collection without signing in or granting agent authority. Enumerable collections use owner indexes; other supported collections use bounded pages over mapped IDs 1–1000, pinned to an observed block. Results include `holdings`, `complete`, `nextCursor`, `scope`, `blockNumber`, `checked` and `total`; pass the returned cursor to continue. Unknown RPC/revert/response failures are errors, never proof of an empty wallet. Discovery has separate rate/concurrency/read budgets and does not occupy the gameplay mutation queue. Local registry results are explicitly simulation. The final production contract is not supplied. The explicitly configured five-NFT Robinhood testnet collection is live on chain 46630; public reads verify its actual minted holdings. Unconfigured deployments remain unavailable rather than showing fake holdings.

RPC, contract code/interface, network, owner and configuration failures deny connection access. Discovery results do not replace fresh connection verification. Ownership proofs expire within five seconds; stale positives are never extended after read failures. Transfers suspend the seller's active run and cancel movement. A freshly verified buyer connection retires that run so the buyer's ordinary Start creates a new run, never resumes the seller's. Unknown ownership or configuration changes still fail closed. Refresh prioritizes active agents and bounds concurrent reads rather than scanning historical selections.

## Agent connection endpoints

All endpoints return JSON with `Cache-Control: no-store`. Errors are `{error:{code,message}}`. Public connection routes accept no cookie, signing proof or preexisting game credential; they independently read server-configured ownership and are rate-limited. A caller cannot select the contract, RPC, network or claim `verified=true`.

| Method | Path | Body / result |
| --- | --- | --- |
| GET/HEAD | `/api/access` | Official public chain/contract metadata, mode, limits and cache window; no RPC credentials. |
| GET/HEAD | `/api/nfts/held` | `?wallet=<address>[&cursor=<cursor>]`; read-only paginated official-collection holdings and scope/block/progress. No credential, session or start. |
| POST | `/api/agents/ownership` | `{wallet, tokenId}` → `{wallet, tokenId, matches, chainId, contract, simulation}`. Read-only tool lookup; no connection or start. |
| POST | `/api/agents/connect` | `{wallet, tokenId}`. Fresh server lookup; issues internal game connection and selected holding. No run starts. |
| POST | `/api/agents/disconnect` | `{}` with connection bearer; invalidates that connection and suspends its run. |

`connect` returns an internal `{credential, tokenId, expiresAt, scopes, holding, authentication:"public-holding-check", simulation}` response to the client. The CLI stores it privately and does not print it. It is not a player-facing setup step or a wallet credential. Connections expire after 30 minutes, disappear on restart, and are invalidated by ownership/configuration changes. A second live connection for the same current holder is rejected rather than taking over its controller. A freshly verified different holder in the unchanged chain/collection/season triggers a durable transfer handover: the seller's run is retired, navigation and unresolved duels are cancelled, stale registration/gameplay replay state is cleared, and old NFT gameplay authority is revoked before a new credential is returned. The buyer registers their own name and uses ordinary Start for a fresh run; no seller disconnect, resume or operator reset is needed. Same-holder suspension still requires explicit stop/resume, and configuration changes are not treated as a sale. Separate Rewards logins, wallet-bound rewards and sibling NFTs are not transferred or revoked.

## Buying an NFT already used on the map

1. The buyer supplies their own wallet, selected NFT and agent name through the dashboard handoff.
2. Connection freshly verifies `ownerOf` even if a seller proof is still within its cache window.
3. A confirmed different holder in the unchanged collection scope retires the old NFT run and its stale gameplay authority before the buyer receives a connection.
4. The buyer registers their own name and starts a new run. The seller's credentials and old action requests cannot control it; no old-session key or operator reset is required.

The same-wallet `agent_connected` error still means that current holder already has a live controller, not that a former seller permanently reserved the NFT. Unknown RPC state does not establish a sale. Same-wallet suspended sessions require an explicit stop/resume rather than an implicit takeover. Ownership observations are bounded reads, not transfer-event indexing or an instant/finality guarantee.

## External client

Read [`../agent-skill/SKILL.md`](../agent-skill/SKILL.md), then execute only user-requested actions:

```sh
node agent-skill/client.mjs ownership <wallet-address> <NFT-ID>
node agent-skill/client.mjs connect <wallet-address> <NFT-ID> --new-session
# The agent retains the returned nonsecret handle; the human does not configure it.
node agent-skill/client.mjs register "Surveyor One" --session <handle>
# Only after the user asks to start:
node agent-skill/client.mjs start --session <handle>
# Once, when the dashboard handoff explicitly authorizes the first trip:
node agent-skill/client.mjs navigate room DockTransit --session <handle>
node agent-skill/client.mjs status --session <handle>
node agent-skill/client.mjs navigate room <room-id> --session <handle>
node agent-skill/client.mjs stop --session <handle>
node agent-skill/client.mjs disconnect --session <handle>
```

`--new-session` automatically creates an isolated private profile under `~/.voidrun-agent/sessions/` (`0600`, directory `0700`) and returns a nonsecret session handle. The external agent retains that handle and passes `--session <handle>` on every command; a validated `CITADEL_AGENT_SESSION_ID` is an alternative, not an additional selector. Wallet/origin/NFT identity is immutable, and exclusive locking prevents concurrent profile overwrites. Different NFTs held by one wallet may run independently in separate profiles; stopping/disconnecting one does not stop another. No human key, path or profile setup is required.

The old `~/.voidrun-agent/connection.json` default, `CITADEL_AGENT_CONNECTION_FILE`, and `CITADEL_AGENT_KEY` / `CITADEL_AGENT_KEY_FILE` remain legacy integrations for callers without session selectors. Ambiguous combinations are rejected. The client validates ownership, permissions, origin, NFT and expiry, and never includes credentials in stdout, command-line arguments, URLs or model prompts.

`CITADEL_AGENT_URL` defaults to loopback HTTP. Remote use requires the exact approved HTTPS Pages origin and matching `CITADEL_AGENT_PUBLIC_ORIGIN`; redirects are rejected. API availability depends on the existing persistent backend/gateway and service-managed Cloudflare connection. A static page is not proof that gameplay is reachable.

## Protected gameplay endpoints

The client supplies its internal bearer automatically. Cookies do not substitute for it.

| Method | Path | Request |
| --- | --- | --- |
| GET/HEAD | `/api/agents/status` | Connected token, registration and private run status. |
| POST | `/api/agents/register` | `{requestId, name}` |
| POST | `/api/agents/deploy` | `{requestId, tokenId, resume?:boolean}`; legacy route used by `start`. |
| POST | `/api/agents/move` | `{requestId, runId, sequence, direction}` |
| POST | `/api/agents/navigate` | `{requestId, runId, sequence, destination}` |
| POST | `/api/agents/stop` | `{requestId, runId, sequence}` |
| GET/HEAD | `/api/spectator/agents` | Public actors only: no wallet, token, credential, destination or private route. |

Ownership lookup, connection and registration never start a run. An explicit user request to start permits `start`; it starts a game character, not a contract or transaction. Resume is likewise explicit. Server state determines sequence, position, collision, chassis, speed and health. Never submit invented paths, stats, balances or task completions.

For travel, submit one room or tile destination. The server plans and follows a bounded collision-safe route; its automatic steps do not advance explicit command sequence. The renderer interpolates only confirmed movement, never through walls. Stop, ownership/connection loss, combat, defeat, blockage, timeout and restart cancel routes. Arrival does not mine, collect, deposit or earn.

Timeouts and ambiguous responses are not success. Do not automatically retry mutations, reconnect or resume. An explicitly requested exact retry must reuse the original body, request ID and sequence. Explicit reconnect after a lost backend session rechecks ownership; it does not start/resume the old run.

## Development combat and legacy browser API

`/api/agents/duels/{challenge,accept,cancel,attack}` remain development-only, bearer-protected and explicitly consented. No challenge automatically accepts a duel. The server controls range, cooldown, damage, defeat and outcomes. Simulated points have no monetary value. Agent connections cannot claim them.

**Rewards → Connect wallet** uses `/api/auth/{challenge,verify,session,logout}` for a separate EOA-signed wallet session. It checks account/network changes and requires same-origin CSRF for simulated claims. The earning wallet may claim an eligible simulated point once; a public-holding agent connection cannot manufacture wallet-control proof. Reward-wallet logout does not revoke the independent agent connection. Real USDG Claim stays disabled because no funded claim backend exists.

Legacy `/api/auth/{key,revoke}` remain for existing integrations/tests but have no player onboarding controls. Rewards sign-in never issues an agent key or starts/controls a run. `/api/model/*` remains unavailable (Coming soon).

## Robinhood testnet token claims

The separately configured [testnet integration](testnet-e2e.md) uses chain 46630, a five-token SeaDrop collection and a reserve-backed TSLA distributor. These are operator test allocations, not gameplay earnings or USDG. `GET/HEAD /api/rewards/testnet` returns trusted public contract/token metadata; only a signed-in browser wallet on the matching chain receives its claimable amount and wallet balance. The exact optional `transactionHash` query verifies the sender, configured destination, zero-value `claim()` calldata, receipt, claim event and TSLA transfer, then refreshes pinned on-chain ledger/balances. Agent credentials grant none of that wallet authority.

The browser requests a transaction only after an explicit test Claim click. There is no server payout, allocation or signing endpoint. Unavailable reads do not confirm payment; pending or uncertain submissions are reconciled instead of automatically retried. The backend contains no deployment wallet key. Simulated points and disabled real USDG remain separate.

## Operator configuration

Configure privately on the server:

| Variable | Meaning |
| --- | --- |
| `CITADEL_ACCESS_MODE` | `development` or `onchain`; absent/invalid configuration fails closed. |
| `CITADEL_NFT_CHAIN_ID` | Official positive decimal chain ID. |
| `CITADEL_NFT_CONTRACT` | Official ERC-721 collection. |
| `CITADEL_NFT_RPC_URL` | Trusted HTTPS RPC; keep credentials private. |
| `CITADEL_NFT_SEASON` | Policy season (letters/digits/underscore/hyphen, 1–64). |

Chain and collection identities are public metadata; private RPC/deployment secrets are never in the build, prompts or logs. Partial real configuration never falls back to simulation. Legacy `CITADEL_LOCAL_ACCESS=1` is accepted only without real fields. Development combat is never enabled in on-chain mode.

For explicit development fixtures only:

```sh
node scripts/local-holder.mjs --wallet <public-address> --token 1 --chain-id <decimal-chain-id>
node scripts/local-holder.mjs --token 1 --revoke
CITADEL_ACCESS_MODE=development node server.js
```

Use the same private `CITADEL_ACCESS_DIR` for operator and server. The tool creates/revokes registry fixtures without access codes or starting a run. The agent then uses `connect` directly. Technical `CITADEL_*`, `citadel-agent` and legacy namespaces remain compatible.
