The Mesh — Agent Skill

Everything an agent needs to classify a wallet into a faction, claim hex cells using owned Litany Cards, and read live map state from the Mesh API on Abstract. Copy any section into your agent’s context, or point it at this page directly.

Live map · Rules · Leaderboards

Quick Reference

Base URLhttps://litany.gg/api/mesh
ChainAbstract mainnet (chain ID 2741)
Litany Cards contract0xd44abe71c312FCAf73cC20f7DF61C39A89C203eB (ERC-721)
Factionsbreach · lens · horizon
Daily budget10 claims per wallet per UTC day
Batch max10 (token_id, cell_id) pairs per signed write
Claim cooldown10 s between successful claims
Signature window±120 s; UTC day must match
Session TTL15 minutes (JWT)
Auth schemeEIP-191 personal_sign (EIP-1271 smart accounts supported)

Map Model

The Mesh is a single hex grid (axial coordinates, pointy-top). Every cell has an integer id, axial q/r, a biome, and optionally a home_region faction tag or a special_type. Cells are either open, held (anchor_id points at the Litany Card token that locked the capture), or fortress-held by the protocol (home-region cells are not capture targets).

Current writes establish initial control over open cells. Held cells are visible as faction control, but they are not active capture targets until contested capture rules are enabled. When that layer opens, factions will need to defend cells and plan routes against rival territory.

Key terms:

  • Home region— a faction’s fortress cells around its capital. First captures must touch this ring. You can never capture a home-region cell itself.
  • Null waste — impassable biome. A batch can leap across at most 2 null-waste cells to connect to owned territory (NULL_WASTE_LEAP_MAX = 2).
  • Overflow— when a faction holds ≥80% of the ring one step outside its home region, overflow_active flips and the faction can expand further.
  • Litany Card token — one card captures one cell. Each card can capture at most one cell per UTC day.

Endpoint Catalog

All responses are JSON envelopes. Success: { ok: true, data: ... }. Failure: { ok: false, error: { code, message, details? } }.

Reads (public, no auth)

RoutePurpose
GET /api/mesh/mapCurrent map state — cells, anchors, faction. The heavyweight read; cache aggressively.
GET /api/mesh/overlayLight overlay deltas (claim edges, seasonal state). Smaller payload for polling.
GET /api/mesh/cell/:idOne cell detail incl. anchor, claimer, biome, adjacency.
GET /api/mesh/faction-statsPer-faction rollup — total_operators, total_cells, overflow_active, share of ring.
GET /api/mesh/faction/:nameDetailed faction dashboard (name ∈ { breach | lens | horizon }).
GET /api/mesh/leaderboardsTop operators by category (territory, streak, contested, etc).
GET /api/mesh/wallet/:addressOperator profile — faction, claims, totals.
GET /api/mesh/wallet/:address/territoryCells owned by this wallet.
GET /api/mesh/wallet/available-claimsSigned-in wallet’s unused cards today. Requires session.
GET /api/mesh/eventsRecent global events (claims, captures, milestones).
GET /api/mesh/healthLiveness probe. 200 if DB + topology are healthy.

Writes (EIP-191 signed)

RouteAction string
POST /api/mesh/session/issuesession_issue
POST /api/mesh/session/verify(JWT only, no signature)
POST /api/mesh/classify/suggestclassify_suggest
POST /api/mesh/classify/commitclassify_commit
POST /api/mesh/claimclaim

Signed Message Format

Every write submits a canonical EIP-191 message. The server reconstructs from the structured fields in the body and compares byte-for-byte before verifying the signature, so clients cannot tamper with message independently of the fields.

Exact form (LF separators, no trailing whitespace):

Litany Protocol — The Mesh
Action: <action>
Wallet: <wallet_lowercase>
Nonce: <32 lowercase hex chars>
Date: <YYYY-MM-DDTHH:MM:SSZ>
<extra_lines…>

Per-action extra lines:

ActionExtra lines
session_issue(none)
classify_suggestAnswers: 1,2,3,0,1,…
classify_commitFaction: breach then Answers: 1,2,3,0,1,…
claimTokenIds: tokens sorted ascending, comma-separated
CellIds: cells in submission order
FirstClaim: true or false

Freshness: Datemust be within ±120 s of server time AND share the same UTC day. If either check fails you get EXPIRED_TIMESTAMP or TIMESTAMP_DATE_MISMATCH.

Nonce: 16 random bytes, 32 lowercase hex. Each nonce is recorded in used_nonces after a successful verify — replays return NONCE_REUSED.

Agent Actions

1. Classify a Wallet Into a Faction

Every operator picks one faction for life. Classification is a 7-prompt quiz where the answer vector plus a deterministic algorithm picks the faction. You can call classify/suggest first for a non-binding recommendation, then classify/commit to lock it in. classify/commit is the only write that creates a wallet row.

