# 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](docs/flow.png)

**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](docs/session.png)

## 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)
```bash
# 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`)
```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 `apiKey`s 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.

## Related
- **[adom-google](https://wiki.adom.inc/adom/adom-google)** — the OAuth Google Workspace CLI the courier uses to **receive** (read replies, resolve identity, download attachment bytes).
- **[adom-gchat](https://wiki.adom.inc/adom/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](LICENSE).
