Updated Describes manager 5.11.2 and SSA Bridge 2.19.5
SSA Bridge protocol#
This page is for developers building their own tool, library or SDK against the SSA Bridge without the manager anywhere in the picture. It describes the wire: how to connect, how to authenticate, how a request and a reply are framed, what an error looks like, how to subscribe to events, and what the bridge will and will not let you do at once.
The bridge is free and complete on its own, and this interface is part of that. There is no key to buy, no endpoint held back and no tier. If you want to write a C#, Python, Go or Rust client for it, nothing here is in your way.
Two things to know before you build on it.
The protocol is still growing. Actions and payload keys are added as the mod grows, and this page describes the shape rather than promising a frozen contract. The last section proposes a protocol version number so that a client can tell an additive change from a breaking one; it is a direction, not something the bridge answers today.
Write your client to ignore what it does not recognise: unknown keys on a result object, unknown event types, unknown error codes. Every addition so far has been additive, and a client built that way survives them.
Related pages: SSA Bridge for what the modules can actually do and what an answer means, SSA Bridge module reference for their vocabulary, and SSA Bridge without the manager for installing it.
Connecting#
A plain TCP socket. IPv4 only: the listener is an IPv4 socket, so an IPv6 client cannot reach it even on the same machine.
The default is 127.0.0.1:27717, and both halves come from Mods/SSABridge/SSABridge.config.json, which the bridge writes itself the first time it starts. The keys that govern the socket:
| Key | Default | What it does |
|---|---|---|
host | 127.0.0.1 | the interface to bind. 0.0.0.0 to accept clients from elsewhere, which also requires a token |
port | 27717 | the port to bind, see the note below on why it is a starting point |
token | empty | the shared secret a non-loopback client must present. Empty means remote control is off |
ratePerSec | 15 | sustained cost units per second fed to the game |
rateBurst | 30 | the size of the burst bucket |
maxHeavyPerTick | 2 | expensive requests run per game frame |
maxSpawnCount | 50 | ceiling on the item count in one spawn |
spawnMinGapMs | 350 | minimum gap between two spawn dispatches |
spawnFeedbackMs | 15000 | how long a hidden command's late output stays hidden |
allowNoExecutor | false | let commands run with nobody online, see cmd |
commandPrefix | empty | the in-game chat prefix that produces chat_command events |
logLevel | info | error, warn, info, detail or trace |
Several of these have guard rails, and knowing them saves a confusing afternoon:
- The configured port is where the bridge starts looking, not where it ends up. If the port is taken it tries the next one, up to twenty above the configured value.
- A
hostthat is not a valid IPv4 literal binds loopback instead. So does an empty one, or one longer than 64 characters. The bridge would rather listen somewhere safe than somewhere the owner did not ask for. - A non-loopback
hostwith notokenset also binds loopback. A public bind with no authentication is not a state the bridge will enter. - A
portoutside 1 to 60000 falls back to the default. The upper bound is lower than you might expect, and a port above it is silently replaced by 27717 rather than refused, which is another reason to read the status file rather than trust the config.
Because of the first of those, do not read the port out of the config file. Read Mods/SSABridge/SSABridge.status.json, which the bridge writes after it has actually bound:
{"version":"2.19.3","host":"127.0.0.1","port":27717,"machineId":"..."}
machineId identifies the Windows install and is what a standalone owner pastes into their account to be issued a licence token. It is not a secret, and it is not part of this protocol.
If your client cannot see the server's filesystem, ask the owner for the port and offer a scan of the twenty ports above it as a fallback.
Keep one connection open and reuse it. The bridge holds at most 24 connections at once, and a new one buys you nothing: everything funnels into one queue and runs on the game thread. A connection per request will hit the ceiling on a busy client and be refused.
Authentication#
Authentication is per connection, and where you connect from decides whether you need it.
From loopback (127.0.0.0/8) there is no authentication. A client on the same machine as the server can send anything the moment the socket is open. This is deliberate: the bridge treats the box it runs on as trusted, and out of the box it listens nowhere else.
From anywhere else, the first thing you send is auth.
{"id":"1","action":"auth","token":"the token from SSABridge.config.json"}
{"id":"1","ok":true,"result":{"authed":true}}
A wrong token answers {"id":"1","ok":false,"error":"bad_token"}, and the attempt is logged with your source IP. Repeated attempts are rate limited and answer rate_limited, so a client that retries in a tight loop will be told to slow down rather than allowed to guess.
Until you have authenticated, one action works and everything else is refused:
statusanswers, but only with the version. No player count, no uptime, no licence state.- Everything else answers
unauthorized, orremote_disabledif the owner has configured no token at all.
A remote connection has ten seconds to authenticate. After that the bridge sends an unauthorized line explaining why and closes the socket. Send auth first, before your version handshake or anything else.
Three more things worth building around:
- There is no TLS. The token crosses the network in clear. If the path is not one you control, put it inside a VPN or an SSH tunnel.
- Authentication does not survive a reconnect. If your client reconnects, it authenticates again, and it resubscribes, see Subscriptions.
- The token is compared in constant time, so timing tells an attacker nothing.
The frame#
Newline delimited JSON. One JSON object per line, in both directions, \n as the terminator. A trailing \r on an inbound line is tolerated and stripped, so \r\n works. Empty lines are ignored.
{"id":"1","action":"status"}\n
Four rules that will bite a client that does not know them:
A line must be one complete JSON object. A line that stops early, with an unclosed brace, an unclosed string or a key with no value, is refused as bad json. Nothing partial is ever executed. Any real JSON serialiser satisfies this; a client assembling strings by hand may not.
Never put a raw newline inside a string value. The reader splits on \n before it parses anything, so a literal newline inside a chat message cuts your request in half. Escape it as \n inside the JSON string, which a serialiser does for you.
Outbound lines never contain a raw newline. The bridge flattens newlines and carriage returns to spaces in everything it sends, so reading up to the next \n always gives you exactly one message.
Values are read strictly. A field the bridge expects to be a number must arrive as a JSON number. A string, a null or a missing value is refused rather than read as zero, which matters most for coordinates, where zero is a real place on the map.
Encoding is UTF-8. \uXXXX escapes in a request are decoded to UTF-8. Nesting deeper than 64 levels is refused.
A request#
{"id":"7","action":"cmd","command":"Announce Restart in 5 minutes","executor":"auto","hide":true,"caller":"my-sdk"}
| Field | Type | Notes |
|---|---|---|
id | string | echoed back on the reply, and how you correlate the two. A number is accepted and comes back as a string. Absent means the reply carries "0" |
action | string | required, see the table below |
executor | string | on cmd, spawn and batch: a 17 digit Steam ID, or auto. Defaults to auto |
hide | bool | on cmd, spawn and batch: hide the command's feedback from the player it runs through. Defaults to true |
caller | string | a label for the bridge's own activity log, so an owner reading it can see who ran what. Optional and free form |
Use your own ids and make them unique per connection. The bridge does not check them, and it does not mind two requests carrying the same one; you will simply not be able to tell the replies apart.
The actions#
| Action | Sends | Answers |
|---|---|---|
status | nothing | version, port, player count, uptime, licence state |
auth | token | {"authed":true} |
list or players | nothing | an array of the players in the world |
where | steamid (optional) | that player's position |
name | steamid (optional) | that player's name |
cmd | command | runs one of SCUM's admin commands, and returns its output |
spawn | item, count, x y z | spawns an item, with pacing |
batch | commands | runs several admin commands through one player |
chat | text, channel, targets, exclude | sends a chat line into the game |
console | command | runs an Unreal console command |
groundz | x, y, fromZ | the ground height under a point |
tracediag | x, y | diagnostics for the trace machinery |
subscribe | events | turns on push events for this connection |
setcmdprefix | prefix | turns the in-game chat command intercept on or off |
modules | nothing | every module, its settings, its schema and its status |
module_config | module, config | replaces one module's settings |
module_data | module, what | asks one module for a report |
module_command | module, what | tells one module to do something |
The last four are where nearly all the capability lives. See The module vocabulary.
status#
The cheapest way to check that the bridge is answering.
{"id":"1","action":"status"}
{"id":"1","ok":true,"result":{"version":"2.19.3","port":27717,"players":3,"uptimeSec":8412,"licensed":false,"licenseExp":0}}
From an unauthenticated remote connection the result is {"version":"2.19.3"} and nothing else.
list and players#
The same action under two names, for the players who are actually in the world.
{"id":"2","ok":true,"result":[{"steamid":"7656119...","name":"Nikolka","x":-410233.1,"y":88120.5,"z":3184.0}]}
A player whose Steam ID could not be read is left out of the list rather than published with a blank one, so every entry can be indexed by steamid.
where and name#
Both take an optional steamid. With no steamid they answer about an arbitrary player, the next one in a rotation, not about "the server". That is rarely what a client wants; pass the ID.
Neither is an error when the player is offline. Both answer player not ready, which covers both "not on the server" and "connected but their prisoner is not in the world yet".
cmd#
Runs one of SCUM's admin commands through a player, and gives you back what the game printed.
{"id":"3","action":"cmd","command":"ListVehicles"}
{"id":"3","ok":true,"result":{"executor":"7656119...","output":["Vehicle 1: ...","Vehicle 2: ..."]}}
Write the command without the leading #. The hash is the chat prefix and the game's own call wants the bare verb. The bridge strips one leading hash if you send it, so both spellings work, but send the bare verb.
A command runs through a connected player, briefly and invisibly. With nobody online there is nothing to run it through, and the bridge answers no_executor. The owner can opt into a fallback with "allowNoExecutor": true, and then an empty server answers:
{"id":"3","ok":true,"result":{"executor":"none","dispatched":true,"confirmed":false,"output":null}}
dispatched is not confirmed. It means the command was handed to the game and nothing more. There is no executing player, so nothing reports back, and output is null because output is captured from a player's chat and there is no player. If your tool charges somebody, gives an item or marks a task done, act on confirmed, never on ok alone.
spawn#
{"id":"4","action":"spawn","item":"BP_Weapon_M9","count":2,"x":-410000,"y":88000,"z":3200}
{"id":"4","ok":true,"result":{"executor":"7656119...","spawned":2,"requested":2,"countNote":"...","item":"BP_Weapon_M9","output":["Spawned BP_Weapon_M9."],"status":"spawned"}}
Four things about this reply:
statusis the verdict to act on. It isspawned,refusedorunknown, derived from what the game itself said.outputis the game's own words, for a person to read.spawnedis the count you asked for, not a count the game reported. The game does not report one, and at least one spawn verb makes exactly one thing whatever number it is given.requestedis the same number under an honest name. Do not charge per unit off either.- All three coordinates or none. Sending
xandywithoutz, or any of them as a string, is refused asbad_xyzrather than quietly placing the item at the island's origin. With no coordinates at all the item lands on the executing player, which is a sensible answer. - The reply can take a few seconds. When the game has to load the asset first it says so slightly later, and the bridge waits up to three seconds to include that instead of telling you there was no output.
Spawns are paced deliberately: at most one every spawnMinGapMs (350 ms by default), and never more than the frame's heavy budget. A burst is spread out rather than refused.
batch#
{"id":"5","action":"batch","commands":["Announce one","Announce two"]}
{"id":"5","ok":true,"result":{"executor":"7656119...","count":2,"ok":2}}
count is how many were sent, ok how many dispatched cleanly. An empty or missing commands array is refused, because a success that ran nothing reads exactly like a success that ran everything.
chat#
{"id":"6","action":"chat","text":"Nikolka: hello","channel":"global","targets":["7656119..."],"exclude":[]}
{"id":"6","ok":true,"result":{"channel":2,"delivered":1}}
textis required, and the line is sent nameless, so put whatever display name you want inside the text.channelislocal,global,squad,adminorserver, or the number directly: 1, 2, 3, 4 and 6 in that order. It defaults to global.targetsabsent or"all"sends to everyone. An array of Steam IDs sends to only those players, which is what makes a private message private.excludedrops the IDs in it, and combines withtargets.deliveredis how many players actually received it.
console#
Runs an Unreal engine console command, which is not the same thing as a SCUM admin command. It answers {"ran":true} or console_failed, and it can tell you nothing about whether the command was a real one, because the engine returns nothing either way.
groundz and tracediag#
groundz traces down at a point and returns the ground height there.
{"id":"8","action":"groundz","x":-410000,"y":88000,"fromZ":3500}
Without fromZ it traces from the sky, which indoors finds the roof. With fromZ, the height you already know something is at, it finds the surface under that, and if the two answers differ by more than half a metre the reply carries both plus a note saying so.
tracediag runs the same probe on seven channels and reports what each returned. It is a diagnostic, not something to build on.
A response#
Every reply to a request carries id and ok.
Success:
{"id":"7","ok":true,"result":{"...":"..."}}
result may be an object, an array or a string, depending on the action.
Failure:
{"id":"7","ok":false,"error":"no_executor","reason":"nobody is on the server to run this through - turn on \"allowNoExecutor\" in SSABridge.config.json to let the bridge dispatch with no executing player"}
Some replies carry extra keys beside result rather than inside it. On module_command and module_data those are changed, note and reach, and they are described under The module vocabulary. Treat any key you do not know as something to ignore, not as an error.
Replies are not ordered. Correlate by id and never by arrival. Two things cause that: a request the bridge cannot afford this frame is put back on the queue and runs on a later one, so a cheap request behind an expensive one overtakes it; and a spawn's reply may be held for a few seconds waiting for the game to speak.
Events arrive on the same socket, interleaved with replies. An event object has an event key and no id, so the discriminator is simple: if there is an id, it is a reply to something you sent; if there is an event, it is a push.
Errors#
The field to branch on is error, and there is one thing you must know about it before you write that switch.
error is a stable code only when it comes from the bridge itself. When a module refuses, error carries the module's own sentence. The modules answer in prose on purpose, so that a refusal can be shown to a person, and there is no code in front of it. So the rule for a client is: match error against the codes you know, and if it matches none of them, treat it as text to display rather than as an unknown code to fail on.
reason is always prose. Never switch on it. It is written for a person, and its wording will change.
These are the codes the bridge itself produces. They are stable, and existing ones will not be renamed.
| Code | What it means | What a client should do |
|---|---|---|
bad json | the line was not one complete JSON object | fix the serialiser. The reply carries "id":"0" because the id could not be read either |
unknown action | no such action in this build | the reason lists the actions it does have |
unauthorized | remote client that has not sent auth | send auth first |
remote_disabled | remote client, and the owner has configured no token | nothing to retry. Tell the owner to set one |
bad_token | wrong token | do not retry in a loop, it is rate limited |
rate_limited | too many auth attempts too fast | wait a second |
busy | the bridge is already holding 1024 unanswered requests | send it again shortly. Usually means your client is outrunning the server |
line_too_long | over 64 KB with no newline in it | the connection is closed after this reply. Split the payload |
too_many_clients | 24 connections already open | reuse a connection instead of opening one per request |
tampered | the mod DLL does not match its signature | nothing to retry, the file is not the one that was shipped |
unknown_module | no module with that id | the modules action lists every id |
unknown_command | the module refused and gave no reason | either the verb does not exist or the module recorded no reason, and from outside those look the same |
no_data | a module report came back empty with no reason | as above, for reads |
missing_config | module_config with no config object | read modules first, change what you need, send the whole set |
missing_command, missing item, missing text, missing command | a required field was absent | the reason names it |
missing_xy, bad_xy, bad_xyz | coordinates absent, not numbers, or not usable | send all of them, as JSON numbers |
no_ground | nothing solid in that column | the place may be outside the world or its level may not be loaded |
no ground below that height | the same trace, but you passed a fromZ | fromZ may already be below the floor. Note that this action answers under two different codes depending on that field |
no_executor | nobody online to run the command through | wait for a player, or ask the owner about allowNoExecutor |
player not ready | that Steam ID is not on the server, or has not spawned in | retry later |
console_failed | the console entry point could not be reached | says nothing about the command itself |
Four of these arrive with "id":"0" and are not a reply to any request you sent, because they come from the connection layer rather than the dispatcher: line_too_long, busy, too_many_clients and the unauthorized sent when the ten second grace period expires. A client that only ever looks up replies in a pending-request map will drop them silently. Handle an id you do not recognise by logging it.
line_too_long, too_many_clients and the timeout unauthorized are each followed by the connection closing.
Subscriptions and events#
The bridge pushes events, and you have to ask for them. A connection that has not subscribed receives replies only.
{"id":"9","action":"subscribe","events":["player_join","player_leave","chat_command"]}
{"id":"9","ok":true,"result":{"subscribed":["player_join","player_leave","chat_command"]}}
Compare what came back with what you asked for. A name the build does not know is dropped silently rather than refused, so the answer is the list of what is actually on. That is also how a client discovers, on an older or newer build, which of the events it wants exist.
Subscriptions are per connection. Reconnect and you subscribe again. There is currently no way to unsubscribe: the flags are only ever turned on, and the way to stop receiving events is to close the connection. If your client wants to pause a feed, do it on your side.
player_join and player_leave#
{"event":"player_join","steamid":"7656119...","name":"Nikolka"}
{"event":"player_leave","steamid":"7656119...","name":""}
Both come from a poll that runs about once a second, so they are not instantaneous and they carry no timestamp.
player_leave is deliberately late. A player's controller can drop out of the ready set for a moment during a respawn or a transient game state, which naively looks like a leave and an immediate rejoin. A leave is only reported after five consecutive misses, so roughly five seconds after the player really went. Build for that: it is the difference between a welcome message firing once and firing every time somebody respawns.
player_leave carries an empty name. The player is gone, so there is nothing left to read it off. Keep your own map from the join event if you need the name.
chat_command#
{"event":"chat_command","steamid":"7656119...","name":"Nikolka","channel":2,"text":"/kit starter"}
This one has a prerequisite: a prefix must be set, either as commandPrefix in the config or at runtime with setcmdprefix.
{"id":"10","action":"setcmdprefix","prefix":"/"}
An empty prefix turns the intercept off. With a prefix set, a player's chat line that starts with it (the prefix at the very start, with a non-space immediately after) is removed from chat entirely, so nobody sees it, including the sender, and forwarded to every subscribed connection exactly once. Repeats of the same line within a second and a half are treated as one.
text includes the prefix. channel is the numeric chat channel the player typed in.
Since the line never reaches chat, the player sees nothing at all unless you answer them. Send a chat with targets set to that one Steam ID.
The outbound backlog#
Every connection has an outbound queue capped at 4096 messages, and once it is full the oldest is dropped. That bound exists so a subscriber that stops reading cannot grow the game server's memory, and the consequence is worth designing around: a slow reader loses messages, and the loss is silent, and it is not only events that can be dropped. Read continuously, and do the work somewhere other than the socket thread.
A write that stalls for five seconds drops the connection.
Limits, timeouts and concurrency#
There is one game thread, and everything runs on it. Requests are queued off the socket threads and executed on the game thread, which is what makes them safe. It is also the whole reason the limits below exist: a client that outruns them does not slow itself down, it slows the server down for the players on it.
| Limit | Value | What happens |
|---|---|---|
| Connections | 24 | further connects are refused with too_many_clients and closed |
| Queued requests | 1024 | further requests answer busy |
| Line length | 64 KB with no newline | line_too_long, connection closed |
| JSON nesting | 64 levels | refused as bad json |
| Requests per frame | 16 | the rest run on the next frame |
| Heavy work per frame | maxHeavyPerTick, 2 | the pass stops and the rest run on later frames |
| Rate | ratePerSec 15, rateBurst 30 cost units | over budget requests are requeued, not refused |
| Auth grace | 10 seconds | the connection is closed |
| Outbound write | 5 seconds | the connection is dropped |
| Items per spawn | maxSpawnCount, 50 | clamped, not refused |
| Gap between spawns | spawnMinGapMs, 350 ms | the spawn waits, it is not refused |
The cost accounting is worth understanding, because the way a request is throttled decides whether your client sees anything at all:
cmdcosts 1.batchcosts one per command in it.chat,console,groundz,tracediag,list,players,whereandnamecost 1 each.module_dataandmodule_commandare not charged up front. They are timed, and charged against the frame's heavy budget only if the call really was expensive. A burst of cheap module reads runs sixteen deep and costs nothing; a burst of expensive ones ends the frame after two.- Spawns are paced by the gap and the frame budget rather than by the rate bucket.
Being over budget is not an error. The request is put back at the end of the queue and runs on a later frame. You get no reply until it runs, and no notification that it was deferred. So:
Your client needs its own timeout. The bridge will not time a request out for you, and a request that has been requeued behind a slow one can legitimately take a second or more. Something in the range of ten to thirty seconds is sane; treat an expiry as "unknown", not as "it did not happen", because a deferred request will still run.
On concurrency: you may pipeline as many requests as you like down one connection, and you should. Opening more connections does not buy parallelism, because everything ends up in the same queue on the same thread; it only spends the connection budget. One connection, requests written as you need them, replies matched by id, is the shape the protocol is built for.
The module vocabulary#
Four actions cover everything the bridge's 44 modules can do, and this is where a real client spends its time.
Every module ships switched off. Installing the bridge changes nothing about a server until an owner turns something on. A module that is off refuses its own verbs and says which setting to turn on, so a refusal here is usually configuration rather than a bug.
modules#
Ask the socket what this build has.
{"id":"11","action":"modules"}
The result is an array, one entry per module:
[{"id":"zones","name":"Zones","enabled":false,"config":{},"schema":{"...":"..."},"status":{"...":"..."}}]
configis what is stored,statusis what the module is doing. When those two disagree it means a setting was not understood.schemadescribes every setting: its type, its legal range or choices, its default and the explanation a panel would show. It comes from the module itself, so it always describes the build you are talking to. This is what a client should build its settings UI from rather than hard coding anything.configErrorsappears only when a setting's text could not be read, and names the key, the value and what was wanted.
This reply is large. It carries the full schema of every module, so fetch it when you need it rather than on a timer.
module_data and module_command#
A read and a write, and both name the module and one string.
{"id":"12","action":"module_data","module":"zones","what":"zones"}
{"id":"13","action":"module_command","module":"zones","what":"move:Outpost A0:-604800:12000"}
The what string is the vocabulary, and it is published in full on SSA Bridge module reference: every read and every verb, per module, with how many colon separated fields each verb takes. That page is generated from the mod, so it describes exactly one build.
What each field means is not in that table, and that is on purpose. Send a verb wrong and the module answers with a sentence naming what it wanted. That refusal is the documentation for the arguments, and it is written to be shown to a person.
A successful module_command:
{"id":"13","ok":true,"result":{"accepted":true,"changed":true,"note":"the countdown is now 600.0 s, was 0.0 s","reach":{"wrote":1,"known":1,"replicated":1,"persists":0,"pushed":1,"atAddress":0}}}
Three optional keys, and they answer questions accepted cannot:
changedis what a program reads. A tank that was filled and one that was already full both answeraccepted: true; onlychangedseparates them. An absentchangedmeans the module does not answer that question, never that nothing changed.noteis the same answer in words, for a person.reachanswers the two questions a successful write does not: will a player see it, and will it survive a restart. They are counts rather than booleans, because a command writing three fields of which two replicate has no honest yes or no.wrotelarger thanknownmeans a field name did not resolve and that write went nowhere. The capability page reads the six counts field by field.
module_data replies the same way. Its payload fills result, and changed, note and reach ride beside it, because two of the modules do their writing through the read path.
On a refusal after a partial write, reach is present on the failure too, which is the one place a caller most needs it: some fields moved and the command still refused.
module_config#
{"id":"14","action":"module_config","module":"zones","config":{"enabled":true,"maxRadius":2000}}
This replaces a module's settings wholesale. It does not merge. Read modules first, change what you need in the object you got back, and send the whole set. Sending only the key you changed resets everything else to nothing.
An empty config object is refused by name, because it would delete every setting the module has and the reply would be indistinguishable from a successful write.
The reply is the full modules array again, so you can see the result without a second call.
A minimal client, end to end#
connect to 127.0.0.1:27717
(remote only) send {"id":"a","action":"auth","token":"..."} and wait for ok
send {"id":"b","action":"status"} and check the version
send {"id":"c","action":"subscribe","events":["player_join","player_leave"]}
loop:
read one line, parse it
if it has "event" -> dispatch to a handler, and never block the reader
if it has "id" -> resolve the pending request, or log it if unknown
ignore keys you do not recognise
Everything else is module_data, module_command and the occasional cmd or chat.
Proposed: a protocol version#
This is a proposal, not something the bridge answers today. It is here so a client author can build for it now, and so the rule below is public rather than implicit.
The build version (2.19.3 and so on) moves on every rebuild, including rebuilds that change nothing a client can see, so it cannot be compiled against: a client cannot tell "the mod was rebuilt" from "the wire changed". The proposal is a separate integer, changed only when the wire changes.
{"id":"1","ok":true,"result":{"version":"2.19.3","protocol":1,"protocolMin":1,"port":27717,"...":"..."}}
protocolis what this build speaks.protocolMinis the oldest it still accepts, so a build can support more than one and say so.- Both would also go into
SSABridge.status.json, so a client on the same machine can read them before it connects. - An absent
protocolmeans protocol 1, which keeps every build that exists today inside the scheme without changing any of them.
status is the right place for it: it is the one action available before authentication.
What counts as breaking, and what does not#
Additive, the number does not move:
- adding an action
- adding an optional field to a request, with a default that preserves the old behaviour
- adding a key to a result object, or beside it
- adding an event type
- adding a module, a module setting, a
module_datareport or amodule_commandverb - rewording a
reason, anoteor any other prose - adding a new error code for a case that previously had none
Breaking, the number goes up:
- renaming or removing an action, a request field, a result key or an event
- changing the type, the unit or the meaning of an existing field
- changing what an existing error code means, or removing one
- making an optional field required, or removing a default
- changing the framing, or the authentication handshake
Adding a key is additive, renaming one is breaking. A client that ignores what it does not recognise is unaffected by everything in the first list.
The module vocabulary is not part of the protocol version. Which verbs exist is data, not wire shape, and it changes far more often. Ask modules at runtime, and treat a verb that has gone as a refusal to report rather than a version mismatch.
What a client should do with the answer#
| It answers | Do |
|---|---|
a protocol you know | proceed |
a protocol higher than you know | proceed, ignoring unknown keys, events and error codes. Every addition is additive by the rule above, so a newer build stays usable by an older client. This is the case that makes the scheme worth having |
a protocol lower than your minimum | refuse, and tell the user which bridge version they need. Do not attempt to speak an older shape |
no protocol key at all | treat it as 1 |
And the rule that makes all of it true, whether or not this ever ships: ignore what you do not recognise, and never parse prose.
Building something on this? The module reference is the vocabulary, and the standalone bridge page covers installing and configuring it.
