co-inbox ยท diff

git:20260925.644ec2d to git:20260928.e755829

18 added, 0 removed. Audit A to A.

---
name: co-inbox
description: Drive `co feishu` / `co lark` โ€” a chat bot as a directory of files. Use when an agent must receive messages from a Feishu or Lark group, reply to them, or run a command per message. Covers setup by QR, the nine verbs, the queue's guarantees, and every exit code.
---
# The inbox
A Feishu or Lark bot, as a directory. One process writes messages into it; you
read them. Nothing here knows what an agent is.
**Always read the output, not just the exit code.** `listen` prints nothing on
a clean hour, and `consume` runs commands whose own failures it reports on
stderr while continuing.
`co discord โ€ฆ` takes the same verbs for a Discord bot (`DISCORD_BOT_TOKEN`,
Message Content intent on). Its ids are Discord's message ids and its chat is
the channel id. See docs/cli/discord.md.
## Which command
| you want to | run |
|---|---|
| set it up, from nothing | `co auth feishu` |
| set it up, reusing a bot you already have | `co auth feishu --app-id cli_โ€ฆ` |
| check it is configured | `co feishu check` |
| take the next message and act on it yourself | `co feishu receive` |
| run a program for every message, forever | `co feishu consume -- <command>` |
| answer a message you took | `co feishu reply <id> "text"` |
| decide not to answer one | `co feishu done <id>` |
| send without being asked | `co feishu send <chat> "text"` |
| **fix something you already said** | `co feishu edit <id> "text"` |
| **take back something you said** | `co feishu delete <id>` |
| **acknowledge a message without a reply** | `co feishu react <id> ๐Ÿ‘` |
| **start a group with people (WhatsApp)** | `co whatsapp group create "<name>" <phone>โ€ฆ` |
| see what is waiting | `co feishu ls` |
| **find out which conversations exist** | `co feishu chats` |
| **read one conversation back** | `co feishu log --chat <id>` |
| watch it work | `co feishu log -f` |
| hold the connection yourself | `co feishu listen` |
`co lark โ€ฆ` is the same verbs against Lark. Pick by where your bot lives; the
credentials are separate (`FEISHU_APP_*` and `LARK_APP_*`).
`co telegram โ€ฆ` is the same verbs against a Telegram bot (`TELEGRAM_BOT_TOKEN`
from @BotFather); its ids are `<chat>.<message_id>`, and its `send` keeps the
older two-argument form. See docs/cli/telegram.md.
+ ### Two WhatsApps, not one
+
+ `co whatsapp` and `co whatsapp-cloud` are different providers with different
+ inboxes (`~/.co/inbox/whatsapp/`, `~/.co/inbox/whatsapp-cloud/`). Do not mix
+ their ids: a message received on one cannot be replied to from the other.
+
+ - **`co whatsapp`** is a linked device on an ordinary number. It is the only one
+ that can sit in a group a person created, and it has `edit`, `delete` and
+ `group`.
+ - **`co whatsapp-cloud`** is Meta's Business Cloud API (preview). One-to-one
+ only. It has one extra verb, `co whatsapp-cloud bind`, run once to route
+ Meta's webhook through O API; `co whatsapp-cloud check` says what else is
+ missing. **Exit 3 from `send` or `reply` here usually means the 24-hour window
+ is closed** โ€” the customer has not written for a day and Meta only accepts an
+ approved template now. Resending the same text will be refused again, so
+ report it rather than retrying. `edit` and `delete` refuse, because Meta has
+ neither.
+
### Finding a conversation
`send` and `reply` need a chat id, and until you have one there is nothing to
paste. `chats` is where ids come from โ€” one tab-separated row per conversation,
id first, so `cut -f1` gives you exactly the thing the other verbs take:
```bash
$ co whatsapp chats
126121882435737@lid direct 4 4 2026-09-17T04:59:17Z Eric Fu ไฝ ๅฅฝ
120363410170505910@g.us group 7 2 2026-09-17T05:00:24Z Eric Fu @bot ๅผ€ๅง‹
# id kind messages for-us last activity who what
```
`for-us` is how many of them were addressed to the bot โ€” the gap between the two
counts is the conversation happening around it.
Then read one back, including everything that never named the bot:
```bash
co whatsapp log --chat 120363410170505910@g.us -n 50
co whatsapp log --chat 120363410170505910@g.us --since 7d
co whatsapp log --sender "Eric Fu" # by name or by id
```
A filtered `log` answers from the record and stops; `-f` tails the whole inbox.
`-n` keeps the **most recent** N, because a conversation is read backwards from
its last turn.
## The 80%
```bash
co auth feishu # scan the QR; the app exists, keys saved
co feishu check # names what is still missing, if anything
# add the bot to a group and @ it
co feishu ls # what is waiting
m=$(co feishu receive) # take one; blocks until there is one
echo "$m" | jq -r .text
co feishu reply "$(echo "$m" | jq -r .id)" "on it"
```
Or hand every message to a program and let it loop:
```bash
co feishu consume -- claude -p # its stdout becomes the reply
co feishu consume -- codex exec -
co feishu consume -- ./answer.sh
```
`consume` gives the command the message JSON on stdin and these variables:
`CO_PROVIDER`, `CO_CHAT`, `CO_THREAD`, `CO_SENDER`, `CO_MSG_ID`, `CO_CHAT_DIR`.
## The message
Ten fields, identical on every provider:
```json
{"id":"om_9f8e","chat":"oc_a1b2","thread":null,"sender":"on_7c6d",
"sender_name":"Eric Fu","text":"look at today's failed deploys","kind":"text",
"quoted":null,"mentioned":true,"at":"2026-09-02T10:31:07Z"}
```
`sender_name` is who that id belongs to โ€” WhatsApp senders arrive as
`126121882435737@lid`, which tells nobody who spoke. Empty when the platform has
no name for them; **use `sender` as the key and `sender_name` only to address
somebody**, because a name is not unique and can change.
`chat` is where a reply goes. `id` is all `reply` needs โ€” it looks up the chat
and thread itself. The provider's own payload is not included unless the
listener was started with `--raw`, so contact names and group titles never
reach a prompt by accident.
**`kind` is how you tell a photo from an empty message.** `text` is anything you
can read as words; otherwise it is the platform's own word for what arrived โ€”
`image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`. Those
come through with `text` empty, because the body is not text, and without
`kind` they are indistinguishable from someone sending nothing at all. Answer
them by saying what you cannot read yet rather than by guessing at silence:
```bash
case "$(jq -r .kind <<<"$MESSAGE")" in
text) ;; # the normal path
image|video|audio|document|sticker)
FILE=$(jq -r '.media.path // empty' <<<"$MESSAGE")
if [ -n "$FILE" ]; then
: # the bytes are on disk at $FILE โ€” read it
else
WHY=$(jq -r '.media.error // "not fetched"' <<<"$MESSAGE")
echo "I can see you sent a $(jq -r .kind <<<"$MESSAGE"), but I could not open it ($WHY)."
fi ;;
*) exit 0 ;; # nothing to say
esac
```
**On WhatsApp, a media message carries the file itself.** `media.path` is where
the bytes landed, with `media.mime` and `media.size` beside it; the listener
fetches them as the message arrives, because the keys are only valid then.
When the fetch failed the record says `media.error` instead, and there is no
file โ€” so check for the path rather than assuming one, and say what went wrong
rather than treating a missing photo as an empty message. Providers other than
WhatsApp have no `media` yet; the `kind` is still there.
A `kind` this list does not name is still the platform's name for it, lowercased
โ€” new message types appear faster than releases do, and arriving as something
beats arriving as nothing.
**Not everything with a `kind` is somebody typing.** These join the record so a
conversation reads back correctly, and only the ones actually about the bot set
`mentioned`:
| `kind` | what happened | wakes a consumer |
|---|---|---|
| `edit` | they changed a message they already sent | like any message |
| `reaction` | somebody put an emoji on one | never |
| `undecryptable` | a message arrived that could not be decrypted | direct only |
| `joined` | **the bot was added to a group** | yes |
| `group-info` | the group was renamed or reconfigured | never |
`undecryptable` is the one worth handling: the message exists and cannot be
read, which is different from nothing arriving. `joined` is the bot's first
sight of a room โ€” usually the moment to introduce itself.
**`quoted` is what the message is replying to**, and `null` when it is not a
reply. Somebody quoting a line and writing "this one is wrong" gives you three
words and a pronoun; the quote is the noun.
```json
"quoted": {"id":"om_7a1c","sender":"on_9d4e","text":"deploy 41 is live",
"kind":"text","from_me":true}
```
`from_me` is the field that changes what you do. **Replying to the bot and
replying to somebody else in the same group are different events**, and
"answer when addressed" cannot be implemented without telling them apart โ€” a
reply to another person is group chatter you should stay out of. `mentioned`
already reads this same value, so the two never disagree; use `quoted.from_me`
when you need the reason rather than the verdict.
```bash
QUOTED=$(jq -r '.quoted.text // empty' <<<"$MESSAGE")
[ -n "$QUOTED" ] && PROMPT="They are replying to: $QUOTED"$'\n'"$PROMPT"
```
## Fixing what you already said
You answered in a group and got it wrong. A follow-up leaves the wrong answer
sitting above the correction forever, where the next reader finds it first.
```bash
ID=$(co whatsapp send oc_ops "deploy finished at **14:02**")
co whatsapp edit "$ID" "deploy finished at **14:20**" # same message, new text
co whatsapp delete "$ID" # gone for everyone
```
Both take the id `send` and `reply` printed โ€” that is the only string you need
to keep.
- **`edit` is yours only.** WhatsApp stamps the edit as coming from you and the
server checks it, so you cannot rewrite what somebody else said. Asking to
edit a message you received says exactly that rather than "no such id".
- **`delete` reaches further.** Your own message always. Somebody else's only
if this account is an admin of that group โ€” WhatsApp decides, and answers
with why when it refuses.
- **Both are WhatsApp only right now.** `co feishu edit` and `co lark edit`
name the endpoints that exist and say nobody has wired them up, so a failure
never looks like a bad id.
## Acknowledging without a message
In a group, people put a ๐Ÿ‘ on each other's messages. A whole reply is louder
than that moment deserves, and silence reads as not listening.
```bash
co whatsapp react "$ID" ๐Ÿ‘ # any message id from receive, log, send or reply
co whatsapp react "$ID" "" # take your reaction off
```
It works on anyone's message, including ones not addressed to you and ones from
before this run, and it goes through the listener like `send`. Deciding *when* a
reaction fits is yours; the automatic SEEN/ANSWERING receipts stay as they are.
WhatsApp only, for now.
## Starting a room for a client
```bash
co whatsapp group create "Acme ร— OpenOnion" 61412345678 61498765432
# 120363041234567890@g.us
# โœ“ +61412345678 added
# โœ— +61498765432 not added: their privacy settings only allow an invite (send them invite_link)
# invite_link: https://chat.whatsapp.com/โ€ฆ
co whatsapp group add 120363041234567890@g.us 61400000000
```
Numbers take the country code, no `+` needed. The first line is the chat id โ€”
use it with `send`. Then **read every person's line before you tell anyone the
group is ready**: WhatsApp reports the group as created even when it quietly
left someone out, and "no WhatsApp account" is a different fix from "their
privacy settings only allow an invite". Exit 1 means at least one person is not
in; send those people the `invite_link` or ask for another number.
### Your text is read as Markdown
WhatsApp has its own marks, and they are not Markdown's. `**bold**` sent
untranslated arrives with the asterisks still on it, which is how an answer
ends up looking machine-generated. `send`, `reply` and `edit` translate:
| you write | it arrives as |
|---|---|
| `**ready**` | *ready* in bold |
| `*maybe*` | _maybe_ in italic |
| `~~dropped~~` | ~dropped~ struck through |
| `# Deploy failed` | *Deploy failed* in bold |
| `- one` | โ€ข one |
| `[the run](https://โ€ฆ)` | the run: https://โ€ฆ |
| ```` ```python ```` fenced block | a monospace block |
Nothing inside a fence or `` `backticks` `` is touched โ€” a code block is
literal, which is the whole point of one. Pass `--plain` to send the characters
exactly as typed.
## The conversation around it
A group asks things across several messages โ€” *"the price sheet is wrong"*,
*"it's missing the cleaning column"*, *"@bot recompute"* โ€” and the bot is handed
only the third. `--context N` adds the N turns before it in that chat:
```bash
co whatsapp receive --context 20
co whatsapp consume --context 20 -- claude -p
```
```json
"context": [
{"at":"โ€ฆ","from":"them","sender":"on_7c6d","text":"the price sheet is wrong","kind":"text"},
{"at":"โ€ฆ","from":"them","sender":"on_7c6d","text":"it's missing the cleaning column","kind":"text"},
{"at":"โ€ฆ","from":"us","sender":"","text":"looking now","kind":"text"}
]
```
Your own replies are in it (`from: "us"`), because a transcript where the bot's
answers are missing reads as though it never responded โ€” a model given that will
apologise for ignoring someone it already helped.
**Opt-in, and zero by default.** Without the flag the line is byte-identical to
before. Context costs tokens, and in a busy group it is also other people's
messages leaving the machine, so it is asked for rather than assumed. Messages
that never named the bot are in it: `mention_only` decides *when you speak*, not
what you are allowed to know.
## Gotchas that change what you report
- **Taking a message is a claim, and claims expire.** `receive` moves the file
from `new/` to `cur/`. If you neither `reply` nor `done` within an hour, it
goes back to `new/` and somebody else gets it. A long job is fine โ€”
`consume` renews the claim while your command runs โ€” but a script that takes
a message and then sleeps is not.
- **`done` is not optional.** A message you decided to ignore stays claimed
until it expires, then comes back. `done <id>` is how you say the silence was
deliberate. It refuses an id the inbox never received (exit 1), because a
finished id is dropped if it ever arrives.
- **A reply happens once.** `reply <id>` refuses a second reply to the same id;
`--again` is the override. Feishu also dedupes on its side for an hour.
- **Two consumers never get the same message**, because taking one is
`rename(2)`. Running two is safe and is how you scale; the loser just gets
the next one.
- **`listen` is the only writer and there is one of it.** A second `listen` on
the same directory exits 1 rather than competing. `receive` and `consume`
start one in the background if none is running; `receive` takes an already
queued message without one. A listener that cannot start (no SDK, a token the
platform refused) is reported with its reason and its exit code, 3.
- **Nothing is deleted.** `received.jsonl` keeps every message forever, so
`grep` is your history and disk is your limit.
- **`--json` is not a flag here.** Every verb that returns data already prints
one JSON object per line.
## Where it lives
```text
~/.co/inbox/feishu/ # $CO_INBOX_HOME moves the whole root
โ”œโ”€โ”€ received.jsonl # every message, appended
โ”œโ”€โ”€ sent.jsonl # every reply, and every send that failed
โ”œโ”€โ”€ done.jsonl # what was deliberately not answered
โ”œโ”€โ”€ new/ cur/ # the queue, and what is claimed
โ””โ”€โ”€ log # connected, reconnecting, send failed
```
`ls new/` is the unread count. `tail -f received.jsonl` is a live view. You do
not need any command in this skill to read it.
## Exit codes
| exit | means | run next |
|---|---|---|
| 0 | it worked | the tip the command printed |
| 1 | the platform refused, or a listener is already running | `co feishu log` |
| 2 | wrong arguments | `co feishu <verb> --help` |
| 3 | not configured: a credential, the SDK, or a token the platform refused | `co feishu check`, then `co auth feishu` |
| 124 | `receive` waited and no message came | `co feishu ls` |
Every one of these prints a line naming the command to run next. A refusal
that names no command is a bug โ€” report it rather than guessing.
## When it is not set up
`co feishu check` exits 3 and names each missing piece. The usual answer is
`co auth feishu`: it creates the application by QR and writes both values. If
the bot already exists and is already in the groups you need,
`co auth feishu --app-id cli_โ€ฆ` authorizes that one instead, keeping its groups
and permissions โ€” a freshly created application is in no group at all.
`listen` additionally needs the SDK, and says so: `pip install lark-oapi`.
## What this does not do
It does not decide who may command an agent. Anything that can read a file can
read this directory, and the only filter the tool applies is the platform's own
โ€” a group message must @ the bot. Sender allowlists belong to whatever consumes
the directory.
It does not recover a gap without the scope for it. History recovery reads back
what arrived while the listener was down, and that needs the bot scope
`im:message.group_msg`. Without it every pass fails, the checkpoint is held
rather than advanced, and `co <provider> check` exits 1 and prints a link that
grants it โ€” so check that before reporting a gap as message loss. Measured on a
live tenant 2026-09-15: with the scope, a message posted during a 90-second gap
was recovered and queued exactly once.
It does not recover a conversation this inbox has never seen. Recovery reconciles
only chats already in `received.jsonl`, and in a group it admits only messages
that mention the bot โ€” unrelated discussion during a gap is dropped on purpose,
not lost.