# @mulmobridge/client

Shared socket.io client library for all MulmoBridge bridges. Handles connection setup, bearer-token authentication, and the send/receive wire protocol so each bridge only needs to implement its platform adapter.

## Install

```bash
npm install @mulmobridge/client
# or
yarn add @mulmobridge/client
```

## Exports

| Export | Description |
|---|---|
| `createBridgeClient(opts)` | Create a connected socket.io client with auth |
| `requireBearerToken()` | Read the bearer token or exit with a helpful message |
| `readBridgeToken()` | Read the bearer token (returns `null` if absent) |
| `TOKEN_FILE_PATH` | Path to the workspace's `.session-token` |
| `resolveApiUrl(explicit?)` | Resolve the server URL a bridge should connect to |
| `mimeFromExtension(ext)` | Map file extension to MIME type |
| `isImageMime(mime)` | Check if MIME is an image type |
| `isPdfMime(mime)` | Check if MIME is PDF |
| `isSupportedAttachmentMime(mime)` | Check if MIME can be sent to Claude |
| `parseDataUrl(url)` | Parse `data:mime;base64,data` strings |
| `buildDataUrl(mime, b64)` | Build a data URL from components |
| `MessageAck` | Acknowledgement returned by `client.send()` |
| `PushEvent` | Server-push event delivered to `client.onPush()` |
| `BridgeClientOptions` | Options accepted by `createBridgeClient()` |
| `BridgeClient` | Client interface returned by `createBridgeClient()` |
| `ParsedDataUrl` | Parsed data URL components |
| `replyAfterProcessingIndicator(send, { produce, post }, opts)` | Keep a platform's "working on it" indicator up while the reply is produced, and post only once it is down — see below |
| `runWithProcessingIndicator(send, work, opts)` / `startProcessingIndicator(send, opts)` | The same lifecycle without the post step |

## Usage

```typescript
import { createBridgeClient } from "@mulmobridge/client";

const client = createBridgeClient({ transportId: "my-bridge" });

const ack = await client.send("chat-123", "Hello!");
if (ack.ok) {
  console.log(ack.reply);
}

client.onPush((ev) => {
  console.log(`Push from ${ev.chatId}: ${ev.message}`);
});
```

## Which server it connects to

The MulmoClaude server is **not pinned to port 3001**. It honours `PORT`, and an
implicit default that is already busy walks forward (`Port 3001 busy → using 3002
instead`). Whatever it ends up binding, it publishes to `<workspace>/.server-port`
— the file every out-of-process reader uses to find it.

`createBridgeClient()` resolves the address in this order:

1. `opts.apiUrl` — an explicit value always wins
2. `$MULMOCLAUDE_API_URL`
3. `http://127.0.0.1:<port>` from `<workspace>/.server-port`

The fourth step — `http://localhost:3001` — depends on **who supplied the
token**, and that is a security boundary rather than a quirk:

