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
  }
}
ClaimMeaning
subThe agent id, as a string, not a number.
appYour publishable key, identifying which app this agent serves.
srvThe host from your mint request. The agent compares this byte for byte against the host it is paired with.
serialUnique to this mint. Changes on every activate and renew.
expLong after iat. Renew shortly before it.
ent.scalesWhether USB scales may be read.
ent.max_printersPrinters this agent may use. null means unlimited.
ent.show_brandingWhether 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

POST /bellhop/claim

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.

  1. Look the agent up by claim digest. Unknown or already consumed, 404 claim_invalid. Past its expiry, 410 claim_expired.
  2. 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.
  3. Store the returned credential and expires_at on the agent, and refresh your cached branding from the response.
  4. Generate an agent token, store its SHA-256 digest replacing any previous digest, and clear the claim fields.
  5. Answer with the payload above.
Replacing the digest is what moves an agent between machines: the previously paired machine's token stops authenticating. Close its live session with code 4001 at the same time, otherwise it keeps working until it happens to reconnect.

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.

TransportWhat your server needsPrint 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

MessagePayload 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>" }
MessageWhen 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

FormatWhat the agent does with the bytesDefault 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.

OptionApplies toMeaning
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.

CodeHTTPMeaningWhat 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.

  1. Read the kid from the JWT header.
  2. Fetch /.well-known/bellhop-keys.json and find the entry with that kid.
  3. Base64-decode its public_key into 32 raw bytes and build an Ed25519 public key.
  4. Verify the signature, then check iss, aud, exp, and that srv is 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_host comes 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 ping with pong. 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 id from the latest hello, never by display name, and send capability keywords back exactly as the agent reported them.