An SMS device class is a server-side feature for people who run the Tetracom SMS queue (the tetracom-sendsms / tetracom-smsd service that sends one-time codes by text through a relay handset). Instead of naming one relay handset to send through, you name a class — a pool of handsets — and let the queue pick which one each code goes through. That lets a second phone share the load, and lets you take one phone out for maintenance without editing every application’s config.
to = @<class>. Until you do both, the queue behaves exactly as it did before — see Single-device setups are unchanged.On this page
- What a device class is
- Single-device setups are unchanged
- Before you start: pair the handset
- Defining a class in classes.conf
- What goes on a member line
- Pointing an application at the class
- How the class picks a device
- Taking a device out of rotation, and back in
- When no device is available
- When the class file is missing or wrong
- What stays the same
What a device class is
A class is a named pool of send-capable Tetracom relay handsets that outbound SMS jobs route through. Each handset in the pool is one member. When an application submits a code to the class, the queue chooses one member — before the code is sealed — and that member’s phone sends the text.
A member is a Tetracom user: the thing you add to a class is the relay handset’s user-id (the account that phone is signed in as), together with the binding that phone’s gateway app minted for itself. “Adding a user to a class” and “adding a device to a class” are the same act: one member line.
Single-device setups are unchanged
If an application names a single relay handset in client.conf — a plain user-id, like to = dave1 — nothing on this page applies to it. No class file is read, the relay_binding_id in that config is still required exactly as before, and the code takes the identical path it always did. The class engine only ever engages when a target starts with the @ sigil.
to = @<class>. Writing the class file alone changes nothing until something points at it; pointing at a class that isn’t defined is a fast, loud error, never a silent fallback.Before you start: pair the handset
A class member is a real, already-paired relay handset — you cannot conjure one by typing a user-id into the class file. Before a phone can be a member it must be a working relay: the Tetracom SMS Gateway app installed and running on it, signed in as its own Tetracom identity, and paired. That handset’s relay binding ID is minted by the gateway app on the phone and exists nowhere else until an operator taps Copy binding and pastes it out. There is no way to discover it from the server side.
- Set the handset up as a relay first. Follow the SMS gateway page for that phone: install both apps, grant the SMS permission, save the relay user-id, and press Start. You need each handset to reach the “Running — listening…” state before it is any use in a class.
- Note two things per handset: its Tetracom user-id (the relay identity it is signed in as) and its relay binding ID (tap Copy binding in the gateway app). You will put both on the member line.
- Repeat for every phone you want in the pool. Each is a separate handset with its own user-id and its own binding.
identity_mismatch rejection. Copy each binding straight from the phone it belongs to.Defining a class in classes.conf
A class is a list of members, and client.conf — a flat key = value file — has no way to express a list. So membership lives in its own file, classes.conf. By default it sits beside client.conf; you can relocate it with the optional classes_conf key (see below). It is read only when something targets a class, so its mere presence changes nothing.
The format is one header per class, an optional policy line, and one repeatable member line per handset:
# /etc/tetracom-smsq/classes.conf
# opt-in; if this file is absent, the queue is single-device as before.
[class jumpotp]
policy = round_robin
member = relay_userid=smsrelay_a binding=6f1c0e5c-2a3b-4c5d-8e9f-0a1b2c3d4e5f capability=send
member = relay_userid=smsrelay_b binding=b28e1f00-1111-2222-3333-444455556666 capability=send
member = relay_userid=iphone_j binding=0a44dead-beef-4000-8000-000000000000 capability=monitor
[class <name>]starts a class. The name must match^[a-z0-9_-]{1,30}$(lower-case letters, digits, dash and underscore). You may define several classes in one file, each with its own header.policy = …is optional, at most one per class, and chooses how a device is picked from the pool. It defaults tofirst_healthy. See How the class picks a device.member = …is one handset. Repeat it for each. The fields are covered next.#starts a comment at the start of a line, or after whitespace within a value. Blank lines are ignored. This is the same comment rule as client.conf.
[class <name>], a bad class name, a duplicate class, a member or policy line before any header, a duplicate relay_userid inside one class, a missing or malformed relay_userid or binding, and a class with no members. Fix what it names and reload.What goes on a member line
Each member value is a set of space-separated key=value fields. Order does not matter; an omitted optional field takes its default; an unknown or repeated field is an error.
relay_userid=<userid>— required. The Tetracom user-id of the relay handset, matching^[a-z0-9_-]{3,30}$. This is the device: choosing this member means routing the code to this user-id’s phone.binding=<uuid>— required. That handset’s relay binding ID, the UUID you copied off its gateway app (above). It is stored canonicalised (lower-case), and it must be a real UUID.capability=send|monitor— optional, defaults tosend.send— the handset is eligible to be chosen for sending. This is what you want for every Android relay that should carry codes.monitor— the handset is never chosen for sending; it is listed for visibility only. Use this for an iPhone, which cannot send SMS this way (iOS has no send API for it), so it can appear in the roster without ever being handed a code.
member line under that class’s header with the handset’s relay_userid and binding and capability=send, then reload the service. To remove one, delete its line. That is the whole operation — membership is just this file.Pointing an application at the class
An application targets a class by putting the class name in the to key of its client.conf, with a leading @:
# in client.conf
to = @jumpotp # the leading @ selects class mode
# relay_binding_id is STILL required in class mode -- it is THIS daemon's
# own wire identity for its sealing manifest. In class mode it no longer
# picks the destination (each member's binding in classes.conf does that);
# omit it and the daemon fails fast at startup instead of rejecting every submit.
# Optional: relocate the class file. Blank/absent => classes.conf
# beside this client.conf. An absolute path is used as-is; a relative
# path is resolved against the client.conf directory.
classes_conf = /etc/tetracom-smsq/classes.conf
The @ is unambiguous because it is not a legal user-id character — so to = smsrelay_a is a single handset and to = @jumpotp is a class, with no overlap. In class mode the queue loads classes.conf, keeps the members that are eligible (next section), and applies the policy.
tetracom-sendsms --status <id> and --list show it. Routing through a class never hides which device did the sending.How the class picks a device
At submit time — before the code is sealed — the queue builds the list of eligible members and picks one. A member is eligible when both are true:
- it is send-capable (
capability=send— somonitorhandsets are never chosen); and - it is online — administratively enabled, i.e. not drained with
tetracom-smsctl.
Among the eligible members, the class’s policy decides which one:
first_healthy(the default) — always the first eligible member, in the order they appear in the file. It is deterministic and keeps no state. With a single active handset (and perhaps a spare you normally keep drained) this is what you want: predictable, always the same phone until it is taken offline. A one-member class is simply a passthrough.round_robin— rotate across the eligible members on each submission, so a burst of codes fans out across the phones instead of hammering one. This is how you spread load across two or more send-capable relays. The rotation uses a small persisted cursor, so even separate one-shot submissions keep taking turns.
round_robin it takes turns between them, which spreads the load evenly. It does not yet measure how busy each phone is and steer to the least-busy one. True least-busy / shortest-queue selection is a planned v1.1 enhancement and is not active yet — it needs a new message on the relay wire and sign-off before it can ship. The parser deliberately rejects policy = shortest_queue with an explanatory error (so you learn it is deferred, not that you misspelled it) rather than pretend to honour it. For now, use round_robin to share load.gateway_unreachable result (exit 4); and if no handset in the class can be selected at all, the caller gets the frozen service_unavailable result (exit 4) instead. Both are exit 4, so a sign-in flow drops cleanly to an authenticator code or email rather than hanging. Checking a handset’s reachability proactively, at submit time — steering to a phone already known to be ready — is a planned v1.1 enhancement and is not active yet; it is part of the same deferred relay-wire work as shortest-queue, and needs sign-off before it can ship.Taking a device out of rotation, and back in
To take a handset out of the pool for maintenance — a reboot, a SIM swap, a move — you do not edit classes.conf. You drain it with tetracom-smsctl, which flips an on/off bit the router consults:
tetracom-smsctl device offline smsrelay_b # stop routing NEW jobs to this handset
tetracom-smsctl device online smsrelay_b # return it to the pool
tetracom-smsctl device list # show devices with an explicit state
tetracom-smsctl device list --json # the same, machine-readable
- Draining stops new jobs, it does not abort in-flight ones.
offlinetakes effect immediately for the next submission — the router stops choosing that handset. But a code that was already chosen and sealed to it before you drained still goes out normally: a committed one-time code has a short life, so stranding it would be worse than letting it drain. - The state is persistent. It is written to <spool_dir>/admin/devices.json and survives a service restart, so a handset you took offline last night is still offline this morning. A handset that has never been touched defaults to online, which is why
device listshows only the devices you have explicitly set. - Draining several handsets at once is safe. Each
tetracom-smsctlrun takes a short lock on the drain file and re-reads it before writing, so two operators (or two scripts) draining different handsets at the same moment never overwrite each other — every change is kept. - The tool only edits that one file. It opens no Tetracom connection, seals nothing, and never interrupts a send. It finds the queue from the same client.conf the service uses (its
spool_dir), or from an explicit--spool-dir.
Useful flags:
--config <path>— the client.conf to readspool_dirfrom (default /etc/tetracom-smsq/client.conf).--spool-dir <dir>— name the queue directory directly, instead of--config.--by <who>— an operator label recorded alongside theoffline/onlinechange, as an audit breadcrumb.--json— machine-readable output fordevice list.
tetracom-smsctl has its own small exit codes — 0 applied or listed, 1 a runtime failure (unreadable config or state, a failed write), 2 a bad invocation. These are local to this admin tool; they are not the frozen exit codes that tetracom-sendsms returns to a calling application.When no device is available
If no member of the class is eligible — every send-capable handset has been drained, or the class holds only monitor members — the submission does not queue and does not hang. It fails fast with the frozen service_unavailable result (exit 4), and nothing is queued.
online restores routing immediately.When the class file is missing or wrong
Two mistakes in the class configuration fail fast and loud at submit — exit 1, nothing queued — rather than routing past the problem:
class_not_found— an application targetsto = @namebut there is no classes.conf where it looked, or the file exists but defines no class by that name. The error names the path it tried. Create the file, or fix the name, or pointtoat a plain relay user-id.classes_invalid— the classes.conf exists but does not parse (one of the strict-parser refusals in Defining a class). The message names the offending line. Fix it and reload.
service_unavailable — retrying will not help until you edit the file. Neither one ever queues a code.What stays the same
- Single-handset setups are byte-for-byte unchanged. A plain
to = <userid>never reads a class file and never touches the drain state; its relay_binding_id is still required, and its behaviour is identical to before the class feature existed. - Membership is one file. Adding or removing a user is adding or removing a
memberline in classes.conf; draining a handset for maintenance is atetracom-smsctlcommand, no file edit. - The code path did not change. A class only chooses which relay a code is sealed to; how the code is sealed, sent and confirmed is exactly as it was for a single handset.
- Nothing sensitive is exposed by any of this. The drain-state file holds only user-ids and on/off bits; the destination number and the code itself are never in it, never in the class file, and never in a log line.
Still stuck?
If the class does not route the way you expect, check three things in order: that each handset has reached “Running — listening…” in its gateway app, that tetracom-smsctl device list shows the handset you expect as online, and that its member line in classes.conf has capability=send and the right binding. If something here does not match what you see, email help@tetracom.me with the class file (with the bindings redacted) and what you expected to happen.