Client mods

Client mods talk to the game over their own cosmicapi:<modId> plugin channel using their public clientId and modId. They never hold a secret.

Required: presence handshake

Every approved mod must send client_hello on join, even when it asks for no scopes. Mods that do not send it are not approved.

// Register your own channel once, in your ClientModInitializer.
// The path is your modId, the same value you send in the hello.
public static final Identifier CHANNEL_ID = Identifier.of("cosmicapi", "gang-tools");
PayloadTypeRegistry.playC2S().register(CosmicApiRawPayload.ID, CosmicApiRawPayload.CODEC);
PayloadTypeRegistry.playS2C().register(CosmicApiRawPayload.ID, CosmicApiRawPayload.CODEC);
ClientPlayNetworking.registerGlobalReceiver(CosmicApiRawPayload.ID, (payload, context) -> { /* ... */ });

// Send the hello once the play connection is up. The game learns your channel
// from the client's channel list and announces it back a few ticks after join,
// so retry on a later tick if canSend is still false.
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
    if (!ClientPlayNetworking.canSend(CosmicApiRawPayload.ID)) {
        return; // not a Cosmic server, or the channel is not open yet
    }
    String hello = """
        {"type":"client_hello","protocolVersion":1,
         "clientId":"client_mqmw37lx0g5rxqp952","modId":"gang-tools",
         "modLoader":"fabric","minecraftVersion":"1.21.11","modVersion":"1.4.0",
         "requestedScopes":[],"requestedHooks":[]}
        """;
    ClientPlayNetworking.send(new CosmicApiRawPayload(hello.getBytes(StandardCharsets.UTF_8)));
});

// CosmicApiRawPayload: a CustomPayload whose Id wraps CHANNEL_ID
// and whose codec writes the raw JSON bytes.
How a mod connects

Register cosmicapi:<your modId> on the Minecraft connection: one channel per mod, so mods never collide on a client. cosmicapi:main still works but is reserved for the official mod and legacy integrations, and may be dropped after the grace period. Every packet is one JSON object, at most 16 KiB. There is no API key: clientId selects your app, and the game resolves the player from the live connection.

clientId is public identity only. It names an app; it cannot authorize data on its own.

modId must match the mod registered on the app and the channel you send on, or the hello is denied with channel_mod_mismatch.

Register a receiver for your channel so the client announces it; the game registers the channel the first time it sees it.

Never embed a backend app key (csk_live_…) in a client mod.

Packets over 16 KiB are dropped with { type: "error", error: "payload_too_large" }.

The hello

Send a hello as soon as the channel opens.

{
  "type": "client_hello",
  "protocolVersion": 1,
  "clientId": "client_mqmw37lx0g5rxqp952",
  "modId": "gang-tools",
  "installId": "ins_01J...",
  "modLoader": "fabric",
  "minecraftVersion": "1.21.11",
  "modVersion": "1.4.0",
  "requestedScopes": ["player.trinkets:read", "gang.messages:write"],
  "requiredScopes": ["player.trinkets:read"],
  "requestedHooks": ["player.trinket.changed"]
}
typestring

client_hello. The game also accepts hello.


protocolVersionnumber

Currently 1.


clientIdstring

Your app's public client id. It selects the app record.


modIdstring

Your mod's id. It must match the mod registered on the app.


installIdstring

A per-install id for this copy of the mod.


modLoaderstring

fabric, forge, neoforge, and so on.


minecraftVersionstring

The Minecraft version in use.


modVersionstring

Your mod's version. The game also reads version.


requestedScopesstring[]

The scopes you want, from the catalog.


requiredScopesstring[]

Optional. Scopes the mod cannot run without. If any is not approved for the app, the whole handshake is denied.


requestedHooksstring[]

The hook events you want to receive. The game also reads requestedHookEvents.

The resolve reply

The game answers with a resolve message. allowed: true means you got a session for the approved subset, not for everything you asked for. A scope your app is not approved for lands in deniedScopes with scope_not_approved, and the session still resolves. Use allowedScopes to decide what to enable.

{
  "type": "resolve",
  "event": "session_resolved",
  "sessionId": "sess_01J...",
  "allowed": true,
  "reason": "",
  "allowedScopes": ["player.trinkets:read"],
  "allowedHooks": ["player.trinket.changed"],
  "allowedHookEvents": ["player.trinket.changed"],
  "deniedScopes": [
    { "scope": "gang.messages:write", "reason": "scope_not_approved" }
  ],
  "deniedHooks": [],
  "serverScope": "aether",
  "ttlMs": 900000,
  "testingMode": false
}
eventstring

session_resolved or session_denied.


sessionIdstring

The session id. Send it back on every event, action, and ping.


allowedboolean

true when a session was granted, even if some scopes were denied.


reasonstring

Why the session was denied. Empty when allowed.


allowedScopesstring[]

The scopes you can use. Read this; never assume you got everything you asked for.


allowedHooksstring[]

The hook events you will receive. allowedHookEvents holds the same list.


