Appearance
Hermes bridge
hermes_bridge/portal_sync.py is the piece that runs on the Hermes host. The Worker never calls Hermes; instead, a Hermes cron job runs this script, which polls the portal over HTTPS and applies the result to the local Securus skill. Each run does these things, in order:
- Connect pending inmates: register them with the Securus skill in portal mode, set their stamp allowance (the plan's monthly allowance minus what the current monthly window already used), and acknowledge to the portal. The portal's allowances are fetched first so this can see current usage.
- Refill allowances when a subscription enters a new monthly window, and revoke the remaining stamps of inmates whose subscription is no longer live.
- Report usage from the Securus stamp ledger back to the portal.
The script needs Python 3.11 or newer (it imports datetime.UTC) and uses only the standard library. It also imports hermes_constants from the Hermes repository to find the Hermes home, so that module must be importable: run the script with the Hermes Python environment, or set PYTHONPATH to the repository root (the cron wrapper below does this).
Configuration
| Item | Value |
|---|---|
| Portal URL | Environment variable REFORM_PORTAL_URL, for example https://portal.example.com. Must be http or https, with a host, and without embedded credentials. A trailing slash is stripped. |
| Token file | $HERMES_HOME/securus/portal_link.token by default, or the path given with --token-file PATH. It holds only the HERMES_LINK_TOKEN secret (surrounding whitespace is stripped). The script never prints it. |
| Hermes home | hermes_constants.get_hermes_home(): a context-local override, else the HERMES_HOME environment variable, else the platform default. The script has no fallback of its own. |
| State file | $HERMES_HOME/securus/portal_sync_state.json, written atomically (temp file, then rename). |
| Securus skill CLI | $HERMES_HOME/skills/communication/securus-emessage/scripts/securus_poc.py, run with the same Python interpreter as the bridge. |
| Securus store | securus_store.SecurusStore, imported from the skill's scripts/ directory, read-only for usage reporting. |
| Timeouts | Each portal HTTP request: 30 seconds. Each Securus CLI subprocess: 600 seconds. A timeout counts as an operation failure. |
Create the token file
Use the same value you stored as the Worker secret HERMES_LINK_TOKEN:
sh
mkdir -p "$HOME/.hermes/securus"
printf '%s' 'PASTE_THE_TOKEN_HERE' > "$HOME/.hermes/securus/portal_link.token"
chmod 600 "$HOME/.hermes/securus/portal_link.token"Run it by hand
sh
PYTHONPATH=/path/to/hermes REFORM_PORTAL_URL=https://portal.example.com \
python3 cloud/reform-assistant/hermes_bridge/portal_sync.pyThe last stdout line is a single JSON summary, for example {"connected":1,"failed":0,"refilled":0,"usage_posted":3}. See Exit codes.
Registering the cron job
The bridge is a plain script with no LLM involvement, so register it as a Hermes no-agent cron job (see the repository's script-only cron guide, website/docs/guides/cron-script-only.md). Hermes runs --script files from ~/.hermes/scripts/ only, so create a small wrapper there that sets the portal URL and runs the bridge:
sh
mkdir -p ~/.hermes/scripts
cat > ~/.hermes/scripts/reform-portal-sync.sh <<'EOF'
#!/usr/bin/env bash
export REFORM_PORTAL_URL="https://portal.example.com"
export PYTHONPATH="/path/to/hermes${PYTHONPATH:+:$PYTHONPATH}"
exec python3 /path/to/hermes/cloud/reform-assistant/hermes_bridge/portal_sync.py
EOF
chmod +x ~/.hermes/scripts/reform-portal-sync.shThen schedule it:
sh
hermes cron create "every 5m" \
--no-agent \
--script reform-portal-sync.sh \
--deliver local \
--name "reform-portal-sync"
hermes cron list
hermes cron run <job_id> # fire it once to testHow Hermes treats the script's result (from the cron guide): exit 0 with non-empty stdout delivers stdout; a non-zero exit delivers an error alert. The bridge prints its JSON summary on every run that gets past configuration, so --deliver local keeps successful summaries in ~/.hermes/cron/output/ instead of sending a message every five minutes, while any failed operation raises an alert (see Exit codes). python3 must be 3.11 or newer; if the system Python is older, use the absolute path to a suitable interpreter in the wrapper.
Manage the job with hermes cron pause <job_id>, hermes cron resume <job_id>, hermes cron edit <job_id> --schedule "every 10m", and hermes cron remove <job_id>.
What one run does
1. Pending connections
Each run first fetches GET /api/hermes/allowances (so reconnections can see current usage) and then GET /api/hermes/connections?status=pending, which returns inmates whose subscription is active or trialing and whose connection is pending. In the connections response period_start and period_end are the current monthly window (falling back to the billing period when there is no window), and billing_period_start and billing_period_end are the Stripe billing period. For each connection the bridge:
- Validates the connection. A
period_startthat is missing,null, or blank makes the connection fail (step 5 below); nothing is stored for it. - Works out the target allowance: the plan's
messages_per_month, minus theusedvalue of the matching inmate in the allowances response when there is one, never below zero (max(0, messages_per_month - used)). A first connection has no usage, so it gets the full plan allowance; an inmate who is reconnected mid-month (for example after a payment failure) is granted only what is still unused in the current monthly window, so stamps already spent are not handed out again. With no matching allowances record the full plan allowance is used. - Runs
securus_poc.py add_contact --first-name ... --last-name ... --state ... --inmate-id <inmate_number> --reply-to portal-<connection_id> --portal-allowance <target>. The skill CLI requires--reply-to, and the portal has no source inbox message id, so the bridge supplies the synthetic idportal-<connection_id>. Once this succeeds the connection id is recorded in the state file'sconnections_readymap, so a retry does not add the contact twice. - Aligns the available balance to exactly the target with
stamps showand agrantorrevokeof the difference (see stamps). Only after this succeeds does it clear anyrevokedmark for the inmate and record the refill marker in the state file'srefillsmap. The marker is the monthly-windowperiod_startof the matching allowances record (the connection's ownperiod_startwhen there is none), so the allowance pass later in the same run sees the window as already handled and does not top the balance back up. - Sends
POST /api/hermes/connections/<connection_id>/ackwith{"status":"connected"}, and then removes the id fromconnections_ready.
If any of steps 1 to 4 fails (an invalid period, a Securus CLI failure, or a timeout), the bridge acknowledges {"status":"failed","detail":"Securus contact or allowance setup failed"}. The console then shows the inmate as Connection failed with that detail.
What portal mode sends to Securus
--portal-allowance switches add_contact (securus_poc_contacts.py) to portal mode. Compared with an admin-driven add:
| Admin add | Portal add (--portal-allowance N) | |
|---|---|---|
| Allowance | Default grant of 20 stamps only if the balance is empty | Available balance set to exactly N (grant or revoke the difference, note portal plan allowance) before the welcome, so the welcome states the right number |
| Message to the inmate | Fixed welcome (subject Hello) that states the reply allowance and the stamp cost of a reply and a picture | The same fixed welcome, and nothing else |
| Message to the admin | Fixed notice Contact added (first name and allowance) sent to the admin inmate | None. No admin-notice message is sent, so the operator pays no admin-notice stamp |
Reply mark on --reply-to | The source request message is marked replied | None; the synthetic portal-<connection_id> is not an inbox message |
| Contact already added and enabled | Error ("already added") | Success: the CLI reports alreadyAdded, sets no allowance, and sends no second welcome; the bridge still aligns the allowance and acknowledges connected |
| Contact disabled earlier (replies revoked) | Re-enabled | Re-enabled and welcomed |
The welcome is sent only when the contact is newly added or re-enabled. If sending it fails, the CLI prints a welcomeError field but still exits 0; the bridge only checks the exit code and the JSON shape, so the connection is still acknowledged as connected.
2. Allowance refills and revocation
GET /api/hermes/allowances lists every active or trialing subscription with inmate_number, messages_per_month, period_start, period_end (the current monthly window), billing_period_end, and used; see HTTP API.
Refill. For each inmate whose window period_start is newer than the one recorded in refills, the bridge tops the available balance back up to messages_per_month (stamps grant of the difference only, never a revoke) and records the new period_start. A balance that is already at or above the target is left alone, so a refill is applied at most once per inmate per monthly window and unused stamps do not accumulate beyond the target. This happens every month on every plan: a quarterly or annual subscriber is refilled monthly, not once per billing period. An allowance entry whose period_start is null, missing, or blank (the subscription is outside its billing period, or the portal has no dates) is reported on stderr and skipped: nothing is refilled or saved for it, it is not used for usage reporting, and that run does not revoke anything, because the response is treated as incomplete. A later window with a valid start is refilled normally. A period that cannot be compared with the previously stored one is treated as newer.
Revoke. Every inmate the bridge has ever refilled or connected is tracked in refills. When a successful allowances response no longer lists one of them (the subscription was canceled, is incomplete or past_due, or the inmate was removed), the bridge reads the available balance with stamps show and runs stamps revoke for all of it, with the note portal subscription inactive; revoke remaining allowance, then records the inmate in revoked so it happens once. A failed, malformed, or partly invalid allowances response never triggers a revoke, and a failed revoke is retried on the next run. If the inmate returns to the response, the revoked mark is cleared only after the refill succeeds, and the next window check counts as new so the allowance is refilled; if reactivating fails, the mark stays and the next run retries. When an inmate is reconnected through a pending connection, the mark is likewise cleared only after the allowance was aligned. Revoking removes stamps only; it does not disable the contact.
3. Usage reporting
For every inmate listed in the current allowances response, the bridge reads spend rows from the Securus store table securus_emessage_stamp_ledger. Each row's message_ids_json lists the covered message ids; the row's amount_millistamps is divided by 1000 to get stamps and split evenly across those ids. For each message id it sends:
json
{
"inmate_number": "145970",
"message_id": "1042:2739507631",
"kind": "reply",
"stamps": 1.0,
"occurred_at": "2026-10-01T12:00:00Z"
}to POST /api/hermes/usage.
- Idempotency key.
message_idis<ledger_entry_id>:<message_id>, so two separate spends that cover the same inbound reply (for example a retry) count separately. A spend row with no usable message ids posts once with the full amount and the key<ledger_entry_id>:. The portal treatsmessage_idas an opaque key. occurred_atis the ledger entry's timestamp converted to UTC.kindis alwaysreply. The bridge does not distinguish snaps or videograms, because the spend ledger rows it reads carry no such information; the portal acceptssnapandvideogrambut the bridge never sends them.- Attribution. The portal attributes each record to the inmate of the account that has a live (
active,trialing, orpast_due) subscription for thatinmate_number. If none does, it answers404 unknown_inmate, which the bridge counts as a failed usage post.
Usage posting is safe to repeat:
- The portal deduplicates on the key (
{"ok":true,"duplicate":true}). - The state file keeps a per-inmate cursor (
usage_cursors): the last ledgerentry_idfully posted. The cursor only advances after every record of an entry was accepted. - A short-lived
usage_postedlist of keys lets an interrupted entry resume without re-posting the ones already accepted. - If one record fails, that inmate's later entries are skipped for the run (so ordering is kept), and other inmates continue.
How allowances map to the Securus skill's stamps
In the Securus skill, a stamp is the unit of local reply authorization. Only the admin inmate 237455 may grant or revoke them, which is why the bridge passes --actor-id 237455 (the constant ADMIN_ACTOR_ID in the script). The relevant skill commands are:
text
securus_poc.py stamps show --user-id <inmate_number>
securus_poc.py stamps grant --user-id <inmate_number> --amount <n> --actor-id 237455 --note <text>
securus_poc.py stamps revoke --user-id <inmate_number> --amount <n> --actor-id 237455 --note <text>The bridge sets the available stamps from the plan's messages_per_month (40 on every plan; the bridge treats one portal message as one stamp):
| Situation | Behavior |
|---|---|
| Newly queued or re-queued connection | stamps show reads available. If it is below the target, stamps grant the difference; if it is above, stamps revoke the difference; if equal, nothing. The note is portal plan <plan_id> period <period_start>. |
New monthly window (from /api/hermes/allowances) | Only grants, to raise available back up to the target. If available already meets or exceeds the target, nothing changes. The note is portal plan refill period <period_start>. |
| Subscription no longer listed | Revokes the whole available balance, once. |
Spent stamps appear as spend entries in the Securus ledger, which the bridge reports back as usage. The skill itself reserves the exact cost before a send, settles it after success, and releases it after a failed send, so the ledger reflects what was actually sent. The portal derives used and remaining from the reported events inside the current monthly window; see HTTP API for GET /api/usage.
Exit codes
| Code | Meaning |
|---|---|
0 | Every step completed without an operation failure. |
1 | At least one operation failed: a failed or timed-out Securus CLI call, a connection or allowance with an invalid period, a failed refill or revoke, a portal request that failed (unreachable, HTTP error, non-JSON reply, 401 from a wrong token), an invalid portal response, a failed acknowledgement, a failed usage post, the Securus store being unreadable, an unreadable or unsavable state file, or an unexpected exception (see Failure handling). The JSON summary is still printed. |
2 | Configuration error: REFORM_PORTAL_URL is missing or invalid, or the token file is unreadable or empty. Nothing runs and no summary is printed. |
In the summary, failed counts failed connections; when other operations fail while no connection did, failed is reported as 1 so the exit code is non-zero. Other processing continues after a failure, so one bad inmate never blocks the rest.
Failure handling
| Failure | What happens |
|---|---|
Missing or invalid REFORM_PORTAL_URL, unreadable or empty token file | Prints "Configuration error: set REFORM_PORTAL_URL and provide a readable token file." to stderr and exits with code 2. |
Portal unreachable, HTTP error, timeout, or non-JSON reply (including 401 from a wrong token) | The request raises an internal error that never includes the response body or token. The step logs a message to stderr, such as "Could not retrieve pending portal connections.", the run continues, and the exit code is 1. Every request sends User-Agent: reform-assistant-portal-sync/1.0: Cloudflare answers urllib's default agent with 403 (error 1010), which would make every run fail this way. |
| Securus CLI fails or times out for one inmate | Counted as failed, an acknowledgement with status failed is sent, and the loop continues with the next connection. Exit code 1. |
| Acknowledgement for a connected inmate fails | Counted as failed and logged. The connection stays pending in the portal; the next run reuses connections_ready and tries again. Exit code 1. |
| Refill or revoke fails for one inmate | Logged ("Could not refill one Securus allowance." or "Could not revoke one inactive portal allowance."); that inmate is retried next run, others continue. Exit code 1. |
| Securus store cannot be opened or read | Logs "Could not open the Securus store for usage reporting." or "Could not read Securus usage records." and skips usage for the run. Exit code 1. |
| State file unreadable or invalid | Logs it and starts with empty state. Exit code 1. |
| State file cannot be saved | Logs "Could not save portal sync state." Exit code 1. |
| Unexpected exception | The bridge catches only the failures it expects per operation (missing keys, OS and subprocess errors, bad values, SQLite errors, portal errors; a Securus store that cannot be imported counts as unreadable). Anything else, such as a bug, is not swallowed per operation: it propagates to a single cron boundary in main, which prints the full traceback to stderr, prints an all-zero summary with failed set to 1, and exits 1. Work done before the exception is kept in the state file. |
A broken token or an unreachable portal therefore raises a Hermes alert on every run until it is fixed. After changing the token, run the script by hand; see the runbook.
Retrying a failed connection
A connection that was acknowledged as failed is no longer returned as pending. It becomes pending again when the next customer.subscription.created or customer.subscription.updated event with an active or trialing status arrives for that subscription, or when you reset it manually; see the runbook.