Permissions
A plugin can only do what it asks for under permissions in the manifest. Phantom shows the player the list in plain words before any of the plugin runs, and the plugin stays off until they allow it.
"permissions": {
"game": "read",
"packets": "read",
"network": ["api.example.com"],
"notifications": true,
"optional": ["notifications"]
}The game: read or act
The SDK runs in your plugin's own frame. ctx.phantom() gives you all of it: flash (every class and member of the client), aj (Animal Jam's features as calls) and keybinds. Each request to the game goes to Phantom as a message, and Phantom checks what the message does, not which part of the SDK sent it.
"game": "read"sees the game and draws over it: reading a member ($get,aj.room.current(),flash.collect,flash.find), listening (a hook that only observes,flash.watch,flash.track,aj.buddies.onChange, keybinds), and drawing on your screen (the overlay,flash.ui.rulewithhide, highlights, recolouring and text styles). Nothing is sent to Animal Jam, and nobody else sees what you draw."game": "act"also acts as the player: calling a client method ($call, andajmembers that do, likeaj.chat.sayoraj.room.join), setting a member ($set,el.keep), constructing a client object, overriding a method ($override), driving or holding a member every frame (flash.drive,flash.hold), and hooks that skip a method or replace its arguments or return value.actincludesread.
A chain or batch needs the highest permission of any of its steps, so a chain of reads with one call in it needs act.
Reads never need act
Every aj read (current(), all(), isX(), status()…) works with read. A few call one of the client's own lookups with no side effects (getItemDef, a translation). Those are on a short reviewed list that's allowed at read, and a test in the SDK fails if a read ever sends anything else that needs act.
The traffic: read or send
"packets": "read"hears the messages between the game and Animal Jam:ctx.phantom().packets.server.watch(...),packets.client.watch(...), and theajevents that come from the server's messages."packets": "send"also sends them in either direction, as though the game or the server did, and changes or drops them on the way (packets.server.on,packets.client.on). It includesread.
No plugin can see, send or change the sign-in, whatever it was granted.
Without packets the SDK still works, but its traffic events never fire, and the plugin's log says so once. Calls that act and then wait for the server's answer, like aj.adventures.lobby.join or lobby.start, need it: without it they throw REFUSED straight away, naming packets:read, and send nothing.
Phantom's own channels
Some of aj is Phantom's own rather than Animal Jam's, and is checked member by member instead of as a message to the client:
| Area | "game": "read" | Needs more |
|---|---|---|
aj.community | who is near (nearby, onNearby), who is hidden | hide, unhide, report: "game": "act" |
aj.customRooms | the room the player is in, its characters, objects, props, shared, limits, every onX | define, open, enter, leave, remove, and every change: "game": "act" |
aj.content | all(), onRequest | replace, restore, load, replaceOnScreen: "game": "act" |
aj.realtime | nothing | everything: "realtime": true |
A custom room takes over the game's screen and moves the player, and replacing an asset puts your own files (a SWF is code) into the client, so both count as acting. A plugin passes content as bytes or a data: URL, never a file on the computer. Each plugin only sees its own rooms and content. When it's turned off, Phantom restores what it replaced, leaves its real-time channels and takes the player out of its room.
Real time: "realtime": true
"realtime": true lets the plugin talk to copies of itself on other people's computers through Phantom's server (aj.realtime): messages, asks, shared state, presence and pictures, with everyone running it in the player's room or in a named channel. Others see the player's screen name, never their sign-in, and moderators can see what's sent. It's reached through ctx.phantom(), so it goes with "game": "read". It's high risk, like a network host, because what the plugin read can leave the computer that way. A plugin can only join from the store or in Developer mode, and the same goes for a shared custom room (open(room, { shared: true })).
Outside the game
"network": ["host"]:ctx.fetchto those HTTPS hosts only (*.example.comfor every name under a domain). A frame has no network access of its own."clipboard": true:ctx.copy. Plugins can't read the clipboard."notifications": true:ctx.notify, on the desktop, titled with the plugin's name.
Toasts (ctx.toast, ctx.confirm), storage, ctx.keep, settings and panels need nothing.
The microphone: "microphone": true
"microphone": true lets the plugin's frames use the microphone with navigator.mediaDevices.getUserMedia({ audio: true }), for voice chat or anything else that listens. Only the microphone: a frame asking for the camera or the screen (video, getDisplayMedia) is refused, whatever the manifest says. It's high risk, so a person at Phantom reads every store version that asks for it, and the store warns about a bundle that calls getUserMedia without asking for it.
Most players are children, so it's always the player's choice:
- Always optional. You don't put it in
optional; Phantom does. The plugin installs and runs without it. - Off until the player turns it on. Its box in the install dialog starts unticked ("Use your microphone"), and an update that newly asks for it asks again, unticked. Until it's ticked,
getUserMediafails with aNotAllowedError. - Can be turned off at any time in the plugin's Permissions in Plugins. It takes effect at once: the plugin's frames restart without it.
- Shown while it may be listening. Once Phantom gives the microphone to a frame, a microphone icon appears in Phantom's title bar naming the plugin, with Stop. Stop restarts the plugin's frames, which ends every track, and turns its microphone off.
- Only on the computer it was turned on. Sync carries the player's choices to their other computers, but it can only turn the microphone off that way, never on.
Developer mode grants a plugin everything it asks for except this. Turn its microphone on in its Permissions, as a player would.
Check for it first, and tell the player how to turn it on:
if (!ctx.can('microphone')) {
await ctx.toast('Turn on the microphone for this plugin in Plugins to talk.');
} else {
const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
ctx.log(`Hearing ${microphone.getAudioTracks()[0]?.label ?? 'the microphone'}`);
}Phantom can't see inside a frame, so the title bar's microphone icon stays until the frame that got the microphone is gone: a panel closed, a page left, the plugin turned off or stopped. Stopping your tracks still frees the microphone at once, but the icon waits for the frame. So run a call in a panel or page the player opens to talk, not in background code, and closing it hangs up.
A voice call
Real-time channels carry the signalling: who's in the call, and the offers, answers and network candidates two RTCPeerConnections swap to find each other. The sound goes directly between the players' computers, not through Phantom.
import { definePlugin } from '@phantomclient/plugin';
type Signal =
| { kind: 'description'; description: RTCSessionDescriptionInit }
| { kind: 'candidate'; candidate: RTCIceCandidateInit };
declare const ICE: RTCConfiguration;
export default definePlugin({
async setup(ctx) {
if (!ctx.can('microphone')) {
await ctx.toast('Turn on the microphone for Voice chat in Plugins to talk.');
return;
}
const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
const voice = await ctx
.phantom()
.aj.realtime.join<Signal>({ scope: 'named', name: 'voice' });
const peers = new Map<string, RTCPeerConnection>();
const offers = (memberId: string) => voice.you.id < memberId;
function connect(memberId: string): RTCPeerConnection {
const peer = new RTCPeerConnection({ ...ICE, iceTransportPolicy: 'relay' });
for (const track of microphone.getAudioTracks()) peer.addTrack(track, microphone);
peer.onicecandidate = ({ candidate }) => {
if (candidate)
void voice.sendTo(memberId, {
kind: 'candidate',
candidate: candidate.toJSON(),
});
};
peer.onnegotiationneeded = async () => {
if (!offers(memberId)) return;
await peer.setLocalDescription(await peer.createOffer());
await voice.sendTo(memberId, {
kind: 'description',
description: peer.localDescription!.toJSON(),
});
};
peer.ontrack = ({ streams }) => {
const speaker = new Audio();
speaker.srcObject = streams[0] ?? null;
void speaker.play();
};
peers.set(memberId, peer);
return peer;
}
function hangUp(memberId: string): void {
peers.get(memberId)?.close();
peers.delete(memberId);
}
for (const member of voice.members())
if (member.id !== voice.you.id) connect(member.id);
const stops = [
voice.onJoin((member) => void (peers.has(member.id) || connect(member.id))),
voice.onLeave((member) => void (voice.rejoining || hangUp(member.id))),
voice.onMessage(async ({ from, data }) => {
const peer = peers.get(from.id) ?? connect(from.id);
if (data.kind === 'candidate') return peer.addIceCandidate(data.candidate);
await peer.setRemoteDescription(data.description);
if (data.description.type !== 'offer') return;
await peer.setLocalDescription(await peer.createAnswer());
await voice.sendTo(from.id, {
kind: 'description',
description: peer.localDescription!.toJSON(),
});
}),
ctx.phantom().aj.realtime.onReconnect(() => {
for (const [memberId, peer] of peers) if (offers(memberId)) peer.restartIce();
}),
];
return () => {
stops.forEach((stop) => stop());
for (const memberId of [...peers.keys()]) hangUp(memberId);
microphone.getTracks().forEach((track) => track.stop());
void voice.leave();
};
},
});"permissions": { "game": "read", "realtime": true, "microphone": true, "network": ["turn.example.com"] }A call between computers shows their addresses
Without a relay, RTCPeerConnection gives each player's IP address to everyone in the call, and can reach any server it's pointed at. Use your own TURN server with iceTransportPolicy: 'relay', as above, so players only see the relay.
Every scope, as the player reads it
| Scope | What the player reads | Risk |
|---|---|---|
game:read"game": "read" | See the game and draw over itEverything the game shows and holds: the room, the people in it, your animal, your items and the game’s own screens. It can hear what happens in the game, draw on top of it, and hide or restyle what is on your screen. With this alone it cannot act for you. Only in the games you choose. | medium |
game:act"game": "act" | Act in the game as youAnything you could do in the game yourself: walk, chat, trade, buy, press the game’s own buttons, and change how the game behaves. Some of that spends gems or diamonds, and a trade cannot be undone. Only allow a plugin you trust. | high |
packets:read"packets": "read" | Read the game’s trafficThe messages between the game and Animal Jam, as they pass. Never your sign in. | medium |
packets:send"packets": "send" | Send, change and block the game’s trafficMessages to Animal Jam as though the game sent them, and to the game as though Animal Jam did, and changing or dropping messages on their way. Never your sign in. Only allow a plugin you trust. | high |
realtime"realtime": true | Talk to other Phantom playersMessages, pictures and shared state between this plugin and its copies on other people’s computers, as it happens, through Phantom’s server: everyone running it in your room, or in a channel it names. They see your screen name, never your sign in, and moderators can see what passes. | high |
network:api.example.com"network": ["api.example.com"] | Send and receive data from api.example.comOnly api.example.com, over HTTPS. What it sends there is between you and the plugin’s author. | high |
clipboard:write"clipboard": true | Copy text to your clipboardIt can never read your clipboard. | low |
notifications"notifications": true | Show notificationsOn your desktop, with the plugin’s name. | low |
microphone"microphone": true | Use your microphoneHear what your microphone picks up, for as long as it is on, and send it wherever the plugin can reach, such as other players. Never your camera. It stays off until you turn it on, a microphone shows at the top of Phantom whenever it may be listening, and you can stop it there or turn it off in Plugins at any time. | high |
Reading the game or its traffic is medium risk. Acting in the game, sending traffic, real time and network access are high. A person at Phantom reads any store version that asks for more than low risk before it's listed, and reads a high-risk one line by line.
When something is refused
A message the plugin wasn't granted is refused in its frame where possible, and by Phantom's main process regardless. Either way the error is a PluginError with the code PERMISSION_DENIED, and its message says what was refused, the scope it needed, and the line to add:
Hide players was not allowed to act in the game as you (game:act), which calling
instance.walkTo needs. Ask for it in package.json: "permissions": { "game": "act" }.A setup that hits a refusal crashes and shows this on the plugin, and every refusal is written to the plugin's log.
Optional permissions
Put a scope in permissions.optional if the plugin can run without it, using its name from the table above ("game:act", "packets:read", "notifications", "network:api.example.com"). The player can untick it when installing and change it later in Plugins. Check before you use it:
if (ctx.can('game:act')) {
await ctx.phantom().aj.chat.say('Two treasures taken!');
}Declining game:read declines game:act too, and declining packets:read declines packets:send.
Updates ask again
A new version that asks for more doesn't run until the player allows what it added. A plugin that read the game and now acts in it waits for game:act. Until then the old grant stays and the new scope shows as waiting. A version that asks for less runs straight away.
Which games
Permissions only apply to the games the player picks for the plugin: all of them, or a few. A plugin can't see a game it wasn't allowed on, or find out that it exists.
A plugin's servers narrow that further. A private server plugin ("servers": ["private"]) is never given an Animal Jam game, whatever the player picks, and a call naming one is refused with GAME_NOT_ALLOWED.
Coming from the old API
The old "sdk": { "room": "read", ... } areas and "flash": true are gone, and a manifest that still has them is refused with the line to write instead:
| Before | Now |
|---|---|
"sdk": { "room": "read" } (any areas) | "game": "read" |
any area at "act", or "flash": true | "game": "act" |
"packets": { "watch": [...] } | "packets": "read" |
"packets": { "send": [...] } or modify | "packets": "send" |
Packet permissions no longer list commands. The plugin names the commands it listens to in its code.