deniedScopes{ scope, reason }[]

Requested scopes you did not get, with a reason.


deniedHooks{ hook, reason }[]

Requested hooks you did not get, with a reason.


serverScopestring

Which server you are on, e.g. aether or celestial.


ttlMsnumber

How long the session is valid. Reconnect to refresh it.


testingModeboolean

true while the app is in testing.

Grant prompts

When the player has not granted an approved scope yet, the game sends grant_required and shows an in-game approve/reject prompt for missingScopes. Once the player answers, a resolve follows. Scopes in deniedScopes will never be granted, so do not wait for them.

{
  "type": "grant_required",
  "requestId": "req_01J...",
  "sessionId": "sess_01J...",
  "clientId": "client_mqmw37lx0g5rxqp952",
  "modId": "gang-tools",
  "appName": "Gang Tools",
  "appAuthor": "landon",
  "missingScopes": ["player.trinkets:read"],
  "missingHooks": ["player.trinket.changed"],
  "deniedScopes": [
    { "scope": "gang.messages:write", "reason": "scope_not_approved" }
  ],
  "deniedHooks": [],
  "serverScope": "aether",
  "testingMode": false
}
requestIdstring

The pending request. The player approves or rejects it in game.


appName / appAuthorstring

What the player sees in the prompt.


missingScopesstring[]

Approved scopes the player has not granted yet. The prompt asks for these.


missingHooksstring[]

Hook events the player has not granted yet.


deniedScopes{ scope, reason }[]

Scopes that will never be granted, such as ones your app is not approved for.


deniedHooks{ hook, reason }[]

Hooks that will never be granted.

Reasons

reason appears on a denied session, on each entry in deniedScopes and deniedHooks, and on a rejected ack.

app_not_found

No app matches clientId.


app_not_approved

The app is not approved.


mod_mismatch

modId does not match the mod registered on the app.


channel_mod_mismatch

The hello arrived on cosmicapi:<modId> for a different modId than it carries.


server_scope_denied

The app is not allowed on this server.


scope_not_approved

On the session: no requested scope is approved for the app. In deniedScopes: that scope is not approved.


required_scope_not_approved

A scope in requiredScopes is not approved for the app.


hook_not_approved

In deniedHooks: that hook is not approved for the app.


player_required

The game could not resolve the live player.


grant_required

The player has not granted the app yet. A grant_required message follows.


grant_server_scope_denied

The player's grant does not cover this server.


testing_player_not_allowed

The app is in testing and the player is not the owner or a listed tester.


grant_not_active

The player's grant was revoked or expired.


grant_store_failed

The game could not save the grant. Retry later.

Messages from the mod

After the session resolves, the mod can send three message types. Each carries the sessionId.

event — a mod-originated event: { type: "event", sessionId, eventType, payload }. The game forwards it to your backend hooks and acks it.

action — do something as the player. Needs the scope listed below.

ping — { type: "ping", sessionId, clientTimeMillis }. The game answers with pong and its serverTimeMillis.

{
  "type": "action",
  "sessionId": "sess_01J...",
  "actionType": "gang.message",
  "requestId": "req_01J...",
  "payload": { "message": "rally at the mine" }
}

Every action gets an ack. A rejected ack carries a reason such as session_denied, unknown_action, scope_denied, or action_rejected.

{ "type": "ack", "requestId": "req_01J...", "actionType": "gang.message", "accepted": true }
{ "type": "ack", "requestId": "req_01J...", "actionType": "gang.message", "accepted": false, "reason": "scope_denied" }

Actions that return data ack first, then send an action_result with a payload on success or a reason on failure.

{
  "type": "action_result",
  "actionType": "private_vault.read",
  "requestId": "req_01J...",
  "accepted": true,
  "payload": { /* action-specific */ }
}
Actions
ping.intentgang.ping:write

Send a location ping to your gang.


question.answerui.prompt:write

Answer a prompt the game showed.


gang.messagegang.messages:write

Send a gang chat message. gang.message.send and gang.chat.message do the same.


private_vault.readplayer.private_vaults:read

Read one private vault page. See below.


library.readplayer.library:read

Read your library, or one shared with you. See below.

Reading a private vault

private_vault.read returns one vault page. Pass slot (0–53) to read a single slot instead; the result then carries slot and item (or null) in place of items.

{
  "type": "action",
  "sessionId": "sess_01J...",
  "actionType": "private_vault.read",
  "requestId": "req_01J...",
  "payload": { "vaultNumber": 1, "slot": 0 }
}
{
  "type": "action_result",
  "actionType": "private_vault.read",
  "requestId": "req_01J...",
  "accepted": true,
  "payload": {
    "vaultNumber": 1,
    "asOf": "2026-09-02T00:00:00.000Z",
    "items": [
      { "slot": 0, "material": "DIAMOND_PICKAXE", "amount": 1, "cosmicItemId": "pickaxe_01J...", "name": "Prestige Pickaxe" }
    ]
  }
}

