Feishu and Lark CLI (co feishu, co lark)
Turn a Feishu bot into a directory of files. Every message someone sends the
bot becomes one line in a log and one file in a queue; anything that can read
a file can answer it. co lark is the same tool against Lark (Feishu outside
China) with its own credentials.
This calls Feishu directly through the official SDK's long connection. It dials out, so it runs on a laptop behind NAT with no public address, no OpenOnion credential, and nothing billed.
Setup
pip install lark-oapi
co auth feishu
co auth feishu prints a QR code and a link. Scan it with Feishu or Lark,
approve, and the application exists — in your own tenant, owned by you — with
its credentials written to ~/.co/keys.env.
Already have a bot? co auth feishu --app-id cli_… authorizes that one
instead of creating a new one, so it keeps the groups it is already in and the
permissions already granted to it — which is usually what you want, since a new
application is in no group at all. If lark-cli is installed, co auth feishu
lists the application ids it has configured so you can copy one. It reads only
the ids: the secret beside them is a keychain reference, and copying that into
a plaintext file would be a downgrade (#1497). There is no developer console to
visit and nothing to copy. It starts on Feishu and moves to Lark by itself if
that is where your tenant lives, so there is nothing to choose first either.
Then add the bot to a group and @ it, or send it a direct message.
co feishu check # says what is missing, if anything
check exits 3 and names the missing item.
co auth feishu always creates a new application, because Feishu has no API
that lists the ones you own. To use an existing one, configure it by hand:
At https://open.feishu.cn/app (Lark: https://open.larksuite.com/app), enable the bot capability.
Under Permissions add
im:message.group_at_msg:readonly(group messages that @ the bot),im:message:send_as_bot(reply), andim:message.group_msg— that last one is what lets the listener read back a gap after a disconnect, and without it recovery cannot run at all. It is a sensitive scope: it lets the application read every message in the groups it is in. Addim:message.p2p_msg:readonlyif people will message the bot directly.Under Events, choose long connection and subscribe to
im.message.receive_v1. No request URL is needed.Publish it to your tenant, then write its credentials into
~/.co/keys.env—co env setrefuses these two names by default, because a hand-typed app secret came from somewhere it cannot check, so say where they came from:co env set FEISHU_APP_ID cli_xxx --from-console co env set FEISHU_APP_SECRET xxx --from-console --secret--secretencrypts the value rather than leaving it in the file. Or write them with an editor:FEISHU_APP_ID=cli_xxx FEISHU_APP_SECRET=xxx # Lark uses its own pair LARK_APP_ID=cli_yyy LARK_APP_SECRET=yyy
The directory
~/.co/inbox/feishu/
├── received.jsonl every message received, one JSON line, appended, never deleted
├── sent.jsonl every message sent, and every send that failed
├── done.jsonl durable completion IDs, including messages handled without replying
├── queue.lock short kernel lock for queue mutations (never delete lock files)
├── new/ messages nobody has taken yet, one file each
│ └── 1756808267-om_9f8e
├── cur/ taken but not yet replied; back to new/ after an hour
├── bad/ files that were in new/ but are not messages (a zero-byte file from a full disk); one log line each
├── log the tool's own log: connected, reconnecting, send failed
└── listen.lock held by the running listener (a kernel lock; the pid inside is for you)
Built-in receive, completion and stale recovery coordinate with the same short
kernel lock. The claim timestamp and rename cannot be separated by the sweep.
Completion is flushed to done.jsonl before queue removal, so a redelivery does
not resurrect a message whose consumer deliberately chose silence. This records
completion without pretending a reply was sent. Lock files stay in place; the
kernel releases ownership when a process exits.
Only files named <arrival>-<id> are the tool's. Anything else that lands in
new/ (.DS_Store, an editor's swap file, a note) is left alone, never
claimed, never swept, never deleted.
The file in new/ and the line in received.jsonl are the same bytes:
{"id":"om_9f8e","chat":"oc_a1b2","thread":null,"sender":"on_7c6d",
"sender_name":"Eric Fu","text":"@OpsAgent 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, empty when the platform has no name for
them. sender is still the key: a name is not unique and can change.
quoted carries the message a reply is answering — {"id", "sender", "text", "kind", "from_me"} — and is null otherwise. from_me separates a reply to
the bot from a reply to somebody else in the same group, which are different
events; mentioned reads the same value.
kind says what arrived: text for anything readable as words, otherwise the
platform's own word for it — image, sticker, audio and the rest come
through with an empty text, and without kind they look exactly like a
message with nothing in it.
chat is where it came from; reply there and the answer lands beside the
question. sender is the person's union_id. mentioned is whether the bot
was @'d (always true in a direct message). Nothing else from Feishu is kept
unless you start listen --raw, so group titles and contact cards never reach
a prompt by accident.
You do not need the commands below to consume it:
ls ~/.co/inbox/feishu/new/ # how many are waiting
tail -f ~/.co/inbox/feishu/received.jsonl # watch live
grep on_7c6d ~/.co/inbox/feishu/received.jsonl | jq -r .text
co feishu receive --no-start -t 0 # claim with a fresh visibility timestamp
co feishu done MESSAGE_ID # finish durably without sending a reply
The default directory follows $AGENT_CONFIG_PATH, normally ~/.co.
A second set of applications gets its own root: CO_INBOX_HOME=~/.co/inbox-ops co feishu listen.
The variable moves the whole root rather than one channel, because moving one
and leaving the others only ever produced a half-configured machine.
The verbs
co feishu listen # hold the connection, write the directory; Ctrl-C stops
co feishu receive # block until a message arrives, take it, print it as one JSON line
co feishu receive -t 300 # give up after 5 minutes (exit 124, like timeout(1)); -t 0 looks once
co feishu send oc_a1b2 "all green"
echo "all green" | co feishu send oc_a1b2 # text from stdin, like mail
co feishu reply om_9f8e "fixed" # back to the chat and thread that message came from
co feishu done om_9f8e # took it, decided not to answer; do not bring it back
co whatsapp edit om_9f8e "the corrected text" # replace a message this account sent
co whatsapp delete om_9f8e # delete a message for everyone
co feishu check # credentials, connectivity, listener, unread; exit 3 on a problem
co feishu ls # unread: id, chat, sender, text
co feishu log -f # the tool's log, following
co feishu listen --raw # also keep Feishu's own payload on each received.jsonl line
co feishu receive --no-start # wait for a message but never start a listener (a cron job, a test)
co feishu consume -- ./answer.sh # the loop: receive, run the command, reply with its stdout
receive takes a message that is already queued without needing a listener;
when the queue is empty it starts a background listen if none is running, so
there is no daemon to remember. If that listener exits at once (no SDK,
credentials the platform refused), receive and consume print the reason from
the log and exit with the listener's own code: 3 when a person has to act, 1
otherwise. A listener that dies later, while receive waits, is noticed within
a second rather than at the end of the timeout. listen in the foreground is for watching it work and
for a service manager; one listener per directory. A second listen on the
same directory says already listening (pid N) and exits 1.
Exit codes, the same on every verb:
| exit | meaning | what to do |
|---|---|---|
| 0 | done | |
| 1 | the platform or the listener refused; its own sentence is on stderr | read it; log has the same line |
| 2 | usage: unknown flag, nothing to send | co feishu <verb> --help |
| 3 | not configured: a credential, the SDK, or the bot capability is missing | check names the item |
| 124 | receive -t N saw nothing in N seconds |
the same as timeout(1) |
listen never prints a traceback. When Feishu refuses the credentials it
writes one line (Feishu refused the credentials: 10003 invalid param),
points at log, releases the lock and exits 1, without dialling the long
connection: a pair Feishu has just rejected cannot connect, and the SDK's
own retry would otherwise hide the refusal behind connect failed lines
every two minutes.
reply ID needs only the id: chat and thread are read from received.jsonl. It
refuses to answer the same message twice unless you pass --again, so a loop
that re-runs cannot double-post. A taken message that is neither replied to
nor marked done comes back to new/ after an hour, on the assumption that
its consumer died; done is how a consumer says it chose silence. done
refuses an id this inbox never received (exit 1, Next: co feishu ls): the id
would otherwise be recorded as finished, and the real message with that id
dropped the day it arrived. send and reply print the id Feishu gave
the new message and exit 1 with Feishu's own reason if it was refused.
Changing a message after it has gone out
edit replaces the text of a message this account sent; delete removes one
for everyone. Both take the id send or reply printed.
ID=$(co whatsapp send 61400000000@s.whatsapp.net "deploy finished at **14:02**")
co whatsapp edit "$ID" "deploy finished at **14:20**"
co whatsapp delete "$ID"
WhatsApp stamps an edit as coming from you and checks it, so only your own
messages can be edited — asking to edit a message you received says so
rather than reporting a bad id. delete also covers somebody else's message
when this account is an admin of that group; WhatsApp decides that and gives
its own reason when it refuses.
WhatsApp only, for now. co feishu edit and co lark edit print the
endpoints that exist for it (PUT and DELETE on /im/v1/messages/<id>) and
say nobody has wired them up, so a missing feature never reads as a bad id.
Markdown, not plain text
send, reply and edit read their text as Markdown and translate it into
WhatsApp's own marks, because the thing writing the text is usually a model and
a model writes Markdown. Untranslated, **ready** arrives with the asterisks
still on it.
| you write | it arrives as |
|---|---|
**ready** |
ready in bold |
*maybe* |
maybe in italic |
~~dropped~~ |
|
# Deploy failed |
Deploy failed in bold |
- one |
• one |
[the run](https://…) |
the run: https://… |
Nothing inside a fenced block or `backticks` is converted. --plain
sends the characters exactly as typed. Feishu's text message has no inline
formatting to translate into, so it accepts --plain and changes nothing;
rich text there is a different message type.
Your own agent, no flags
Say which channels the agent answers in ~/.co/host.yaml, beside the name and
the trust level it already keeps there:
name: oo
trust: open
listen:
feishu:
chats: [oc_a1b2] # absent or empty = every group the bot is in
mention_only: true # a group needs an @; a direct message is already one
lark: {}
Then start it the way you always did:
co ai # answers the channels in the file
co server # so does a deployed Host
The command line is for overriding that file for one run, not for configuring
it: co ai --listen lark answers only Lark this time, co ai --no-listen
answers nothing. A channel named on the command line still takes its options
from the file. An unknown channel, or a host.yaml that does not parse, stops
the start and says which — an agent nobody can reach otherwise looks exactly
like a working one.
Each conversation keeps its own session, so a follow-up remembers the question
it follows, and two threads of one group do not read as non-sequiturs to each
other. On a Host the turn is recorded in .co/session_results.jsonl beside the
interactive ones, carrying via: feishu and the sender.
In 1.8.5 anyone who can address the bot can command it. A self-built application is scoped to your tenant and to the groups the bot was invited to, so that is your company, not the internet. Sender allowlists arrive in 1.9.
Any other agent, two lines
m=$(co feishu receive) # {"id":"om_9f8e","chat":"oc_a1b2","text":"...",...}
echo "done, all green" | co feishu reply om_9f8e
Or let the tool run the loop for you:
co feishu consume -- claude -p # one claude per message; its stdout is the reply
co feishu consume -- codex exec -
co feishu consume -- ./answer.sh
consume runs the command with the message JSON on stdin and these variables:
CO_PROVIDER, CO_CHAT, CO_THREAD, CO_SENDER, CO_MSG_ID, and
CO_CHAT_DIR (a per-chat directory the command may keep its own state in).
Non-empty stdout is sent back as the reply and the message is done. Empty
stdout with exit 0 is the command choosing silence: also done, noted in
log. A non-zero exit, or a reply Feishu refused, sends nothing, is reported
on stderr with the message id while the loop carries on, and leaves the
message taken: it comes back to new/ in an hour, the same as a
consumer that died, so a transient failure is retried and a question is
never silently consumed. A command that cannot be run at all (./answer.sh
without its exec bit) is refused with exit 2 before any message is taken.
consume was called serve in 1.8.5b1 and the old name is gone, not aliased —
this project does not ship hidden commands, and serve was public for a few
hours in one opt-in beta. It was renamed because nothing here serves
anything — it takes messages off a queue and hands each to a command, which is
what this design calls a consumer throughout, and what lark-cli event consume
calls it too. A verb another agent can guess is worth more than one it has to
be told.
consume runs one command at a time, because commands written for it have
always run alone and some are not safe to run twice at once. --workers N
answers N conversations at once; messages within one conversation stay in
order whatever N is.
While a command is running, consume keeps saying so, so the hour is measured
from the last sign of life rather than from when the message was taken. A
command that takes ninety minutes is not interrupted; one whose process died
is offered to the next consumer as before. A message that has been handed out
four times without ever finishing is completed with a gave up line in the
log: something about that one breaks whoever takes it, and handing it out
again just breaks the next consumer too, every hour, forever.
What the tool does for you
- Acknowledges Feishu within its three-second window by doing nothing in the handler but writing the two files; the agent runs elsewhere.
- Writes the log line and the queue file before acknowledging, so a crash after the acknowledgement loses nothing.
- Drops redelivered messages by id, against the log, so a duplicate is dropped after a restart too.
- Returns a taken-but-unanswered message to
new/after an hour, so a consumer that died mid-task does not make it vanish. - Reconnects on its own and writes each attempt to
log. - Retries a rate-limited send three times with backoff. Feishu allows five messages per second per group, shared with every bot in that group.
- Fetches a new tenant token when Feishu says the cached one is no longer
good, so rotating the app secret in the console does not leave
consumefailing every reply until the old token's two hours are up. - Keeps one listener per directory with a lock the kernel holds: a listener
killed with SIGKILL, or a reboot, holds nothing, so the next
receivestarts a fresh one instead of waiting on a pid that is gone. - Treats a redelivery as the recovery it is. If the log line was written but the queue file was not (a crash between the two), the redelivered message is queued, not dropped as a duplicate; only a message that is queued, taken or answered is a duplicate.
- Makes
reply --againa real second post. Feishu holds a reply key to one post per hour; a retry of the same text reuses it and is dropped,--againuses a fresh key and lands.
The free edition's API quota
Feishu's free edition (基础免费版) caps all self-built apps in a tenant,
together, at 10,000 counted API calls a month since November 2024, and
refuses counted calls for the rest of the month once it is spent (error
99991403). Paid editions lift it. Feishu raised the free cap to 1,000,000
in June 2026 as a limited-time change; the admin console (管理后台 > 费用中心
权益数据) shows the number that applies to you today.
What this tool spends, per Feishu's own list of what counts:
| call | counted? | when |
|---|---|---|
| receiving a message over the long connection | no (event subscription) | every message |
tenant_access_token |
no (authentication) | every process, at most once |
bot/v3/info |
yes | once per listen start and per check |
messages/{id}/reply, messages (send) |
yes | once per reply or send |
So 10,000 a month is 10,000 replies, and listening costs nothing. Do not poll
check on a timer: a bot that probed itself every minute is how others burnt
the whole month in a week.
Lark
co lark is the same tool with three differences: the domain is
open.larksuite.com, the credentials are LARK_APP_ID and LARK_APP_SECRET,
and the directory is ~/.co/inbox/lark/. Every verb, flag,
file and exit code above is identical. A tenant on Feishu and a tenant on Lark
are two applications with two directories; nothing is shared between them.
Error messages name the platform you are talking to, so a co lark user is
never sent to the Feishu console.
Keeping it running
receive and consume restart a listener that died, so a consumer loop is
enough for most setups. To hold the connection whether or not anything is
consuming, run listen under the service manager you already have. On macOS:
<!-- ~/Library/LaunchAgents/ai.openonion.co-feishu-listen.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>ai.openonion.co-feishu-listen</string>
<key>ProgramArguments</key><array><string>/usr/local/bin/co</string><string>feishu</string><string>listen</string></array>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/co-feishu-listen.err</string>
</dict></plist>
launchctl load ~/Library/LaunchAgents/ai.openonion.co-feishu-listen.plist
On Linux the equivalent is a systemd --user unit with Restart=always.
Either way the process reads ~/.co/keys.env itself, so the unit carries no
secrets. The listener writes nothing to stdout; its life is in log.
What it does not do
It does not decide who may command an agent. Feishu's group_at_msg scope
already limits what reaches the bot to messages that @ it; anything finer,
such as an allowlist of senders, belongs to whatever consumes the directory.
It does not carry images, files or cards (a non-text message arrives as its
type in brackets, [image]), and it does not stream partial replies.
Messages are plain text on disk in a directory only you can read. The log
grows; rotate it with logrotate like any other.
1.8.5 integration acceptance
The implementation is being integrated on top of stable 1.8.4. Synthetic tests cover durable completion after silence, redelivery after a partial write, torn JSONL tails, unsafe ID collisions, and a stale sweep racing the claim timestamp. An unread malformed file is quarantined by both receive and ls; valid messages remain available. Queue/log writes are flushed before delivery returns.
Real channel delivery and provider behavior during a network gap remain separate acceptance gates. Local concurrency tests do not establish that Feishu retains events throughout an arbitrarily long disconnection. No existing listener or polling automation should be stopped merely to run a synthetic test.
Recovering a disconnected listener
The candidate reconciles history on startup and after an SDK reconnect. It uses
only conversations already recorded in this inbox; it does not discover or
import every chat the bot can access. The first run starts a new history boundary
at the listener's start time. Preserve recovery.json with the inbox when
restarting: deleting it discards that recovery boundary.
Every history page passes through the same message-ID deduplication and durable
completion records as WebSocket events. A successful pass advances the checkpoint;
a failed page, denied permission, or storage failure leaves it unchanged and is
retried after 60 seconds. co lark check reports an outstanding recovery failure.
This work runs outside the WebSocket callback so fetching history does not delay
live-event acknowledgements.
Recovery needs the bot scope im:message.group_msg and access to each known
conversation. Without it every pass fails with 230027 … need scope: im:message.group_msg, the checkpoint is held rather than advanced, and
co <provider> check prints a link that grants it — one click, no Developer
Console. An application created by co auth asks for it up front.
Group recovery admits only messages that mention this bot;
it does not turn unrelated group discussion into agent work. Known direct chats
retain their direct-message semantics. Known threads and threads discovered in
history are paginated separately. Messages in conversations the inbox has never
seen, deleted messages, and history the provider no longer exposes cannot be
promised recoverable. These limits also apply to co feishu.
Measured on a live Lark tenant, 15 September 2026: listener killed, a message
posted during a 90-second gap, listener restarted — history recovery complete: 1 new message(s), the message queued exactly once, and no WebSocket received
line for it, so recovery is what delivered it rather than platform redelivery.
The 8 September loss that opened this gate is explained by the missing scope
above, which no setup route granted at the time. See
the run.
Not claimed: that run reproduced a killed listener and a frozen one, not a transport aborted with its reconnects rejected. What it shows is that when the platform does not redeliver, recovery does.
ConnectOnion