Download

adom-courier

App-neutral Google Chat human-in-the-loop middleware for Adom apps. An always-on poller that posts a prompt/ask as Marv into a Chat space and carries the human's reply back — responder identity + downloaded attachment bytes — to the app that asked. Apps never touch Google.

adom-gchat   POST as Marv (webhook, bot-badged) ── prompts · acks · outcomes
   ▲
adom-courier post → correlate reply→request → resolve identity → download files → dispatch to app
   ▼
adom-google  RECEIVE: poll Chat · resolve responder (People API) · download attachment bytes

How adom-courier works — both directions

Two directions over the same machinery. Apps ask a human (POST /requests → Marv posts the prompt → the reply comes back with responder identity + attachment bytes). And people can start a thread that the courier routes to that space's app, which replies as Marv (type:inbound) — turning Chat into a front-end for AI-first apps. Strict 1:1 space → app; opt in per space.

Why two transports

  • Post = adom-gchat (incoming webhook). Prompts appear as Marv · App — bot-badged, no seat, no per-app OAuth. Webhooks are send-only.
  • Receive = adom-google (OAuth REST, pull). The only way to read replies, resolve who answered, and download attachment bytes. One OAuth identity for the whole fleet, on the courier's container only.
  • Pull, not push — Google's inbound interaction POSTs are dropped by the Cloudflare edge on *.adom.cloud; polling sidesteps that and the daily-auth friction (long-lived gateway token).

Correlation

The webhook doesn't expose its threadKey on read and returns no message handle, so after posting the courier finds its own Marv message (the BOT message with that exact text) to learn the thread.name, then matches human replies to the request by thread.name. Marv's own posts are BOT-typed, so the poller skips them for free.

App-facing API

POST /requests {app, space, itemKey, text} post a threaded prompt as Marv; returns {requestId, threadName}
POST /reply {requestId|threadName, text, expectReply?} post the outcome or a clarifying Q; expectReply re-opens for multi-turn
GET /state open requests + conversations + recent responses (identity, text, attachments, delivery status) + stale-space alarms
GET /health {ok:true} — adds a count-only {ok:true, stale:N} when a space's poll is stuck (externally monitorable, leaks no names)

On a reply the courier POSTs {type, requestId, app, itemKey, turn, responder, text, attachments[]} to the app's dispatchUrl and auto-acks receipt in-thread as Marv. Human-initiated messages dispatch the same way as type:"inbound". Every dispatch is tracked — a failed push is flagged delivered:false in /state rather than vanishing into a log line. All domain logic (image validation, pass/fail, any LLM/vision) lives in the app — the courier is dumb, reusable transport.

A live courier session — post, dispatch, delivery status, staleness alarm

CLI

adom-courier health | state
adom-courier request --app smokey --space smokey-notifications --item fe-3 --text "..."
adom-courier reply --request req-1 --text "Logged ✅" [--expect-reply]

Talks to $ADOM_COURIER_URL (default http://127.0.0.1:8870); add --json for raw output.

Deploy (its own container — one service per box)

# one-time on a fresh box — get the adom-wiki CLI:
curl -fsSL https://wiki.adom.inc/download/adom/adom-wiki-cli/1.0.2/adom-wiki-cli-v1.0.2 -o ~/.local/bin/adom-wiki && chmod +x ~/.local/bin/adom-wiki
adom-wiki pkg install adom/adom-courier                        # + deps (adom-google, adom-gchat)
bash ~/project/adom_modules/adom/adom-courier/service/deploy.sh
# then the interactive steps:
adom-google auth              # the reading identity
adom-gchat setup              # add each space's Marv webhook
# edit ~/.config/adom-courier/config.json, then map the port so apps can reach it

The watchdog (cron, every 2 min) keeps /health green and pulls published updates from the registry (registry auto-update, ≤15 min cadence) — no git auth needed. Config + state live in ~/.config/adom-courier/ so updates never clobber them.

Config (external — ~/.config/adom-courier/config.json)

{
  "pollIntervalMs": 8000,
  "ackReceipt": true,
  "adminToken": "<random — gates /state + /reset>",
  "spaces": { "smokey-notifications": { "id": "spaces/…", "gchatSpace": "smokey-notifications" } },
  "apps":   { "smokey": { "dispatchUrl": "https://…/notify", "apiKey": "<inbound bearer>", "dispatchToken": "<outbound bearer>" } }
}

A space without gchatSpace falls back to posting via adom-google (as the authed user).

Auth (bearer, both directions)

  • App → courier: /requests + /reply require Authorization: Bearer <apps[app].apiKey>; the courier verifies it (timingSafeEqual) and that the token's app matches the request's app. /state + /reset require adminToken; /health is open. With no apiKeys set the write endpoints are open (dev) — the boot log warns.
  • Courier → app: if an app sets dispatchToken, the courier sends Authorization: Bearer <dispatchToken> on the dispatch POST, so your receiver can verify the call genuinely came from the courier.
  • CLI: pass --token or set $ADOM_COURIER_TOKEN.

Status

Proven end-to-end against smokey-notifications, both directions: (1) Marv posts an inspection prompt → a human replies from their phone with a gauge photo → the courier resolves the responder (name + email), downloads the image bytes, dispatches to the app, and Marv auto-acks; and (2) a human starts a thread → the courier routes it to the app as type:"inbound" → the app records it and replies as Marv. Multi-turn, outcome-reply, per-app bearer auth, attachment caps, dispatch delivery status, and a per-space staleness alarm all supported.

  • adom-google — the OAuth Google Workspace CLI the courier uses to receive (read replies, resolve identity, download attachment bytes).
  • adom-gchat — the webhook CLI the courier uses to post as the bot (Marv).
  • Integrating an app? See the Integrating your app section in the bundled skill (skills/adom-courier/SKILL.md) — the 4-part contract + a worked example.

License

MIT — see LICENSE.