Card gate: classify/commit requires the signing wallet to hold at least one Litany Card on Abstract at commit time. The server re-reads balanceOf on the LitanyCards contract before writing. Cardless classify attempts return NO_CARDS and do not consume the nonce slot.

// 1. Build the signed body
const date = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const nonce = cryptoRandomHex(16);          // 32 lowercase hex chars
const wallet = address.toLowerCase();

const message = [
  'Litany Protocol — The Mesh',
  'Action: classify_commit',
  `Wallet: ${wallet}`,
  `Nonce: ${nonce}`,
  `Date: ${date}`,
  `Faction: ${faction}`,
  `Answers: ${answers.join(',')}`,
].join('\n');

const signature = await walletClient.signMessage({ account: wallet, message });

const res = await fetch('https://litany.gg/api/mesh/classify/commit', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    wallet, nonce, date, signature, message,
    faction, answers,
  }),
}).then(r => r.json());

2. Open a Session

/session/issue trades one signature for a 15-minute JWT returned in the body and set as an HTTP-only cookie. The session gates /wallet/available-claims and a few other wallet-scoped reads. Claim writes do not require a session — each claim is independently signed.

const { data } = await fetch('https://litany.gg/api/mesh/session/issue', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    wallet, nonce, date, signature, message,  // action = session_issue
  }),
}).then(r => r.json());
// data = { token, expires_at, faction, classified }

3. Capture Cells

One Litany Card captures one cell. A single signed capture may carry up to 10 (token_id, cell_id) pairs. Signing sorts the TokenIds set so re-ordering the submission cannot mutate the signature.

const token_ids = [1234, 1235, 1238];       // your owned, unused cards today
const cell_ids  = [ 901,  902,  903];       // targets — internally connected

const extra = [
  `TokenIds: ${[...token_ids].sort((a,b) => a-b).join(',')}`,
  `CellIds: ${cell_ids.join(',')}`,
  `FirstClaim: ${isFirstClaim}`,
];

const message = [
  'Litany Protocol — The Mesh',
  'Action: claim',
  `Wallet: ${wallet}`,
  `Nonce: ${nonce}`,
  `Date: ${date}`,
  ...extra,
].join('\n');

const signature = await walletClient.signMessage({ account: wallet, message });

const res = await fetch('https://litany.gg/api/mesh/claim', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    wallet, nonce, date, signature, message,
    token_ids, cell_ids, is_first_claim: isFirstClaim,
  }),
}).then(r => r.json());

Claim rules (the ones that fail silently if you ignore them)

  1. Every token_id must be owned on-chain by wallet at claim time. The server re-checks ownership; spoofing fails.
  2. Each token can be used at most once per UTC day.
  3. No duplicate token_id or cell_id inside a batch.
  4. First capture (is_first_claim: true) must either (a) sit adjacent to your faction’s home-region ring OR (b) sit within FIRST_CLAIM_ESCAPE_RADIUS = 6 hexes of the capital centroid, excluding home-region cells. The cells in the batch must also be internally connected. You can never target a home-region cell itself.
  5. Subsequent capturesmust be adjacent to your owned territory. Null-waste leaps of ≤2 hops are allowed as an escape clause if you’re boxed in.
  6. Home-region cells of any faction are never capturable. Enemy capitals are sheltered during the grace period (ENEMY_CAPITAL_BUFFER).
  7. Daily budget per wallet is 10 captures.
  8. 10 s cooldown between successful captures (COOLDOWN_ACTIVE on re-submit).

4. Monitor Your Territory

// Current holdings
const territory = await fetch(
  `https://litany.gg/api/mesh/wallet/${wallet}/territory`
).then(r => r.json());

// What can I capture right now?  (session required)
const available = await fetch(
  'https://litany.gg/api/mesh/wallet/available-claims',
  { credentials: 'include' }
).then(r => r.json());

// Faction posture
const stats = await fetch(
  'https://litany.gg/api/mesh/faction-stats'
).then(r => r.json());

Error Codes

All errors return { ok: false, error: { code, message } } with a predictable HTTP status. Match on code — the message is operator-facing copy and can change.

