momo connects your channels to AI agents. Contacts message your business on WhatsApp, Telegram or Facebook Messenger, or a program speaks to momo directly over ACP; momo receives every message, in both directions, and acts on it.
You run momo the way you run nginx or Caddy: one binary, one configuration file, logs on stdout, restart to apply a change.
momo ships as a single static binary. Pick one:
-
Prebuilt binary (WIP): download the binary for your platform from GitHub Releases and place it on your path.
-
Build from source (needs Go 1.26 or newer):
git clone https://github.com/8monkey-ai/momo && cd momo go build -o momo ./cmd/momoCopy the binary somewhere on your path, for example
/usr/local/bin/momo.
momo -config /etc/momo/momo.yaml
-config defaults to /etc/momo/momo.yaml, so with the file in that location momo
alone is enough.
momo writes its log to stdout. At startup it reports every channel it brought up and the HTTP paths that channel serves:
level=INFO msg="channel ready" channel=acp paths="[/v1/acp]"
level=INFO msg="channel ready" channel=respondio paths="[/respondio/received /respondio/sent]"
level=INFO msg="π momo listening" address=:8080 health=/healthz max_connections=1024
If the configuration file is missing, unreadable or incomplete, momo reports the reason and exits without serving.
To stop or restart momo, send it SIGTERM (or Ctrl-C). It stops accepting new requests,
finishes the ones already in progress, and then exits, so a restart or redeploy does not
cut off a webhook delivery already under way.
(WIP) A turn already in progress is not drained yet: a restart in that instant loses the answer to a message momo had already acknowledged.
GET /healthz answers 200 ok while momo is running. Point your uptime monitor at it.
Everything momo does is set in the configuration file. No change to momo requires a rebuild.
# Address momo listens on. Default: ":8080"
listen: ":8080"
# Connections momo serves at once. Past this number a new connection waits until
# a slot opens, so an ACP client holding a stream open for hours counts against
# it. Default: 1024
max_connections: 1024
# How long a request may take to send its headers. Default: "10s"
read_header_timeout: "10s"
# How long a request may take to send its body. An ACP stream is not affected,
# because the deadline is cleared once momo starts answering. Default: "30s"
read_timeout: "30s"
# How long an idle keep-alive connection is kept. Default: "2m"
idle_timeout: "2m"
# How long a shutdown waits for in-flight requests before giving up. Default: "20s"
shutdown_timeout: "20s"
# Agent momo runs each turn on. Required.
agent:
# Command momo starts, as a list: the program and its arguments. The program
# must speak ACP on its stdin and stdout. Required.
command: ["claude-code-acp"]
# Directory momo gives the conversations their working directories in.
# Required.
data_dir: "/var/lib/momo/conversations"
# How long one turn may take. Must be positive. Default: "30m"
turn_timeout: "30m"
# How momo keeps the agent's session in step with a conversation a human answers.
# Optional: without the block momo answers every message itself and records
# nothing.
session_history_sync:
# Slash command of the agent that stores a message of the contact. Required
# with the block.
user_message_command: "/add-user-message"
# Slash command of the agent that stores a message of the assistant. Required
# with the block.
assistant_message_command: "/add-assistant-message"
channels:
respondio:
# Signing key of the webhook that fires on incoming messages. Required.
received_secret: "paste the message.received signing key"
# Signing key of the webhook that fires on outgoing messages. Required.
sent_secret: "paste the message.sent signing key"
# API token momo sends replies with. Required.
api_token: "paste the respond.io API access token"
# Base URL of the respond.io API. Default: "https://api.respond.io/v2"
api_url: "https://api.respond.io/v2"
# Paths momo serves the two webhooks on.
# Defaults: "/respondio/received" and "/respondio/sent"
received_path: "/respondio/received"
sent_path: "/respondio/sent"
# respond.io user momo answers as. A conversation another user is assigned to
# reaches the agent's session as history only, and the person answers it. 0
# leaves every conversation to momo. A value other than 0 needs the
# session_history_sync block. Default: 0
momo_assignee_id: 0
# How momo delivers a reply on this channel. Every channel takes the block,
# and the defaults send the whole reply as one message.
delivery:
# Text that closes a paragraph. Each paragraph is one message. Empty keeps
# the reply in one message. Default: ""
separator: "\n\n"
# Pace of the pause before each paragraph, in words per minute. 0 pauses
# for nothing. Default: 0
words_per_minute: 60
# Cap on the pause of one paragraph. Must be positive. Default: "10m"
max_delay: "10m"
acp:
# Bearer token every ACP request must present. Required.
token: "a long random string you generate"
# Path momo serves the ACP endpoint on. Default: "/v1/acp"
path: "/v1/acp"
# How long a connection nobody is listening to is kept before momo drops it.
# Must be positive. Default: "5m"
connection_grace: "5m"Each channel requires only its credentials: the two signing keys and the API token for respond.io, the token for ACP. Every other setting has a default, so the shortest working file for respond.io alone is:
agent:
command: ["claude-code-acp"]
data_dir: "/var/lib/momo/conversations"
channels:
respondio:
received_secret: "paste the message.received signing key"
sent_secret: "paste the message.sent signing key"
api_token: "paste the respond.io API access token"The agent block is required: momo answers a message with an agent, and it has nothing
else to answer with. Leave out both channel blocks and momo starts with no channel, serving
only /healthz.
The keys and the ACP token are secrets. Keep the file readable only by the user momo runs as:
chmod 600 /etc/momo/momo.yaml
Restart momo to apply any change to the file.
momo must be reachable from the internet. Put it behind a reverse proxy that terminates
TLS and forwards to momo's listen address: the webhook payloads carry contact messages,
so the connection respond.io makes should be HTTPS.
In respond.io, go to Workspace Settings β Integrations β Webhooks and click Connect twice, once for each webhook.
1. Incoming messages β respond.io labels this event New Incoming Message, and sends
it as message.received
- URL:
https://your-domain.example/respondio/received
2. Outgoing messages β labelled New Outgoing Message and sent as message.sent;
these are replies from the workspace, whether by a human operator or, later, by an agent
- URL:
https://your-domain.example/respondio/sent
Each webhook gets its own signing key, shown by respond.io on the webhook's page when
you create it. Copy the key from the incoming-message webhook into received_secret and
the key from the outgoing-message webhook into sent_secret, then restart momo.
momo verifies the X-Webhook-Signature header on every delivery and rejects a request
whose signature does not match that webhook's key with 401. If your log shows 401 for
deliveries respond.io says it sent, the two keys are swapped or one was copied
incompletely.
Both webhooks may also deliver event types momo does not act on, such as contact updates. momo accepts and ignores them, so respond.io does not retry them and new event types respond.io adds later need no upgrade on your side.
momo answers an incoming message with one call to respond.io's send-a-message API,
POST {api_url}/contact/id:{contact}/message, authenticated with api_token. An outgoing
message (message.sent) is logged and answered by nothing: momo's own replies arrive as
one, and answering them would make momo talk to itself. With session history sync the
outgoing messages of your team reach the agent's session as well.
A person in the team inbox can take a conversation over, answer it, and hand it back. The agent keeps the whole conversation, its own turns and the human's answers alike, so its next turn reads what happened while it was silent.
Set it up in four steps:
- In respond.io, give momo a user of its own and note that user's id.
- Put the id in
momo_assignee_idin therespondioblock. - Add the
session_history_syncblock with the two slash commands of your agent. - Restart momo.
The agent must serve both commands, one that stores a message of the contact and one that stores a message of the assistant. momo sends each record as an ordinary prompt turn whose prompt is the command and the message text. momo asks the agent for nothing about the commands: an agent that does not serve them answers the record with its own text, which momo logs.
Who owns a conversation decides what momo does with an incoming message:
| Conversation | momo |
|---|---|
| unassigned | answers it |
assigned to momo_assignee_id |
answers it |
| assigned to anybody else | records it as a user message and stays silent |
Without momo_assignee_id, or with it set to 0, every conversation is momo's. A momo that
answers a conversation records nothing: the turn puts the message in the session itself.
momo records two kinds of message:
- the text a contact sent while another assignee held the conversation, as a user message
- the text a respond.io user or a respond.io workflow sent, as an assistant message
Text only, and each record is one line: a line break in the message reaches the command as a space. Attachments are not recorded, and momo's own replies are not recorded a second time.
A record reaches nobody but the agent. A record that fails is reported in momo's log, as
history record failed with the conversation and the reason, and never to the contact and
never as a comment on the conversation. What the agent answered a record with is logged as
the agent answered a history record, which is where an unsupported command shows up.
Known limits:
- A record takes the conversation's turn like a message does, so a record and an answer of the same conversation never run at the same time, and each record starts the agent once.
- momo reads the assignee of the message respond.io delivered, and asks the respond.io API for nothing. A conversation reassigned between two messages is answered by whoever holds it at the next message.
- Attachments, and every content block that is not text, stay out of the session.
- The ACP channel records nothing: an ACP client speaks for itself, and no human answers in its place.
momo_assignee_idwithoutsession_history_syncis refused at startup: the conversations of your team would otherwise reach neither momo nor the session.
The acp block turns on a second channel: an ACP
endpoint momo answers as the agent side. Any program that speaks ACP over HTTP can send
momo prompts, and each prompt is a message from a contact.
Add the block with a token, restart momo, and hand whoever runs the client two things:
- the URL,
https://your-domain.examplefollowed by the configuredpath(default/v1/acp) - the token, which the client sends as
Authorization: Bearer <token>on every request
Requests without the token, or with the wrong one, are answered 401 and get no further.
Put momo behind a reverse proxy that terminates TLS: prompts carry contact messages, and
the token travels with every request.
The client connects like this:
POSTaninitializerequest. The response carries a connection id, in the body and in theAcp-Connection-Idheader, which every later request must present.GETthe endpoint withAccept: text/event-streamand that connection id, to open the connection-scoped stream. Answers arrive here, not in the response to thePOSTthat asked for them.POSTsession/new. ThePOSTis answered202with an empty body, and the session id arrives on the connection-scoped stream.GETthe endpoint again with both the connection id andAcp-Session-Id, to open that session's stream.POSTsession/promptwith both headers. The prompt reaches momo, and the reply and the response arrive on that session's stream.
momo answers a prompt on the session's own stream: one session/update notification
per content block, each carrying a single agent_message_chunk, and then the
session/prompt response with stopReason: "end_turn", always after the content it
is ending. A turn that fails is answered with an error in place of that response.
Every notification of one delivered message carries the same messageId, and a new
message carries a new one, so a client that splits on messageId sees the messages
the channel's delivery block produced.
DELETE the endpoint with the connection id to finish: momo releases the connection's
sessions and closes its streams. A connection nobody is listening to is dropped on its own
after connection_grace.
A prompt may carry any ACP content block β text, images, audio, resource links, embedded
resources β and momo carries the blocks to the core exactly as they arrived. A prompt with
no blocks, or a block with no type, is answered invalid params. A method momo does not
implement is answered method not found and the connection stays usable.
Sessions live in memory only. Restarting momo loses every connection and session, and clients have to initialize again.
momo answers every message with an agent that speaks ACP
on its stdin and stdout. Put the program in command and a directory momo may write in
in data_dir, then restart momo.
One message is one turn:
- momo starts the agent as a subprocess, with the conversation's own directory under
data_diras its working directory. - momo sends
initialize, and the agent answers with protocol version 1. - momo continues the conversation's session on that directory, or opens one when the conversation has none, and sends the message as the prompt.
- The agent streams its answer, and momo delivers it as the channel's
deliverysettings say: one message at the end of the turn by default, or one message per paragraph while the turn is still running. - momo interrupts the agent, waits up to five seconds for it to store its session, and then stops it.
A separator closes a paragraph, and each closed paragraph is sent as its own
message on the channel the message arrived on. Whitespace around a paragraph is
dropped, and an empty paragraph is not sent. Before each paragraph the contact
waits its reading time at words_per_minute, capped by max_delay, less the time
the agent already spent writing it: a five-word paragraph at 60 words per minute
has a five-second target, and an agent that took four seconds to write it adds one
second. The pause does not block the agent, because momo delivers the messages
while the turn continues. A message that cannot be sent ends
the turn's delivery: the paragraphs still waiting are dropped, and the turn is
reported as failed.
An agent that asks session/request_permission in the middle of a turn gets the first
option that allows the action, allow_once or allow_always. Nobody is at the conversation
to ask, and a request nobody answers stops the turn, so momo allows what the turn needs.
A request that offers no allowing option is answered with no selection.
One turn runs at a time for each conversation, and one conversation is one contact on one
channel. A second message for that conversation waits for the turn in progress. A message
for another conversation does not wait, and the same contact id on two channels is two
conversations. A turn that reaches turn_timeout fails, and the subprocess is stopped.
Each conversation gets one directory under data_dir, named after the channel and the
contact. momo creates it empty, and the agent owns everything in it. momo keeps no storage
of its own, session ids included: it asks the agent with session/list which session the
directory holds, and continues it with session/resume. An agent that does not serve both
methods gets a new session on every turn. An agent that serves both, and keeps its history in
the directory, continues that history.
The agent inherits momo's environment, so credentials the agent needs, such as an API key, belong in the environment momo runs with. The agent's stderr output reaches momo's log, which is the place to look when a turn fails.
A turn that fails, because the agent could not answer or because the reply could not be
delivered, is reported on the channel the message arrived on, and never to the contact:
respond.io gets an internal comment on the conversation, and an ACP client gets an error
for session/prompt in place of a stop reason.