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
400because 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
| Path | Who |
|---|---|
/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:
| Field | Type | Example |
|---|---|---|
bridgeVersion | string | "1.2.0", or "1.2.0+bridge-v1.2.0" when the image was stamped at build |
capabilities | string (comma separated) or array | "brother-ql,zpl,ipp,cups,raw,media-query,snmp" |
environment | JSON 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
| Name | What it drives |
|---|---|
brother-ql | Brother QL printers by IP |
zpl | PDFs on Zebra / ZPL printers (needs poppler) |
ipp | IPP printers — inkjets, lasers, AirPrint |
cups | Printers with a driver on the bridge machine |
windows | Printers plugged into a Windows computer, via the spooler |
raw | Raw ZPL and ESC/POS over a socket |
media-query | Asking a printer what is loaded |
snmp | Asking 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
| Field | Type | Guarantee |
|---|---|---|
id | number | The job id. Used in the report back |
kind | string | pdf | zpl | escpos | raw. A bridge must refuse any other value with unsupported_format and carry on |
copies | number | ≥ 1 |
printerId | number | null | |
detail | string | null | For logs and the job name |
payloadB64 | string | Base64. What to print |
target | object | Where to send it |
Target fields
| Field | Type | Notes |
|---|---|---|
id | number | |
name | string | null | For messages a person reads |
protocol | string | agent-brother-ql | agent-zpl | agent-ipp | agent-escpos | agent-cups | agent-windows. An unknown value must be refused with unsupported_format, never guessed at |
host | string | null | IP or hostname on the bridge's network |
port | number | null | Defaults: 9100 raw, 631 IPP |
cupsQueue | string | null | Queue name for driver printers |
dpi | number | null | For rasterising a PDF to ZPL |
maxWidthIn | number | null | Print head width |
stock | object | null | { kind, widthIn, heightIn, sheet } — what is loaded |
model | string | null | e.g. QL-820NWB, for Brother raster |
label | string | null | Brother label id, e.g. 62, 29x90 |
windowsPrinter | string | null | Optional. Present for agent-windows |
modelProtocol | string | null | Optional. Present for agent-windows |
labelFrom | string | null | Optional. 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 }
}
| Field | Type | Required |
|---|---|---|
ok | boolean | yes. In a JSON object, anything other than false is success |
error | string | no. Trimmed to 500 characters |
code | string | no. One of the codes below; anything else is stored as no code |
loadedMedia | object | null | no. What the printer said is in it |
bridgeVersion, capabilities, environment | no |
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
…/donecall is never a print failure: retry it (backoff, network errors and 5xx only; a 4xx is an answer) and do not sendok: falsefor 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, orqueuedafter the 15-minute requeue) is moved by a report. A report for a job that is alreadydone,failed,cancelledorheld, or for a job that is not this workspace's, is answered200 { "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". pollSecondsis 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
| Code | Meaning |
|---|---|
stock_mismatch | The printer has different labels. Laberty holds the job and emails the admins |
no_media | Out of labels or paper |
printer_error | Needs a person: jam, cover open, error light |
unreachable | Off, wrong address, or a different network |
no_address | No IP set in Laberty |
unsupported_format | This 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.
404is normal — it means a Laberty older than this endpoint. The bridge keeps its defaults and says nothing alarming.minBridgeVersionis 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
- 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.
- Always report. Success or failure,
…/doneis always called. - Refuse what it does not understand. An unknown
kindorprotocolisunsupported_formatwith a clear message — never bytes sprayed at a printer on the off chance. - Ignore what it does not recognise. Unknown response fields, unknown config keys: ignored, never fatal.
- Back off on network failure, with a cap, and keep the heartbeat honest so "last seen" means something.
- Report its version so a person can be told which bridge is installed.
Things deliberately NOT promised
- The
targetobject gaining fields. It will. They are optional; ignore what you do not recognise. mediaProbesbeing present. It is optional.- The unversioned
/api/agent/*paths. They are kept forever for bridges already installed, but new work should use/v1. :lateston 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
}
updateisnullwhen there is nothing newer, when the bridge has already rejected that version, or when the release is on hold.commandsareping,restart,update,rollback,forgive,diagnose. An unknown type is answeredok: falseand 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/configgainsfeatures.airprint(boolean, false unless the workspace has it on and its plan or add-on allows it) andairprint: { 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 (sourceairprint) 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