- **Token from the workspace** (`.session-token`): no fallback. The workspace
  owns both halves, so a token without a port is HALF a generation — the server
  is mid-startup and has not bound yet. The client waits and joins when the port
  appears. The window is not narrow: the server writes `.session-token` before
  it binds, with sandbox setup (a Docker image build on a cold start) in
  between, so it can last minutes (#3078).
- **Token pinned by you** (`MULMOCLAUDE_AUTH_TOKEN`): the default still applies.
  You supplied the credential and are pointing the bridge somewhere deliberately
  — a container without the workspace mounted, say — so there is no freshly
  minted secret to strand.

`resolveApiUrl()` still returns `http://localhost:3001` as its last step, and
`DEFAULT_API_URL` is still exported — they are for naming a default, not for
connecting to one. `resolvePublishedApiUrl()` is the same order WITHOUT that
step, and is what the client uses.

The workspace itself is `$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when
that is unset — the same rule the server applies, and the same root the bearer
token is read from.

Only `process.env` is consulted. A `.env` file reaches this library through the
bridge's own `import "dotenv/config"`, which resolves `.env` against the
process's **current working directory** — so a bridge launched from somewhere
else does not see a `MULMOCLAUDE_WORKSPACE_PATH` that lives only in the repo's
`.env`, exactly as it would not see `MULMOCLAUDE_AUTH_TOKEN` there. Export the
variable, or run the bridge from the directory holding the `.env`.

| Export | Resolves |
|---|---|
| `readBridgeToken()` / `tokenFilePath()` | at call time |
| `TOKEN_FILE_PATH` | at import time — a snapshot, kept for compatibility |

### Following a restart

The pair is re-read whenever the connection fails. If the server comes back as a
different generation — a new token, a new port, or both — the client rebuilds its
socket against it and your handlers are re-attached; nothing needs restarting
(#3078). If the pair is unchanged, the socket is left alone so socket.io's own
reconnection handles an ordinary outage.

One case is outside this: a server-initiated disconnect (`io server disconnect`)
is the one reason socket.io does not retry, so no connection failure follows it.
The chat-service never issues one, so there is nothing to recover from today.

## Showing that the bot is working

While `send()` waits, a chat user sees nothing unless the bridge tells the platform it is busy (Discord typing, LINE loading, Telegram chat action). `replyAfterProcessingIndicator` sends that request at once and again every `refreshMs`, then stops it, waits for a request still in flight, and only then posts the reply:

```typescript
await replyAfterProcessingIndicator(
  () => channel.sendTyping(),
  { produce: () => produceReply(), post: (reply) => channel.send(reply) },
  { refreshMs: 8_000, label: "discord", log: console },
);
```

Posting first would let a request that crossed the post put the indicator back on an answered message. A request that hangs is waited for at most `INDICATOR_STOP_WAIT_MS`, so it never holds the reply back. A failed request is logged as a warning and does not stop the refresh.

## How long `send()` waits

The server gives the agent **5 minutes** per turn, then replies with whatever
text has streamed so far — anything the agent produces after that is dropped.
`send()` waits one minute longer than that, so the server's reply always wins
over a client-side timeout.

To give long turns more time, set the limit in milliseconds on the bridge:

```bash
BRIDGE_REPLY_TIMEOUT_MS=1800000          # every bridge: 30 minutes
DISCORD_BRIDGE_REPLY_TIMEOUT_MS=1800000  # this bridge only (wins over the shared form)
```

It travels to the server in the handshake options, so the server and `send()`
always use the same value — nothing to keep in step by hand. A value that is not
a positive whole number is ignored with a warning (the default applies); one
past Node's timer ceiling (about 24.8 days) is clamped with a warning.

While a turn is running, the next message in the same chat waits for it, and
that waiting counts against the next message's own limit: if the limit runs out
before its turn comes, it is answered with a "timed out before the agent could start" reply
instead of being run. Upgrade the
bridge together with the server: an older client keeps its fixed 6-minute wait
and gives up before a longer server limit ends.

## Ecosystem

Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.

**Shared libraries:**

- [`@mulmobridge/client`](https://www.npmjs.com/package/@mulmobridge/client) — socket.io client library used by every bridge below  ← **this package**
- [`@mulmobridge/protocol`](https://www.npmjs.com/package/@mulmobridge/protocol) — wire types and constants
- [`@mulmobridge/chat-service`](https://www.npmjs.com/package/@mulmobridge/chat-service) — server-side relay + session store
- [`@mulmobridge/relay`](https://www.npmjs.com/package/@mulmobridge/relay) — Cloudflare Workers webhook proxy
- [`@mulmobridge/mock-server`](https://www.npmjs.com/package/@mulmobridge/mock-server) — mock server for local bridge development

**Bridges** (one npm package per platform):

- [`@mulmobridge/bluesky`](https://www.npmjs.com/package/@mulmobridge/bluesky) — Bluesky DMs over atproto
- [`@mulmobridge/chatwork`](https://www.npmjs.com/package/@mulmobridge/chatwork) — Chatwork (Japanese business chat)
- [`@mulmobridge/cli`](https://www.npmjs.com/package/@mulmobridge/cli) — interactive terminal bridge
- [`@mulmobridge/discord`](https://www.npmjs.com/package/@mulmobridge/discord) — Discord bot via Gateway
- [`@mulmobridge/email`](https://www.npmjs.com/package/@mulmobridge/email) — IMAP poll + SMTP reply, threading preserved
- [`@mulmobridge/google-chat`](https://www.npmjs.com/package/@mulmobridge/google-chat) — Google Chat via MulmoBridge relay
- [`@mulmobridge/irc`](https://www.npmjs.com/package/@mulmobridge/irc) — IRC (Libera, Freenode, custom)
- [`@mulmobridge/line`](https://www.npmjs.com/package/@mulmobridge/line) — LINE Messaging API via MulmoBridge relay
- [`@mulmobridge/line-works`](https://www.npmjs.com/package/@mulmobridge/line-works) — LINE Works (enterprise LINE)
- [`@mulmobridge/mastodon`](https://www.npmjs.com/package/@mulmobridge/mastodon) — Mastodon DMs + mentions
- [`@mulmobridge/matrix`](https://www.npmjs.com/package/@mulmobridge/matrix) — Matrix / Element
- [`@mulmobridge/mattermost`](https://www.npmjs.com/package/@mulmobridge/mattermost) — Mattermost
- [`@mulmobridge/messenger`](https://www.npmjs.com/package/@mulmobridge/messenger) — Facebook Messenger via MulmoBridge relay
- [`@mulmobridge/nostr`](https://www.npmjs.com/package/@mulmobridge/nostr) — Nostr NIP-04 encrypted DMs
- [`@mulmobridge/rocketchat`](https://www.npmjs.com/package/@mulmobridge/rocketchat) — Rocket.Chat
- [`@mulmobridge/signal`](https://www.npmjs.com/package/@mulmobridge/signal) — Signal via signal-cli-rest-api
- [`@mulmobridge/slack`](https://www.npmjs.com/package/@mulmobridge/slack) — Slack Socket Mode
- [`@mulmobridge/teams`](https://www.npmjs.com/package/@mulmobridge/teams) — Microsoft Teams via Bot Framework
- [`@mulmobridge/telegram`](https://www.npmjs.com/package/@mulmobridge/telegram) — Telegram bot
- [`@mulmobridge/twilio-sms`](https://www.npmjs.com/package/@mulmobridge/twilio-sms) — SMS via Twilio Programmable Messaging
- [`@mulmobridge/viber`](https://www.npmjs.com/package/@mulmobridge/viber) — Viber Public Account bots
- [`@mulmobridge/webhook`](https://www.npmjs.com/package/@mulmobridge/webhook) — generic HTTP webhook bridge
- [`@mulmobridge/whatsapp`](https://www.npmjs.com/package/@mulmobridge/whatsapp) — WhatsApp Cloud API via MulmoBridge relay
- [`@mulmobridge/xmpp`](https://www.npmjs.com/package/@mulmobridge/xmpp) — XMPP / Jabber
- [`@mulmobridge/zulip`](https://www.npmjs.com/package/@mulmobridge/zulip) — Zulip

## Related projects

Published from the MulmoClaude monorepo by [Receptron](https://github.com/receptron).

- **[MulmoClaude](https://github.com/receptron/mulmoclaude)** — an open-source AI assistant platform that runs on your own computer. Claude Code as the engine, a personal wiki for long-term memory, schema-driven collections for your data, and chat that summons the right GUI (markdown, charts, forms, spreadsheets, wikis) for each task.
- **[MulmoTerminal](https://github.com/receptron/mulmoterminal)** — a terminal-first cockpit for running many AI coding agents in parallel. One roster showing every session's summary and PR status, tmux-backed session persistence, git-worktree isolation, one-click PRs, and mobile push with remote reply.
- **[MulmoTerminal manual](https://receptron.github.io/mulmoterminal/)** — setup, workflows, feature reference, configuration, mobile notifications, and alternative / local model providers. Available in English and Japanese.

## License

MIT
