LABERTY Privacy Terms Help Center

Laberty Bridge Protocol v1

The promise: a bridge installed today keeps working forever.

The print bridge runs on the customer's own machine — a NAS, a mini PC, a Raspberry Pi, a desktop. Laberty cannot reach in and update it, and nobody should have to. So this document freezes the conversation between the two.

Everything below is a contract. Within v1:

  • A field is never removed.
  • A field is never renamed.
  • A field never changes type, and never changes meaning.
  • New things arrive only as new OPTIONAL fields. A bridge that ignores them keeps working exactly as it did.
  • Unknown fields are ignored, never refused. A newer bridge sending fields this Laberty has never heard of gets its job. A newer Laberty sending fields the bridge has never heard of is ignored by the bridge. Neither side ever answers 400 because the other knew something extra.
  • Behaviour that needs a newer bridge is skipped for old ones, silently and with no error anywhere.

If something ever genuinely cannot be expressed this way, it becomes v2 at /api/agent/v2/*, mounted alongside v1. v1 keeps answering. It is not switched off.

scripts/test-bridge-contract.mjs holds frozen fixtures of every shape below and fails if any v1 field disappears or changes type. Its failure message is deliberately blunt:

This breaks installed bridges. Add an optional field instead, or bump to v2.


Paths

PathWho
/api/agent/v1/*The contract. What an installed bridge calls
/api/agent/*The same handlers, unversioned, kept forever for bridges installed before v1 existed

They are literally the same code (src/routes/api.js mounts one agentApp at both), so they cannot drift.

A bridge asks for /v1 first. A Laberty too old to have it answers 404, and the bridge drops to the unversioned paths once and stays there. That is why a new bridge works against an old self-hosted Laberty.

Authentication is unchanged: Authorization: Bearer <bridge key>. A bridge key (lbk_…) may call /agent/* and nothing else. Keys never expire; access ends when one is revoked.


What the bridge says about itself

Three optional fields, sent on every call — as query parameters on a GET, and as body fields on a POST:

FieldTypeExample
bridgeVersionstring"1.2.0", or "1.2.0+bridge-v1.2.0" when the image was stamped at build
capabilitiesstring (comma separated) or array"brother-ql,zpl,ipp,cups,raw,media-query,snmp"
environmentJSON object{"container": true, "platform": "linux"}

A bridge that sends none of them is treated exactly as bridges were before any of this existed. That is the load-bearing rule: silence means "assume it can do everything", so no installed bridge loses a single job.

Capability names

NameWhat it drives
brother-qlBrother QL printers by IP
zplPDFs on Zebra / ZPL printers (needs poppler)
ippIPP printers — inkjets, lasers, AirPrint
cupsPrinters with a driver on the bridge machine
windowsPrinters plugged into a Windows computer, via the spooler
rawRaw ZPL and ESC/POS over a socket
media-queryAsking a printer what is loaded
snmpAsking a printer over SNMP

A name Laberty does not recognise is ignored, not refused — a newer bridge may well claim things this Laberty has never heard of.

What Laberty does with them

Laberty works out which capability a job needs and never hands a bridge a job it did not claim. Such a job stays queued with errorCode: "bridge_too_old" and a sentence saying an update would print it. It is not failed, nobody is emailed, and nothing is retried in a loop — it prints by itself the moment an updated bridge polls.

The rule, exactly:

  • Bridge reported nothing → assume the baseline: every capability above. Behaviour is identical to before this existed.
  • Bridge reported a list → only jobs in that list are handed over.

So a capability added after v1 must be claimed out loud, and an old bridge simply never receives that kind of job. That is what lets Laberty grow new printer support without breaking anything already installed.


GET /api/agent/v1/jobs

The poll. Claims at most one job.

Response — nothing to do:

{ "job": null }

Response — paused (from a Laberty device or Admin):

{ "job": null, "paused": true }

Response — a job:

{
  "job": {
    "id": 812,
    "kind": "pdf",
    "copies": 1,
    "printerId": 1,
    "detail": "Address labels for Rose (5)",
    "payloadB64": "JVBERi0xLjQK…",
    "target": {
      "id": 1,
      "name": "Kitchen QL-820NWB",
      "protocol": "agent-brother-ql",
      "host": "192.168.1.50",
      "port": 9100,
      "cupsQueue": null,
      "dpi": 300,
      "maxWidthIn": 2.4,
      "stock": { "kind": "die-cut", "widthIn": 3.5, "heightIn": 1.4, "sheet": null },
      "model": "QL-820NWB",
      "label": "62"
    }
  },
  "mediaProbes": [
    { "printerId": 1, "target": { "…": "the same shape" }, "asked": true }
  ]
}

Job fields

FieldTypeGuarantee
idnumberThe job id. Used in the report back
kindstringpdf | zpl | escpos | raw. A bridge must refuse any other value with unsupported_format and carry on
copiesnumber≥ 1
printerIdnumber | null
detailstring | nullFor logs and the job name
payloadB64stringBase64. What to print
targetobjectWhere to send it

Target fields

FieldTypeNotes
idnumber
namestring | nullFor messages a person reads
protocolstringagent-brother-ql | agent-zpl | agent-ipp | agent-escpos | agent-cups | agent-windows. An unknown value must be refused with unsupported_format, never guessed at
hoststring | nullIP or hostname on the bridge's network
portnumber | nullDefaults: 9100 raw, 631 IPP
cupsQueuestring | nullQueue name for driver printers
dpinumber | nullFor rasterising a PDF to ZPL
maxWidthInnumber | nullPrint head width
stockobject | null{ kind, widthIn, heightIn, sheet } — what is loaded
modelstring | nulle.g. QL-820NWB, for Brother raster
labelstring | nullBrother label id, e.g. 62, 29x90
windowsPrinterstring | nullOptional. Present for agent-windows
modelProtocolstring | nullOptional. Present for agent-windows
labelFromstring | nullOptional. Why that Brother label was chosen

mediaProbes is optional and may be absent. A bridge without media-query ignores it entirely and nothing anywhere reports an error.


POST /api/agent/v1/jobs/{id}/done

Always sent — success or failure. A bridge that goes quiet leaves a job claimed forever and the queue looks stuck for no visible reason.

{ "ok": true }
{
  "ok": false,
  "error": "Wrong labels in \"Kitchen QL-820NWB\": it says it has 62mm continuous",
  "code": "stock_mismatch",
  "loadedMedia": { "source": "escp", "widthMm": 62, "lengthMm": 0, "dieCut": false }
}
FieldTypeRequired
okbooleanyes. In a JSON object, anything other than false is success
errorstringno. Trimmed to 500 characters
codestringno. One of the codes below; anything else is stored as no code
loadedMediaobject | nullno. What the printer said is in it
bridgeVersion, capabilities, environmentno

Response: { "ok": true }

A body that is not a JSON object (unparseable, empty, an array) is "unknown", never success: Laberty answers 400 { "ok": false, "code": "bad_body" } and leaves the job exactly as it was.

Delivery is at-least-once; the report is idempotent

A job can reach a bridge more than once: if its report never arrives, Laberty hands the job out again after 15 minutes. So:

  • Report success separately from printing, and retry the report. Once the printer has the labels, a failed …/done call is never a print failure: retry it (backoff, network errors and 5xx only; a 4xx is an answer) and do not send ok: false for it.
  • Remember the job ids you have printed (a bounded list; on disk if you have somewhere to keep it) and, when a job you have already printed is handed to you again, report it done without printing it again.
  • Laberty treats the report as idempotent. Only a job that is still in flight (claimed, or queued after the 15-minute requeue) is moved by a report. A report for a job that is already done, failed, cancelled or held, or for a job that is not this workspace's, is answered 200 { "ok": true, "ignored": true } and changes nothing (and sends no failure email). It is never a 404, which a bridge might read as "this Laberty has no v1 paths".
  • pollSeconds is clamped to 2 to 30: a member's print waits for the next poll and Laberty gives up on an unpicked job after 45 seconds.

Error codes

CodeMeaning
stock_mismatchThe printer has different labels. Laberty holds the job and emails the admins
no_mediaOut of labels or paper
printer_errorNeeds a person: jam, cover open, error light
unreachableOff, wrong address, or a different network
no_addressNo IP set in Laberty
unsupported_formatThis bridge cannot print that. Also the correct answer for an unknown kind or protocol

Any other value is accepted and recorded as "no code" — never a 400.


GET /api/agent/v1/config

Everything the bridge should be doing, so the installed copy holds only the URL, the key, and a cache of this. A bridge that never reaches this endpoint runs perfectly well on its own built-in defaults, which are the same numbers.

{
  "configVersion": 1,
  "pollSeconds": 5,
  "requestTimeoutSeconds": 30,
  "jobTimeoutSeconds": 180,
  "heartbeatSeconds": 60,
  "configRefreshSeconds": 300,
  "retry": { "initialSeconds": 5, "maxSeconds": 300, "factor": 2, "jitter": 0.2 },
  "logLevel": "info",
  "features": {
    "probeMedia": true,
    "probeOrder": ["ipp", "snmp", "escp"],
    "snmp": true,
    "reportPrinters": true,
    "maxProbesPerPoll": 4
  },
  "minBridgeVersion": "1.0.0"
}

Rules that make this safe to hand to a machine nobody can reach:

  • Every value is clamped on both sides — in Laberty (src/lib/bridge-config.js) and again in the bridge (agent/server-config.mjs). A bad config cannot tell a bridge to poll every 0 seconds or wait forever on a socket. The worst it can do is choose a different sane number.
  • The bridge caches the last good copy on disk and uses it after a restart when Laberty is unreachable.
  • An answer that is not a config is ignored. A captive portal, a proxy error page or an HTML login screen is never mistaken for settings.
  • 404 is normal — it means a Laberty older than this endpoint. The bridge keeps its defaults and says nothing alarming.
  • minBridgeVersion is informational and never enforced. Refusing an old bridge is exactly the breakage this contract exists to prevent.

GET /api/agent/v1/status

Also callable with a Printing-scoped key, so your own tools can check the bridge is alive before queueing.

{
  "queued": 0, "claimed": 0, "done": 214, "failed": 2,
  "lastAgentSeenAt": "2026-09-15 18:02:14",
  "agentLikelyOnline": true,
  "limits": {
    "jobsPerMinute": 60, "fairUseJobsPerMonth": 10000, "jobsThisMonth": 214,
    "apiActionsPerMonth": 20, "apiActionsThisMonth": 12, "metered": false
  }
}

POST /api/agent/v1/printers

The printers the bridge's computer can print to, so Laberty's printer setup can offer a list. Bridge keys only.

{
  "computer": "FAMILY-PC",
  "platform": "windows",
  "printers": [
    { "name": "Brother QL-800", "driver": "Brother QL-800", "port": "USB001", "default": false, "status": "ready" }
  ]
}

Response: { "ok": true, "computer": "FAMILY-PC", "printers": 1 }

404 means a Laberty that does not take printer lists; the bridge stops offering and carries on.


POST /api/agent/v1/printers/{id}/media

What a printer says is loaded, with no job involved. Bridge keys only — a Full access key is refused with 403, because only the thing on the network can know this.

{
  "media": {
    "ok": true,
    "source": "ipp",
    "mediaName": "om_62x100mm_62x100mm",
    "widthMm": 62, "lengthMm": 100, "dieCut": true,
    "printerState": "idle",
    "stateReasons": [],
    "raw": "media-ready: om_62x100mm_62x100mm"
  }
}

source is ipp, snmp or escp. Send {"media": null, "error": "it did not answer"} when nothing replied.

Response:

{
  "ok": true,
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "reported": "62 x 100mm die-cut",
  "matched": { "id": 2, "name": "Shipping (1202)" },
  "confirmed": true
}

matched is null when the report matches no label stock, and the recorded roll is left alone. 503 means migration 030 has not been applied.


What the bridge must do to hold up its end

  1. Never crash out of the loop. Every job is wrapped; a failure in one job never stops the next. A crash in the courtesy probes never looks like a failed poll.
  2. Always report. Success or failure, …/done is always called.
  3. Refuse what it does not understand. An unknown kind or protocol is unsupported_format with a clear message — never bytes sprayed at a printer on the off chance.
  4. Ignore what it does not recognise. Unknown response fields, unknown config keys: ignored, never fatal.
  5. Back off on network failure, with a cap, and keep the heartbeat honest so "last seen" means something.
  6. Report its version so a person can be told which bridge is installed.

Things deliberately NOT promised

  • The target object gaining fields. It will. They are optional; ignore what you do not recognise.
  • mediaProbes being present. It is optional.
  • The unversioned /api/agent/* paths. They are kept forever for bridges already installed, but new work should use /v1.
  • :latest on the image. It may one day point at a v2 that speaks a different protocol. Pin :1, which never changes meaning.

Updates and commands (additive to v1)

A bridge that runs through the launcher (agent/launcher.mjs) keeps itself up to date and can be told what to do. These endpoints are new and optional: a Laberty without them answers 404, and the launcher just keeps running what it has. Bridge keys only.

GET /api/agent/v1/control

Called about once a minute. Query parameters (all optional): launcher, protocol, version (the release running), base (the installed copy's version), platform, container, healthy, bad (releases it rejected, comma-separated), lastProblem.

{
  "update": { "version": "1.3.1", "files": 16 },
  "commands": [{ "id": "17", "type": "restart", "args": {} }],
  "checkSeconds": 60
}
  • update is null when there is nothing newer, when the bridge has already rejected that version, or when the release is on hold.
  • commands are ping, restart, update, rollback, forgive, diagnose. An unknown type is answered ok: false and ignored.
  • A delivered command that is not answered is offered again after five minutes; the launcher remembers the ids it has handled.

GET /api/agent/v1/release/{version}

The release itself: { version, launcher, deps, signature, files: { name: text } }.

The launcher installs it only if the Ed25519 signature verifies against a key compiled into the launcher, over a manifest rebuilt from the files actually received (so a changed, added or missing file fails); every path is a plain relative name; it does not try to replace launcher.mjs; and it does not need a newer launcher or different npm packages than are installed. The private key is never on the Laberty server.

After switching, the new release must reach Laberty within three minutes and must not crash three times in a row, or the launcher marks it bad and goes back to the last good release by itself.

POST /api/agent/v1/control/ack

{ "id": "17", "ok": true, "result": "restarting the bridge", ...launcher state } -> { "ok": true }. A body with no id is 400.

Switching it off

Set BRIDGE_RELEASE_HOLD=1 on the Worker: /control offers no update and /release answers 404. Commands still flow.


AirPrint sharing (additive to v1)

A bridge on a workspace that has AirPrint sharing switched on can appear on its local network as an AirPrint printer, so phones and laptops print through it. Everything is optional; an older bridge ignores all of it.

  • Capability airprint (new, not in the v1 baseline).
  • GET /api/agent/v1/config gains features.airprint (boolean, false unless the workspace has it on and its plan or add-on allows it) and airprint: { port, printers: [{ id, name, location }] }: the printers an admin chose to share, and the port to listen on (default 8631). Nothing is listed when the feature is off.
  • POST /api/agent/v1/shared-print (bridge keys only) { printerId, jobName, userName, dataB64 } -> { ok: true, jobId }. Laberty queues the PDF for the real printer (source airprint) and logs it. Refused with 403 when the feature is off, 404 for a printer that is not shared (or not this workspace's), 415 for something that is not a PDF, 413 above 1.4 MB (one database row), 429 over the usual per-minute rate limit.

On the network the bridge answers mDNS (_ipp._tcp, subtype _universal) and speaks IPP at /ipp/print/<printer id> (Get-Printer-Attributes, Validate-Job, Print-Job, Get-Jobs, Get-Job-Attributes, Cancel-Job), PDF only. AirPrint has no sign-in: anyone on that network can print to a shared printer, which is why sharing is off by default and chosen per printer. Multicast DNS does not cross Docker's default network: run the bridge with host networking (network_mode: host) to use it.

Download the API reference (Markdown) · Contact support