Tetracom logo — tetrahedron mark

SMS device class

Route outbound text messages through a pool of relay handsets — and take any one out of rotation without editing the config.

Back to user help index

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.

This is an operator page, not an app setting. Everything here is done on the host that runs the SMS queue, by editing config files and running a command-line tool — not in the Tetracom phone app. If you are setting up the handset that actually sends the texts, you want the SMS gateway page instead. If you don’t run the SMS queue at all, you can stop reading here.
It is opt-in and additive. Nothing here changes a single-handset setup. You only get a class by writing a classes.conf and pointing an application at it with 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

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.

Why you would want one. Two reasons. First, sharing load: with more than one send-capable handset in the pool, a burst of codes can be spread across them instead of piling onto one SIM. Second, maintenance: you can drain a handset out of the pool for a reboot or a SIM swap, and the queue simply stops choosing it — no config edit, no downtime for the others.

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.

You opt in deliberately, in two places. A class is used only when both are true: a classes.conf exists, and an application targets a class with 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.

  1. 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.
  2. 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.
  3. Repeat for every phone you want in the pool. Each is a separate handset with its own user-id and its own binding.
The binding ties a code to a physical phone. If you copy the wrong binding onto a member line, codes routed to that member go to the wrong handset or to none — and the relay refuses a mismatched binding with an 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
The parser is strict on purpose, and it names the offending line. Because a class routes a login code to a physical phone, a typo silently ignored would be a code sent to the wrong handset — or to none. So the file is refused (with the line number) on: an unknown key, an unknown or duplicated member sub-key, a header that isn’t [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.

  1. 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.
  2. 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.
  3. capability=send|monitor — optional, defaults to send.
    • 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.
To add a user to an existing class, add one more 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.

You can always see which phone a code went through. The handset the class chose is recorded as the relayUserid on that job’s status entry, so 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:

Among the eligible members, the class’s policy decides which one:

  1. 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.
  2. 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.
“Send through the most available phone” — here is exactly what v1 does, honestly. Today the class balances across the phones that are online and send-capable: with 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.
v1 does not phone the handset at submit time. Eligibility is “online and send-capable” only — when the class chooses, the submitter opens no connection to check the phone is actually reachable. Reachability is instead checked reactively, at send time: if the chosen handset turns out to be unreachable — powered off, asleep, or its gateway app not running, so it does not answer the relay — the send fails with the frozen 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
  1. Draining stops new jobs, it does not abort in-flight ones. offline takes 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.
  2. 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 list shows only the devices you have explicitly set.
  3. Draining several handsets at once is safe. Each tetracom-smsctl run 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.
  4. 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:

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.

This is deliberate, so a caller can fall back cleanly. An application that needs a code delivered (for example, a sign-in flow) reads exit 4 as “this channel can’t take it right now” and drops to another method — an authenticator code, or email — instead of waiting on a code that will never send. Because nothing was queued, there is no risk the text also goes out later. Bringing any send-capable handset back 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:

These are configuration errors, not transient ones. They mean the class file needs a human to fix it, so — unlike service_unavailable — retrying will not help until you edit the file. Neither one ever queues a code.

What stays the same

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.