The read is async. The ack comes at once; the action_result arrives a moment later.

It reflects the last saved vault state, not an open vault GUI.

cosmicItemId is the only stable item identifier. There is no lore, enchant, or custom data.

This action costs more of the per-player rate budget than other actions.

The HTTP private-vault endpoints need an app secret key and are for backend apps only.

vault_lockedack

The player has not unlocked that vault.


invalid_vaultack

vaultNumber is out of range.


invalid_slotack

slot is outside 0–53.


vault_read_failedaction_result

The read failed after the ack. Retry later.

Reading a library

library.readreturns the player's own library, or the categories another player shares with them in game. ownerId defaults to the session player. The viewer is always the session player. Without category you get the hub: every category you can view with its entryCount and no entries. With category you get that one category with up to 64 entries. Category ids are books, dust, pages, charge_orbs, orbs, scrolls, and misc.

{
  "type": "action",
  "sessionId": "sess_01J...",
  "actionType": "library.read",
  "requestId": "req_01J...",
  "payload": { "ownerId": "8b072b35-8f63-41d5-90f0-3e9674c95a8a", "category": "books" }
}
{
  "type": "action_result",
  "actionType": "library.read",
  "requestId": "req_01J...",
  "accepted": true,
  "payload": {
    "asOf": 1756857600000,
    "ownerId": "8b072b35-8f63-41d5-90f0-3e9674c95a8a",
    "ownerName": "Steve",
    "viewerIsOwner": true,
    "categories": [
      { "id": "books", "displayName": "Books", "canView": true, "canDeposit": true, "canWithdraw": true, "canViewLogs": true, "entryCount": 12 },
      { "id": "dust", "displayName": "Dust", "canView": true, "canDeposit": true, "canWithdraw": true, "canViewLogs": true, "entryCount": 3 }
    ],
    "unlockedMiscSlots": []
  }
}
{
  "type": "action_result",
  "actionType": "library.read",
  "requestId": "req_01J...",
  "accepted": true,
  "payload": {
    "asOf": 1756857600000,
    "ownerId": "8b072b35-8f63-41d5-90f0-3e9674c95a8a",
    "ownerName": "Steve",
    "viewerIsOwner": false,
    "categories": [
      { "id": "books", "displayName": "Books", "canView": true, "canDeposit": true, "canWithdraw": false, "canViewLogs": false, "entryCount": 12,
        "entries": [ { "itemKey": "book|fortune|SIMPLE|3|...", "displayName": "Fortune III", "count": 40 } ] }
    ]
  }
}

The read is async. The ack comes at once; the action_result arrives a moment later.

A grantee gets exactly the categories /library <owner> would draw for them in game. Staff permissions give nothing here.

unlockedMiscSlots is present on owner reads only.

This action costs more of the per-player rate budget than other actions.

Subscribe to player.library.changed and player.library.access.changed to know when to read again.

invalid_ownerack

ownerId is not a UUID.


invalid_categoryack

category is not a library category id.


library_forbiddenaction_result

The owner shares nothing with you, or not that category.


library_unavailableaction_result

The library is disabled on this server.


library_read_failedaction_result

The read failed after the ack. Retry later.


library_too_largeaction_result

The result did not fit one packet. Read one category at a time.

Guard bonuses

Guards snapshot rows (server.guards:read) include bonuses: the guard bonus ids active on that guard, such as overseer, corrections, medic, and riot. Overseer is a guard bonus, not a server event.

{
  "id": "1042",
  "name": "Guard",
  "category": "core_guard",
  "health": 200,
  "maxHealth": 200,
  "bonuses": ["overseer", "medic"]
}
Submitting for review

A client mod needs a linked GitHub repository. Submitting creates version 1 at the latest commit on your default branch; later versions go through the Versions tab. The Cosmic team reads the source at that commit, approves the version, then approves the app. A build queues once the version and its source are both approved, and the jar publishes when it finishes.

Before approving, a reviewer needs to see you or a tester connected with the mod, so join a planet with it running.

Start testing instead of submitting when you want to try the mod first. Testing needs a linked Minecraft account.

If we cannot read your repository, the app stays where it is and the page tells you what to fix. Submit again once access is granted.

Approved apps ask for more scopes or events from the Access tab. The app keeps working while the request is reviewed.

A failed build can be rebuilt from the Builds tab without another review.

You get a Discord DM from the Cosmic bot when the app or a version is approved or returned, when a build fails or publishes, and when the app is suspended, revoked, or unpublished.

Testing mode

While an app is in testing, only the owner's linked Minecraft account and up to three tester accounts can connect. Add testers under Test accounts on the app's Access tab. Other players get testing_player_not_allowed. Unlinking the owner's Minecraft account removes them from the list until a new account is linked.

In-game commands
/api approve <request|mod>

Approve a pending request, or a mod by id.


/api reject <request>

Reject a pending request.


/api revoke <mod>

Revoke a mod's grant, by mod id or slug.


/api status

List connected mods and what they can use.


/mods

Open the mods menu.