Wire protocol
Speaking the protocol directly
This page is for one reader: someone whose language has no Bellhop library, who therefore has to speak the wire protocol themselves. Everything documented here is work the Node and Rails libraries already do. If either of those fits your stack, close this page: implementing it by hand buys you nothing.
These are wire protocols, not suggestions
The pairing exchange and the session protocol are spoken by the shipped Bellhop app, which
expects the paths, header names, and message shapes below. Implement them exactly.
Renaming /bellhop/claim or any message
type produces an integration that cannot pair.
Both sides ignore JSON fields and message types they do not recognise. That tolerance is
the only compatibility mechanism there is, so rely on it, and do not reject a message
because it carried something new. The one deliberate exception is the
options object of a print job, which is a closed set
for reasons the print options section explains.
Machine-readable schemas for every message are published at
https://bellhop.dev/protocol/v1/agent-to-server.json
and
https://bellhop.dev/protocol/v1/server-to-agent.json.
The credential
A JWT with an Ed25519 signature. Header
{"alg": "EdDSA", "kid": "<active key id>"}, and this
payload:
{
"iss": "bellhop.dev",
"aud": "bellhop-agent",
"sub": "42",
"app": "bh_pk_...",
"app_name": "Slaydate",
"serial": "56c2b7ba-7cce-4a7f-b967-a93a59f3ad25",
"srv": "deliver.example.com",
"iat": 1786059481,
"exp": 1817595481,
"ent": {
"scales": false,
"max_printers": 1,
"show_branding": true
}
}
| Claim | Meaning |
|---|---|
sub | The agent id, as a string, not a number. |
app | Your publishable key, identifying which app this agent serves. |
srv | The host from your mint request. The agent compares this byte for byte against the host it is paired with. |
serial | Unique to this mint. Changes on every activate and renew. |
exp | Long after iat. Renew shortly before it. |
ent.scales | Whether USB scales may be read. |
ent.max_printers | Printers this agent may use. null means unlimited. |
ent.show_branding | Whether the agent shows Bellhop branding alongside yours. |
Entitlements are read from your app record at the moment of minting, which is why a plan change takes effect at the next activation or renewal rather than instantly. There is nothing to invalidate and nothing to poll.
Treat the credential as sensitive. It is not a password, but it carries your entitlements,
and the srv binding is the only thing preventing its reuse
elsewhere.
Pairing
Pairing is how a credential reaches one specific machine. It is deep-link only: no token is ever displayed, copied, or typed. Your server mints a single-use claim token, wraps it in a link, and the Bellhop app trades it for a long-lived agent token plus the credential.
1. Mint a claim token and show the link
When an admin adds a location, create the agent on bellhop.dev, then mint a claim token alongside it. Store only its digest.
claim_token = SecureRandom.hex(32)
agent.update!(
claim_token_digest: Digest::SHA256.hexdigest(claim_token),
claim_expires_at: 15.minutes.from_now
)
# The raw token exists only inside this link.
"bellhop://pair?server=#{CGI.escape(public_origin)}&claim=#{CGI.escape(claim_token)}"
server is your canonical https origin, for example
https://deliver.example.com. Render the link as an
"Open in Bellhop" button, with the expiry shown, and offer a "New pairing link" action that
regenerates the digest and expiry. That is what a human uses for an expired link, and what
moves an agent to a different machine.
2. Answer the claim exchange
The Bellhop app appends this path to the server from the
link, so it must live at exactly /bellhop/claim. It takes
no session and no API key: the claim token is the credential. Rate limit it, ten requests
per minute is the reference setting.
Request
{ "claim_token": "<raw claim token>" }
Response 200
{
"agent_token": "<raw agent token>",
"agent_name": "Shipping Desk",
"app_name": "Deliver",
"accent_color": "#4F46E5",
"credential": "<Ed25519 JWT>",
"transports": [
{ "type": "websocket", "url": "wss://deliver.example.com/bellhop/socket" },
{ "type": "http", "url": "https://deliver.example.com/bellhop" }
]
}
app_name and
credential are required, and the app rejects the
response as malformed without them. accent_color is
optional. Take all three from the branding payload of the activation call, not from your
own configuration, so a rename on bellhop.dev propagates without a deploy.
transports is optional and lists where to connect, most
preferred first. Leave it out and the app assumes
wss://<your host>/bellhop/socket and
https://<your host>/bellhop, which is why a server
that mounts the conventional paths needs no configuration at all. Include it when you offer
only one transport, or when your socket lives on a different host from your application.
Offering both is worth doing: it is what rescues a shipping desk sitting behind a corporate
proxy that quietly breaks WebSocket upgrades.
3. Do the work in this order
The ordering matters more than anything else in this section. Activate against bellhop.dev before consuming the claim token, so a licensing failure leaves the link redeemable and the human simply retries it.
- Look the agent up by claim digest. Unknown or already consumed, 404
claim_invalid. Past its expiry, 410claim_expired. - Call
POST /api/v1/agents/:id/activate. If it fails for any reason, answer 502 with a JSON error, change no state, and leave the claim token unconsumed. - Store the returned credential and
expires_aton the agent, and refresh your cached branding from the response. - Generate an agent token, store its SHA-256 digest replacing any previous digest, and clear the claim fields.
- Answer with the payload above.
The raw agent token appears in exactly one response body and thereafter only as a connection parameter. Never render it, never log it, and store only the digest.
The agent connection
Once paired, the agent keeps a session open to your server. This is the path print jobs and
scale readings travel, and bellhop.dev is not on it. The messages are flat JSON objects,
encoded once, with a type field in both directions.
There are two transports. They carry the identical messages with identical meaning, so pick whichever your stack can host and the app adapts.
| Transport | What your server needs | Print latency |
|---|---|---|
WebSocket/bellhop/socket |
A process that stays up and can hold connections | Immediate |
HTTP/bellhop/sessions |
The ability to answer two ordinary routes | Immediate, or up to 3s if you cannot hold a request open |
Authentication
Every connection and every request carries the raw agent token as a bearer token. Digest it and match it against the stored digest, in constant time.
Authorization: Bearer <raw agent token> Bellhop-Protocol-Version: 1
Authenticate at connection time, not per message. On the WebSocket transport that means
refusing the upgrade with 401
before the handshake completes, which is what makes unpairing take effect immediately. The
token is never put in a query string, because query strings end up in access logs. The app
is native, not a browser, so it sends no Origin header and
you must not require one.
The handshake
The agent sends hello first, always. You answer with
ready, and send nothing before it.
agent → { "type": "hello", "protocol_version": 1, "agent_version": "1.2.0",
"platform": "macos", "session_id": "5b1f9c2e-...",
"capabilities": ["print:zpl", "print:raw", "print:pdf", "print:gif", "scale"],
"printers": [
{ "id": "Zebra_ZP450", "name": "Zebra ZP450",
"capabilities": { "papers": ["w288h360"], "default_paper": "w288h360",
"dpi": [203], "default_dpi": 203,
"duplex": false, "color": false } },
{ "id": "Office_HP", "name": "Office HP LaserJet",
"capabilities": { "papers": ["Letter", "A4"], "default_paper": "Letter",
"bins": ["Auto", "Tray1"], "default_bin": "Auto",
"duplex": true, "color": true } }
],
"default_printers": { "label": "Zebra_ZP450", "document": "Office_HP" } }
server → { "type": "ready", "protocol_version": 1, "app_name": "Deliver",
"accent_color": "#4F46E5", "credential": "<Ed25519 JWT>",
"heartbeat_seconds": 20 }
Persist what hello tells you and stamp a last-seen time.
That is what powers an "agent online, printing to Zebra ZP450" admin row.
capabilities is authoritative: never send a
format that has no matching
print:<format> entry.
"scale" appears only when the operator shared the scale
and the credential grants it.
printers is the inventory: exactly the printers the
operator has shared with this pairing, each with a stable
id, a display name, and
a capabilities object saying what the queue can do,
as papers, bins,
dpi, duplex, and
color, with a default for each list. The keywords are
the driver's own, untranslated, and a print option must send one back exactly as
reported. The id is what a job names to target that
printer; the name is for admin screens only.
default_printers maps the
label and document
roles to the id a job lands on when it names no printer of its own. An empty
capabilities object means the agent could not read that queue's driver, not that
anything goes: an option aimed at such a printer fails rather than being guessed at.
A later hello can arrive at any time, when the operator
changes a printer or shares the scale. Treat it as a complete replacement of the previous
one rather than ignoring it.
Always include the credential in ready
It costs nothing and it is what makes renewal invisible. Your renewal job stores a fresh credential, the agent adopts it on its next reconnect, and nobody touches the machine.
After ready, send every print job for this agent that is
still unacknowledged. Redelivery is safe, and doing it here is what makes a machine that
was asleep overnight catch up on its own.
Agent to server
| Message | Payload and meaning |
|---|---|
hello |
As above. Sent on every session and whenever the agent's advertised state changes. |
ack |
id (echoing the print's id),
status (printed
or failed),
error (the sentence a person reads), and
error_code, a machine-readable class such as
unknown_printer or
unsupported_value, so you branch on codes
rather than on English. Transition the job row and record both. Make this
idempotent, because you can receive the same ack twice.
printed means the document reached the operating
system without error, not that paper came out.
|
weight |
grams, stable.
Only stable, non-zero readings arrive, already debounced, and only from pairings
sharing the scale. Rebroadcast to your own browser-facing realtime layer.
|
event |
code, message,
at. Informational agent state that is not tied to a
job, currently scale_attached and
scale_detached. Ignoring these entirely is fine.
|
ping / pong |
Keepalive. See below. |
Server to agent
{ "type": "print", "id": "job_7f2a1c", "kind": "label",
"format": "zpl", "data": "<base64 of the document>" }
{ "type": "print", "id": "job_7f2a1d", "kind": "packing_slip",
"format": "pdf", "printer": "Office_HP",
"options": { "copies": 2, "duplex": "long-edge" },
"url": "<https URL>" }
{ "type": "config", "app_name": "Deliver", "accent_color": "#4F46E5" }
{ "type": "credential", "credential": "<Ed25519 JWT>" }
| Message | When to send it |
|---|---|
print |
On job creation, plus every unacknowledged job when the agent sends
hello.
format is
zpl, raw,
pdf, or
gif.
printer and
options are optional: a job without them goes
to the default printer for its format and prints the way that printer prints by
default. See the two sections below.
kind is yours and opaque to the agent, so
label, nametag,
or anything else is fine.
|
config |
Only when branding changes mid-session. Everything in it is already in
ready, so most servers never send this.
|
credential |
When your renewal job stores a fresh credential and you would rather not wait for the next reconnect. An agent that cannot verify one keeps the credential it has, so a bad push never breaks a working machine. |
close |
Optional, immediately before you disconnect, carrying the code below. Useful when your framework will not let you choose a WebSocket close code. |
Formats and routing
| Format | What the agent does with the bytes | Default role |
|---|---|---|
zpl |
Delivers them to the queue untouched. | label |
raw |
Delivers them to the queue untouched. | label |
pdf |
Renders the document through the operating system's print system. | document |
gif |
Renders one label image onto the target printer's media. | label |
zpl and raw are
delivered identically, and they are still two formats.
zpl is a claim about the content: these bytes are ZPL,
so an admin screen can say so and the queue they land on had better speak it.
raw claims nothing about the content and everything
about the treatment: no filter runs, nothing is prepended, nothing is appended, and no
encoding is touched. It is the escape hatch for ESC/POS receipt printers, EPL, and any
control language nobody has named yet. Its honest limit: the agent cannot tell one byte
stream from another, so raw bytes sent to the wrong queue print gibberish and ack
printed, because they were. Naming the right
printer is what catches that.
A job that names no printer routes by format, to the role shown above in the agent's
default_printers. When that role is unset, the job
fails with no_default_printer rather than landing on
the system default or the only printer that happens to exist: a label on the wrong
device is a wasted label. A job with a printer goes to
that printer instead. The value is the id of an entry
in the most recent hello, never the display name, and
naming anything else fails with unknown_printer. That
failure deliberately does not distinguish a printer that does not exist from one the
operator has chosen not to share.
Print options
options asks for something specific about how the job
prints. Every option is optional, and an absent one means whatever the target printer
does by default, which hello reports as
default_paper,
default_bin, and
default_dpi.
| Option | Applies to | Meaning |
|---|---|---|
copies |
every format | 1 to 100. The ceiling is a blast radius, not a technical limit: a loop that computes 10000 instead of 2 should meet a failed ack rather than an empty roll of labels. For zpl and raw the agent itself delivers the bytes that many times, untouched each time. |
paper, bin, dpi |
pdf, gif |
A value from the target's capabilities, sent back exactly as reported. Matching is string equality, never translation. |
color |
pdf, gif |
true for colour, false for greyscale. |
rotate |
pdf, gif |
0, 90, 180, or 270 degrees. Clockwise, always. |
fit |
pdf, gif |
Scale the document to the media. Defaults to true; false prints at natural size and clips. |
duplex |
pdf |
one-sided, long-edge, or short-edge. |
pages |
pdf |
A page range like 1-4,7. One-based, ascending, no spaces. |
collate |
pdf |
With more than one copy, 1,2,3,1,2,3 rather than 1,1,2,2,3,3. |
nup |
pdf |
Pages per sheet: 1, 2, 4, 6, 9, or 16. |
Options fail loudly
An option the agent does not recognise, one that does not apply to the job's format,
a malformed value, or a value the target printer cannot honor fails the whole job
before anything reaches the printer, as
unsupported_option,
invalid_option, or
unsupported_value in the ack. The agent never prints
a reduced version of what you asked for: half a request honored and acked
printed is worse than a clean failure.
This is the one place the ignore-unknown-fields rule does not apply. The table above
is the whole of version 1, the set is closed, and feature detection is sending a job
and reading the ack. A rejected job counts as completed in the agent's duplicate
ledger, so a corrected job needs a new id.
Sending a document
A print job carries its document one of two ways, and exactly one of them must be present.
data is base64 of the bytes, up to 50 MB decoded.
url is an https address the agent fetches, following at
most three redirects, with a 30 second timeout and a 50 MB ceiling.
Inline your ZPL. It is plain text and almost always under a kilobyte, and inlining it
removes the signed URL, its expiry, and a whole category of support problems. Use
url for PDFs, where object storage genuinely is the right
place for the bytes. The agent sends no credentials with that fetch, so the URL has to
carry its own authorisation. It does not need to be on your host: signed storage URLs are
expected.
id is an opaque string, unique per pairing, never reused.
Your primary key rendered as a string is ideal. The agent remembers the outcome of recent
jobs and answers a redelivered id with the original ack instead of printing a second label,
so re-sending whenever you are unsure is the correct instinct.
Keepalive
The agent sends { "type": "ping" } every
heartbeat_seconds. You must answer with
pong, echoing token when
one is present. This is the only mandatory message that is easy to forget, and skipping it
makes every agent drop and reconnect on a timer, which looks like a network fault and is
not.
if message["type"] == "ping"
send({ "type" => "pong", "token" => message["token"] })
end
Application-level ping is used rather than RFC 6455 ping frames because not every framework
exposes those to application code, and they do not exist at all on the HTTP transport. You
may also send your own ping, and should treat an agent as
offline after roughly three missed intervals with no message of any kind.
Ending a session
Some failures are worth retrying and some are not. Say which, either as a WebSocket close
code, as a close message, or as an HTTP status.
| Code | HTTP | Meaning | What the agent does |
|---|---|---|---|
4001 |
401 |
Token unknown, rotated, or revoked | Stops. Tells the operator to re-pair. |
4002 |
426 |
Protocol version not supported | Stops. Prompts for an app update. |
4003 |
410 |
Agent removed or deactivated | Stops. Tells the operator to re-pair. |
4004 |
Superseded by a newer session | Retries after a delay. | |
4005 |
404 |
This transport is not available here | Switches to the other transport. |
| anything else | 5xx |
A deploy, a restart, a network fault | Reconnects on capped exponential backoff. |
A server that signals none of this still works. The agent retries forever on backoff, which is the right response to an unexplained disconnect. It only means a re-paired agent keeps knocking instead of telling its operator what is wrong.
If you cannot host a WebSocket
Then do not. The HTTP transport is three ordinary routes and carries the same messages. It is the right choice on Vercel, on Lambda, on any serverless tier, in a stack with no WebSocket story, and anywhere you would simply rather not run a socket server. Your queue is a table, and any process can write to it.
POST /bellhop/sessions → body is the hello message
← { "session_id", "poll_seconds", "message": <ready> }
GET /bellhop/sessions/:id/messages?wait=25
← { "messages": [ ... ] }
POST /bellhop/sessions/:id/messages → { "messages": [ ... ] }
← { "messages": [ ... ] }
DELETE /bellhop/sessions/:id optional, a clean disconnect
The GET is a long poll: hold it open until a message is
queued for this agent or wait seconds elapse, then answer.
Return as soon as the first message is available rather than waiting for more. An empty
array is a normal answer. Answer 404
once a session has expired, and the agent opens a fresh one, which is a routine event and
not an error. Expire a session after roughly twice
poll_seconds with no request on it, and treat the agent as
offline at that point. That is where presence comes from on this transport.
The response to the POST may carry queued messages back,
which saves a round trip. Returning an empty array is always acceptable.
And if you cannot hold a request open either
Set poll_seconds to 0,
or just answer the poll immediately with an empty array. The agent notices that polls are
returning instantly and settles into a fixed three second interval instead of spinning. A
print job then arrives between 0 and 3 seconds after you create it, which nobody standing
at a label printer can perceive. The cost is roughly 1,200 requests per agent per hour,
which is nothing for a few agents and worth measuring if you have hundreds.
There is no tier below this, and that is deliberate rather than an oversight. Something has to answer an HTTP request on demand. If your application cannot, it cannot drive a printer either.
Verifying a credential
The Bellhop agent verifies credentials offline against a key set bundled at build time. Your server does not have to verify anything, since it received the credential directly over TLS from the issuer. Verifying anyway is cheap and catches configuration mistakes early, so it is worth doing once in a test.
- Read the
kidfrom the JWT header. - Fetch
/.well-known/bellhop-keys.jsonand find the entry with thatkid. - Base64-decode its
public_keyinto 32 raw bytes and build an Ed25519 public key. - Verify the signature, then check
iss,aud,exp, and thatsrvis the host you sent.
published = JSON.parse(Net::HTTP.get(URI("https://bellhop.dev/.well-known/bellhop-keys.json")))
kid = JWT.decode(token, nil, false).last["kid"]
entry = published["keys"].find { |key| key["kid"] == kid } or raise "unknown kid #{kid}"
public_key = OpenSSL::PKey.new_raw_public_key("ED25519", Base64.strict_decode64(entry["public_key"]))
payload, _header = JWT.decode(token, public_key, true,
algorithm: EdDSA.new,
verify_iss: true, iss: "bellhop.dev",
verify_aud: true, aud: "bellhop-agent")
The jwt gem only ships EdDSA through
rbnacl, which needs libsodium installed and is
deprecated upstream. OpenSSL has supported Ed25519 natively since 3.0, so the smaller
dependency-free path is a custom algorithm object:
class EdDSA
include JWT::JWA::SigningAlgorithm
def initialize = @alg = "EdDSA"
def sign(data:, signing_key:) = signing_key.sign(nil, data)
def verify(data:, signature:, verification_key:)
verification_key.verify(nil, signature, data)
rescue OpenSSL::PKey::PKeyError
false
end
end
Keys rotate. Retired public keys stay published until no unexpired credential references
them, so always select by kid rather than assuming a
single key, and cache the key set for minutes rather than months.
What to store
An agent record ties together the three layers: the remote id for licensing, the token digests for pairing, and the connection state the session maintains.
name string # "Shipping Desk"
bellhop_remote_id integer # id from POST /api/v1/agents, unique, null until registered
token_digest string # SHA-256 of the agent token, unique
claim_token_digest string # SHA-256 of the outstanding claim token
claim_expires_at datetime # 15 minutes out
credential text # the latest JWT, encrypted at rest
credential_expires_at datetime # copied from the response, so the renewal
# job can query without decoding JWTs
printers json # everything below is set from `hello`
capabilities json # e.g. ["print:zpl", "print:pdf", "scale"]
agent_version string
platform string
last_seen_at datetime # stamped on every message received
On the HTTP transport you also need a session row: an id you hand back, the agent it belongs to, a last-polled timestamp for expiry, and a queue of messages waiting to go out. That queue can be a column on the print job if print is all you push.
Store branding in a single cached row rather than per agent, refreshed from every
activation and renewal response: app_name,
accent_color,
show_bellhop_branding,
synced_at. That row is what the branding fields of
ready are built from.
Print jobs need kind,
format, a document reference, a
status of
pending / sent /
printed / failed, and an
error string.
Encrypt the credential and store only digests of both tokens. In Rails that is one line:
encrypts :credential.
Then a daily job renews anything inside the window and pushes the result to the agent, which keeps the fleet current without anyone thinking about it:
renew_ahead = 2.weeks # your own margin, not a protocol number; early is always safe
Agent
.where.not(bellhop_remote_id: nil)
.where(credential_expires_at: ..renew_ahead.from_now)
.find_each { |agent| RenewCredentialJob.perform_later(agent) }
On failure, log it and keep the credential you have. The window is wide and the job runs daily, so an agent only ever expires after a long run of failed attempts. That is the fuse: renewals are cheap and safe to repeat, so alert on a run of failures rather than on any single one.
A worked example
A minimal Ruby client
No gem required. It raises a typed error carrying the machine-readable code, so callers can
branch on agent_limit_reached without parsing sentences.
require "net/http"
require "json"
class Bellhop
BASE = URI("https://bellhop.dev")
class Error < StandardError
attr_reader :code, :status, :body
def initialize(code:, status:, body:)
@code, @status, @body = code, status, body
super(body["message"] || "Bellhop returned #{status}")
end
end
def initialize(secret_key:, server_host:)
@secret_key = secret_key
@server_host = server_host
end
def app = get("/api/v1/app")
def agents = get("/api/v1/agents").fetch("agents")
def create_agent(label) = post("/api/v1/agents", label: label)
def activate(id) = post("/api/v1/agents/#{id}/activate", server_host: @server_host)
def renew(id) = post("/api/v1/agents/#{id}/renew", server_host: @server_host)
def deactivate(id) = post("/api/v1/agents/#{id}/deactivate")
private
def get(path) = request(Net::HTTP::Get.new(path))
def post(path, **body) = request(Net::HTTP::Post.new(path), body)
def request(req, body = nil)
req["Authorization"] = "Bearer #{@secret_key}"
req["Accept"] = "application/json"
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
response = Net::HTTP.start(BASE.host, BASE.port, use_ssl: BASE.scheme == "https",
open_timeout: 5, read_timeout: 15) { |http| http.request(req) }
parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body)
return parsed if response.is_a?(Net::HTTPSuccess)
raise Error.new(code: parsed["error"], status: response.code.to_i, body: parsed)
end
end
Used from your own code:
bellhop = Bellhop.new(
secret_key: Rails.application.credentials.dig(:bellhop, :secret_key),
server_host: ENV.fetch("PUBLIC_HOST") # "deliver.example.com"
)
agent = bellhop.create_agent(location.name)
location.update!(bellhop_agent_id: agent["id"], bellhop_status: agent["status"])
# Later, when the agent opens the pairing link and posts to /bellhop/claim:
credential = bellhop.activate(agent.bellhop_remote_id) # before consuming the claim
agent.update!(
credential: credential["credential"],
credential_expires_at: credential["expires_at"],
token_digest: Digest::SHA256.hexdigest(agent_token = SecureRandom.hex(32)),
claim_token_digest: nil,
claim_expires_at: nil
)
render json: {
agent_token: agent_token,
agent_name: agent.name,
app_name: credential["branding"]["app_name"],
accent_color: credential["branding"]["accent_color"],
credential: credential["credential"],
transports: [
{ type: "websocket", url: "wss://#{PUBLIC_HOST}/bellhop/socket" },
{ type: "http", url: "https://#{PUBLIC_HOST}/bellhop" }
]
}
rescue Bellhop::Error => e
case e.code
when "agent_limit_reached" then notify_admin_of_cap(e.body["limit"])
when "payment_required" then notify_owner_of_billing
else raise
end
Rules worth repeating
These are the ones a hand-written client gets wrong. The rules about keys, agent creation, and renewal live on the integration guide, because they apply however you connect.
- Neither raw token is ever displayed, typed, or logged. Store digests, and put the claim token only inside the link.
server_hostcomes from configuration, never from a request header. It must match the host in your pairing links byte for byte, including a non-default port. It is not the transport host: your socket can live anywhere.- Activate against bellhop.dev before consuming the claim token, so a failure leaves the link redeemable.
- Re-pairing rotates the agent token. Close the old machine's session with 4001 when it does.
- Select verification keys by
kid. Never assume there is exactly one. - Answer
pingwithpong. It is the one mandatory message that is easy to miss. - Print job ids are opaque strings, unique per pairing, never reused. Re-send unacknowledged jobs on every
hello; the agent suppresses duplicates. - Add fields freely; never rename or repurpose one. Both sides ignore what they do not recognize, everywhere except inside a print's
options, where an unknown key fails the job by design. - Target printers by
idfrom the latesthello, never by display name, and send capability keywords back exactly as the agent reported them.