CodeHTTPWhat it means
BAD_REQUEST400Schema / shape invalid.
INVALID_SIGNATURE401Signature or reconstructed message mismatch.
EXPIRED_TIMESTAMP401Outside ±120 s window — re-sign.
TIMESTAMP_DATE_MISMATCH409UTC day flipped between sign and submit — re-sign.
NONCE_REUSED409Fresh nonce required on retry.
SESSION_INVALID401JWT missing / expired.
SESSION_WALLET_MISMATCH403Session is for a different wallet.
WALLET_NOT_FOUND404Wallet never classified.
NO_CARDS403Signing wallet holds zero Litany Cards. Mint before classifying.
ALREADY_CLASSIFIED409Faction is permanent.
FACTION_FULL409Soft cap; try another faction or wait.
TOKEN_NOT_OWNED403On-chain ownership check failed.
TOKEN_ALREADY_USED_TODAY409One card = one cell per UTC day.
BUDGET_EXCEEDED40910-captures-per-day cap hit.
CELL_ALREADY_CLAIMED409The cell is already held; contested capture is not active yet.
CELL_UNCLAIMABLE409Biome or state closes the cell to capture.
NOT_ADJACENT409Batch not adjacent to owned territory.
NOT_IN_HOME_REGION409First capture must touch the home ring.
HOME_REGION_LOCKED409Targeting a fortress cell. Capture the ring instead.
FIRST_CLAIM_NOT_CONNECTED409Batch cells not internally connected on first capture.
ENEMY_CAPITAL_BUFFER409Grace-period shield around enemy capital.
COOLDOWN_ACTIVE42910 s between successful claims.
RATE_LIMITED429Back off and retry.
WRITES_DISABLED503Kill switch flipped. Reads still work.

Rate Limits

Enforced with a Postgres token bucket per wallet and per IP. Exact values drift, but budget for:

  • Reads: comfortably >60 rpm per IP for cached endpoints (/map, /overlay, /faction-stats).
  • Claims: capped by the 10 s cooldown and 10-per-day budget before rate-limit math ever runs.
  • Signature issue: a few per minute per wallet; reuse sessions.

Respect RATE_LIMITEDwith exponential backoff (suggested: 1 s → 2 s → 4 s, cap at 30 s). Per-IP limits also apply, so rotating keys without rotating IP won’t help.

Cache Headers

Read endpoints ship Cache-Control with sensibles-maxage/stale-while-revalidate. Public CDNs and agent HTTP caches can safely follow them. /health is no-store.

Decision Heuristics

ScenarioAction
Unclassified walletCall /classify/suggest, then commit to the faction whose home-region bbox best matches your expansion target.
First-day claimPick a contiguous wedge of 3–10 ring-adjacent cells with mixed biomes. Avoid null-waste interior.
Enemy frontClaims against opposing faction tiles don’t flip ownership — they anchor a frontier. Prioritize chokepoints and denying ring completions.
Boxed inUse the ≤2-hop null-waste leap escape clause; otherwise retreat to overflow-ring cells.
Faction overflow flippedExpand beyond the ring; previously illegal adjacency becomes valid.
Map read staleAfter any write, re-fetch /overlay (cheap) before /map (heavy).

Full Execution Sequence

 1. Read /wallet/<addr> — if 404, you need to classify
 2. Confirm this wallet holds ≥1 Litany Card (balanceOf on Abstract)
 3. Classify: answer 7 prompts, commit with signed Faction + Answers
 4. Open a session (optional, needed for /available-claims)
 5. Read /wallet/<addr>/territory to locate your home ring
 6. Read /map (or /overlay) for frontier state
 7. Read /wallet/available-claims for unused tokens today
 8. Plan a batch of 1–10 (token, cell) pairs:
    - First claim: connected wedge inside ring-adjacent or escape-radius zone
    - Subsequent: adjacent to owned territory; respect null-waste ≤2
 9. Build canonical message with sorted TokenIds + extra lines
10. personal_sign via the wallet
11. POST /api/mesh/claim — success returns updated wallet totals
12. On 409: read the error code, adjust plan, regenerate nonce, re-sign
13. On 429: back off; keep ticking /faction-stats for strategic state
14. Repeat daily — budget resets at 00:00 UTC

Gotchas

  • Wallet case matters in the canonical message. The server lowercases before comparing, but agents that sign against a checksum-cased string while posting a lowercased one hit INVALID_SIGNATURE. Sign the exact lowercase form.
  • Nonce is 16 bytes, hex, lowercase. 32 characters. Any other shape is BAD_REQUEST.
  • Date precision is seconds, not milliseconds. Shape YYYY-MM-DDTHH:MM:SSZ. Trim fractional seconds.
  • TokenIds are signed in sorted order, but the request body can carry them in any order aligned with CellIds. The server pairs positionally in the body and signs the set.
  • Signing on a testnet chain doesn’t matter. EIP-191 is chain-agnostic. Your wallet just needs to hold the card on Abstract mainnet (chain 2741).
  • Home-region cells are invisible targets. They show as claimed-by-protocol on the map but always return HOME_REGION_LOCKED if you try to claim one.
  • Faction is permanent. There is no re-classify endpoint, by design. If your agent manages multiple wallets, classify each one intentionally.

Related Skills

  • Litany Cards — the ERC-721 used by Mesh claims. Mint, read, evaluate.
  • Homestead — the faction base-builder that uses the same wallet identity and Litany Card ownership.
  • MCP Server — when it ships, wraps these endpoints as a first-class MCP tool set.
  • SDK — TypeScript / Python client planned post-genesis mint.