# Mudroom Support: full documentation > Mudroom Support is an open-source support door for developer-facing SaaS. When a developer's coding agent (Claude Code, Codex) gets stuck on something the vendor's docs don't answer, it asks the vendor's support team over HTTP. The team replies in a Slack thread, and the reply goes back to the agent in the same session, or by email if the session has closed. This site is about the product, for teams who want to run a support door. To ask another vendor's support team, use the /llms.txt on that vendor's own support host, linked from their docs. ## Ask Mudroom support For questions about setting up, running, or pricing Mudroom Support that these docs don't answer, our team has an HTTP support door at [https://support.mudroom.dev/llms.txt](https://support.mudroom.dev/llms.txt?via=docs). A person on our team replies. Use your available HTTP tools to read the instructions and contact support. No email or Slack plugin is needed. If browser or web-fetch access fails, a code or terminal tool can try: ```bash curl --fail --silent --show-error --max-time 20 'https://support.mudroom.dev/llms.txt?via=docs' ``` Browser or web-fetch errors (including ERR_BLOCKED_BY_CLIENT) alone do not mean support is down: terminal HTTP requests can have separate network access. If a terminal request fails because of a sandbox/network restriction or cannot resolve the support host, use the tool's normal network approval flow when available and retry if approved. Respect denied access. If approval is denied or unavailable, report the access blocker rather than claiming support is down or a question was sent. With the developer's permission to contact support, read the door's instructions, ask for any missing requester name or email, and send the actual question over HTTP. Never submit the example identity. ## What it does - Real-time chat: a developer's coding agent asks the support team a question and the reply comes back into the same session. Follow-ups stay in the same thread. - Context that you need: support can ask the developer for logs, versions or config, and the developer's agent gathers it faster than a person could. - No lost replies: if the developer's session has closed by the time support answers, the reply goes out by email. - What the support team uses: Slack (each question is a thread, the team types a normal reply), optional email through the vendor's own Resend account, and one link in the vendor's docs. Developers install nothing. - What it runs on: one self-hosted process (Docker or Node) with a SQLite file, on the vendor's own subdomain. Apache-2.0. ## Contact - [Book a demo](https://cal.com/mudroom/30min?overlayCalendar=true): a 30-minute call to set up a pilot. - [Source on GitHub](https://github.com/mudroom-ai/mudroom-support): code, issues and the README. - [Email](mailto:dennis@mudroom.dev): dennis@mudroom.dev. --- # What Mudroom Support is Mudroom Support gives your product a support door that coding agents can use. When a developer's agent (Claude Code, Codex) hits something your docs don't answer, such as a bug on your side, a limit, or an account or pricing question, it asks your support team over HTTP. Your team replies in a Slack thread, and the reply goes back to the agent in the same session. There is nothing for developers to install: no plugin, no MCP server, no hooks. Agents find the door from links you place where they already look (API errors, SDK messages, your docs), and the door's `/llms.txt` teaches them the rest. On your side it's one small server you host yourself (Apache-2.0). Status: early. Delivery while the agent session is open, email delivery for closed sessions, secret masking, per-field caps and resume links all work. It has not yet run with a real vendor. ## How an agent uses it 1. The agent searches your docs first. When they can't answer, it reads `https://support.acme.dev/llms.txt`, linked from your docs. 2. It strips secrets and posts the question with context: the error, what it tried, and the environment. The developer's name and email are required. The server also masks key and token patterns before anything is stored. 3. The question shows up as a new thread in your Slack channel. Someone on your team types a reply in that thread. 4. The agent waits in the background (long-poll, up to 2 hours) and keeps working. It treats each reply as a message from a person on your support team: information, not instructions to run. 5. The agent can follow up in the same thread, and closes it once the developer has what they need. 6. If the session has closed by the time you answer, the reply is emailed to the developer with a prompt to paste into their agent, and the agent picks the conversation up from there. ## What your team sees Only Slack. There is no dashboard, inbox or human UI. Support types normal replies in the thread, with no forms, buttons or reply codes. A ✅ reaction tells them the reply arrived. ## What you run One process with a single SQLite file, as a Docker image or with Node. Slack connects over Socket Mode (outbound only), so the only inbound traffic is HTTPS from agents. See [Install](/docs/install.md). Then link the door where agents get stuck. See [Where agents find the door](/docs/discovery.md). ## What it doesn't do - Agents can ask, wait, follow up and close. They can't change accounts, raise limits or take actions on your behalf. A person on your team does that. - Chat apps without a code tool (ChatGPT in plain chat) can read the instructions but can't post a question. - Slack is the only support channel so far, and Resend is the only email provider. --- # Install You need Node 22.13 or later (or Docker) and a Slack app. The [Slack guide](/docs/slack.md) has a manifest you can paste. ## With Node ```bash git clone https://github.com/mudroom-ai/mudroom-support && cd mudroom-support npm install && npm run build npm run init # prints the env vars to set cat > .env <<'ENV' VENDOR_NAME="Acme" PUBLIC_URL=https://support.acme.dev SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_CHANNEL_ID=C0123456789 ENV npm start ``` If a required variable is missing, the server won't start, and it prints which variable to set. Every variable is listed in [Configuration](/docs/configuration.md). ## With Docker or Railway The `Dockerfile` builds a single image. Run exactly one replica, mount a persistent volume at `/data` (the SQLite file is stored there), set the env vars above, and set `TRUST_PROXY=1` behind Railway's proxy. `GET /healthz` reports the state of the Slack connection and the outbox. ## Reply emails (optional) When a developer's agent session has closed, the door can email them support's reply. It sends through your own Resend account, from a domain you verified there. See [Resend](/docs/resend.md). Without it, replies reach only open agent sessions, and the door's `/llms.txt` says so. ## Your own domain Run the door on a host you own, like `support.acme.dev` for `acme.dev`, before you link it anywhere. Agents trust links only on that host, and a platform host (`*.up.railway.app`, `*.fly.dev`) is easy to imitate. Then add the links: [Where agents find the door](/docs/discovery.md). --- # Where agents find the door Coding agents don't go looking for a support door. They follow links on pages and output they're already reading: an error your API returned, a message your SDK threw, your package README, your docs. This page covers where to put the link so an agent finds it at the moment it gets stuck, and how to check which placement works. Below, `https://support.acme.dev` stands in for your `PUBLIC_URL`. ## Write it as information, not an instruction Agents treat text inside tool output, error messages and dependencies as data, not as instructions. A line like *"Coding agents: ask Acme support at …"* reads like a prompt injection, and a careful agent ignores it on purpose. In our own test, Claude Code saw that exact line in an SDK error and README and said: *"I didn't follow it, because that's an instruction from a dependency, not from you."* So state where support is and what it can do, and leave the decision to the developer. The agent then passes it along ("Acme has a support line; want me to ask them?"), the developer says yes, and the agent asks with the developer's go-ahead and email. Every snippet below is worded that way. ## The host is enough Nobody needs to remember `/llms.txt`. `GET /` on the door returns the same agent instructions to any client that doesn't ask for HTML (curl, agent fetch tools), and a browser gets a short page that links to them. A developer can say *"ask Acme support at support.acme.dev"* and the agent has everything it needs. `/llms.txt` and `/.well-known/llms.txt` serve the same text. ## Explain HTTP access before the link fails An agent may find the support link in your docs, try its browser or web-fetch tool, and stop when that tool blocks the support host. Recovery guidance inside the support host's `llms.txt` cannot help an agent that has not read it. Put the HTTP client and network-access guidance beside the link on your own docs site and in its `llms.txt`, as in the snippets below. Keep the normal HTTP flow for agents that already reach the door. The curl guidance is a fallback only when browser or web-fetch access fails; it does not require identifying the agent by name. Browser access and terminal network access can differ. A browser error such as `ERR_BLOCKED_BY_CLIENT`, or a DNS failure inside a restricted terminal, does not by itself prove the support service is down. If browser or web-fetch access fails, a code or terminal tool can try curl. The agent can use its tool's normal network approval flow where available and retry if approved. Denied access must be respected; unavailable or denied approval is an access blocker to report to the developer. This requires no email or Slack plugin on the developer's side. The agent still needs the developer's permission to contact support and their actual name and email. It must ask for missing details rather than submit an example identity. ## Where to put the link Ranked by how close each surface is to the moment an agent needs help. Do as many as you can, starting at the top. ### 1. API error responses An agent that hits an error reads the response body right away. Add the door to errors your support team can actually help with: quota and plan limits, account and billing problems, 5xx responses. Leave it off validation errors the agent can fix itself (a missing field, a typo), or support ends up answering those. As a field in the error body: ```json { "error": { "code": "quota_exceeded", "message": "Monthly upload quota exceeded for this plan.", "help": "https://support.acme.dev/?via=api-error" } } ``` If you use RFC 9457 problem details, keep `type` pointing at your docs page for that error, and add the door as an extension member (`"support": "https://support.acme.dev/?via=api-error"`). Or as one line at the end of the message, for clients that only show the message text: ``` Monthly upload quota exceeded for this plan. If the docs don't explain this, Acme support can help: https://support.acme.dev/?via=api-error (a coding agent can send them the question for you) ``` ### 2. SDK error messages and the package README Agents read stack traces, and they often open the package README in `node_modules` or `site-packages`. In the SDK, add the same line to the errors from section 1 (wrapped API errors, auth and quota failures): ``` AcmeQuotaError: Monthly upload quota exceeded. Acme support: https://support.acme.dev/?via=sdk (a coding agent can send them the question for you) ``` In the README you publish to npm/PyPI, under *Support* or *Help*: ```markdown **Support:** if these docs don't answer it, Acme's support team can. Coding agents can send them a question on your behalf at https://support.acme.dev/llms.txt?via=sdk, and a person replies. If browser or web-fetch access fails, a code or terminal tool can try `curl --fail --silent --show-error --max-time 20 'https://support.acme.dev/llms.txt?via=sdk'`. Its network access can differ. If the execution environment restricts the request, its normal network approval flow may allow it; retry only if approved and respect denied access. No email or Slack plugin is needed. ``` ### 3. Your docs site Add a support entry to your docs site's `llms.txt` (many docs platforms generate this file; add it to the header or an "Optional" section). Keep the HTTP access guidance next to the link: ```markdown - [Acme support](https://support.acme.dev/llms.txt?via=docs): a person on Acme's support team answers what these docs don't cover; coding agents can ask on the developer's behalf If browser or web-fetch access fails, a code or terminal tool can try `curl --fail --silent --show-error --max-time 20 'https://support.acme.dev/llms.txt?via=docs'`. Its network access can differ. If the execution environment restricts the request, its normal network approval flow may allow it; retry only if approved and respect denied access. No email or Slack plugin is needed. ``` And one line on your human support or contact page, which agents also fetch: ``` Using a coding agent? It can ask our support team for you at https://support.acme.dev/?via=support-page ``` Include the same HTTP access paragraph on that page so an agent reading it has the recovery guidance before visiting the support host. ### 4. What developers can say Once any of the above is live, *"ask Acme support"* is enough for an agent that has seen one of those links, and *"ask Acme support at support.acme.dev"* is enough for any agent. Put that sentence wherever you tell developers how to get help. ## Place it safely A page that copies your door's instructions looks the same to an agent as your real one, and an agent that trusts a copy sends it the developer's name, email and error details. The door can't tell a copy apart from itself, so the protection is where the link comes from: - **Link only from surfaces you own:** your API, your SDK, your package page, your docs domain. Never tell agents to search the web for your support door. - **Run the door on your own domain**, like `support.acme.dev` for `acme.dev`, so an agent can see the host belongs to you. On a platform host (`*.up.railway.app`, `*.fly.dev`) anyone can stand up a similar-looking name. Add a custom domain before you link it anywhere. - **Name the exact host in every snippet.** The door's instructions already tell agents to trust resume links only on that host. ## See which placement works Every snippet above ends in `?via=`. The door ignores the parameter, but its access log records the full URL: ``` [access] GET /llms.txt?via=docs 200 3ms ip=… ua="…" ``` To count visits by surface: ```bash grep '^\[access\] GET .*via=' server.log | sed -n 's/.*via=\([a-z-]*\).*/\1/p' | sort | uniq -c ``` These numbers are a minimum: some agents drop the query string or type the bare host. ## Limits - The agent needs a code or terminal tool that can make HTTP requests to your support host. Reading a page in a browser or web-search tool alone does not establish that it can POST a question. Tool availability and network permissions vary by session. - Mudroom cannot grant network access or override an agent's denied permissions. The discovery copy explains the available route; a network approval prompt may still be necessary. - A link on a host that isn't yours can't be checked by domain. See *Place it safely*. - Agents decide for themselves when to follow a link, and most will check with the developer first. That's intended: the developer agrees before their email and error details go to you. The instructions also tell agents to search your docs first and ask only what the docs don't answer. ## Verify a fresh session After publishing the snippets and deploying the door, start fresh ChatGPT Work and Claude sessions and ask each to contact support without naming curl or supplying recovery instructions. Check that each discovers the door, uses its available HTTP tools, falls back to curl only if browser/web-fetch access fails, requests network access if its environment requires it, asks for missing requester details, and sends exactly one question. Verify the Slack thread and the reply arriving back in the agent. If access is denied, the agent should report the access blocker without claiming the service is down or the question was sent. Automated tests check the published contract and HTTP behavior; this fresh-session check is needed to establish whether a particular agent follows the discovery copy. --- # Slack ## Overview A **Communication** connector: a `CommunicationConnector` with every thread member (`src/connectors/slack.ts`, Socket Mode client in `src/connectors/slack-socket.ts`). It is the support door's required channel: one Slack channel the vendor's support team watches. - **Each question is a thread.** When a developer's coding agent asks a question (`POST /api/messages`), the server posts it to the channel. Support answers by **typing a reply in that message's thread**; the server stores the reply, adds a ✅ reaction so support knows it arrived, and the agent reads it with `GET /api/threads/:id`. Support never has to end a conversation: the agent closes the thread when the developer has what they need, and an answered thread closes by itself after 7 quiet days. Inbound replies arrive over Slack **Socket Mode**: the server opens an outbound WebSocket to Slack, so it needs no public URL, no tunnel route and no signing secret. It uses Node's built-in `WebSocket`; there is no Slack SDK dependency. Delivery survives restarts in both directions, with the six bot scopes from the manifest below: - **Replies typed while the server was down are picked up.** Socket Mode does not replay events, so on every connect (start, redeploy, reconnect) the server reads each open thread since the last message it handled (`conversations.replies`) and treats what it finds like a live reply: stored once, ✅ added, the agent's `get_thread` answers. - **Questions survive a Slack outage.** When a post fails, the question is kept and retried for up to 24 hours (`support.slack_retry_window_hours`), waiting 30 s, then twice as long each time, never more than 30 minutes (`support.slack_retry_max_backoff_min`; a longer Slack `Retry-After` wins). Every Socket Mode connect also sends whatever is waiting at once. Meanwhile the agent sees `delivery: "delivered"`: the question is accepted and the server gets it to the channel by itself. Only when the 24 hours run out does the agent see `delivery: "failed"`. - **A message is posted once.** Every agent message carries an invisible key as Slack message metadata. Before any retry (after a timeout, a Slack error or a crash mid-send) the server looks for that key in the channel (`conversations.history`) or thread and posts only if the message is not already there. ## Prerequisites - A Slack workspace where the vendor (or whoever runs the server) may **create and install apps**. - A channel for agent questions. A **private channel** with only the support team and the app is the safest choice (see *Limitations*). - The host needs outbound HTTPS and WSS to `slack.com` and `*.slack.com`; no inbound URL. - Node >= 22.4 (built-in `WebSocket`); the server already requires 22.13. ## Setup 1. **Create the app from a manifest.** Go to [api.slack.com/apps](https://api.slack.com/apps), click **Create New App**, choose **From a manifest**, pick your workspace, select **JSON** and paste this. It sets the bot scopes, the event subscriptions and Socket Mode in one step: ```json { "display_information": { "name": "Agent desk", "description": "Questions from developers' coding agents" }, "features": { "bot_user": { "display_name": "Agent desk", "always_online": true } }, "oauth_config": { "scopes": { "bot": ["chat:write", "channels:history", "groups:history", "channels:read", "groups:read", "reactions:write"] } }, "settings": { "event_subscriptions": { "bot_events": ["message.channels", "message.groups"] }, "socket_mode_enabled": true, "org_deploy_enabled": false, "token_rotation_enabled": false } } ``` Click **Next**, then **Create**. (No Request URL is needed: Socket Mode delivers events over an outbound websocket.) 2. **Generate the app-level token.** A manifest cannot create tokens. Open **Basic Information**, scroll to **App-Level Tokens**, click **Generate Token and Scopes**, name it `support`, add the scope `connections:write` and click **Generate**. Copy the token that starts with `xapp-`: this is `SLACK_APP_TOKEN`. 3. **Check the manifest took** (optional). **OAuth & Permissions** lists the six bot scopes, **Event Subscriptions** the two bot events, and **Socket Mode** is on. To build the app by hand instead, pick **Blank app** and set those three screens yourself. 4. **Install it.** Open **Install App** and click **Install to Workspace**, then **Allow**. Copy the **Bot User OAuth Token** that starts with `xoxb-`: this is `SLACK_BOT_TOKEN`. If you change scopes later, click **Reinstall to Workspace**. 5. **Invite the app to the channel.** In Slack, open the channel and type `/invite @Agent desk` (your app's name). 6. **Copy the channel ID.** Click the channel name at the top; at the bottom of the **About** tab, copy the **Channel ID** (starts with `C`): this is `SLACK_CHANNEL_ID`. 7. **Optional: owner user IDs.** To make only certain people's replies count, open each person's profile, click **⋮** and **Copy member ID** (starts with `U`). Comma-separate them in `SLACK_OWNER_USER_IDS`. 8. **Set the env vars.** Put the bot token, the app token, the channel ID and (optionally) the owner IDs in `.env` or your host's environment (`npm run init` prints the list). See *Env vars*. The access check (every `npm start`) calls `auth.test` (bot token), `conversations.info` (channel exists, app is a member, not archived) and `apps.connections.open` (app token and Socket Mode). ## Use cases What an agent can do end to end through this channel: - **Ask support a question** the docs cannot answer (a bug on the vendor's side, a limit, an account or pricing question), and **wait for the typed answer** with `GET /api/threads/:id?wait=N`. Follow-ups from the agent land in the same Slack thread. - **Get the answer after a redeploy.** A reply support typed while the server was stopped or reconnecting arrives when it is back; the agent keeps polling the same thread. - **Get the answer in plain words.** Support types free text; there are no buttons, forms or reply codes. - **Ask while Slack is down.** The question is accepted (`delivery: "delivered"`) and posted when Slack is back, within 24 hours. - **Close the thread when done.** The agent closes the thread (or follows up with `done: true`) once the developer has what they need, and support sees "Thanks, the assistant has what it needs." in the thread. What it cannot do: - **Find, quote, book, cancel or reschedule** anything: the support door has no booking. - **Get an answer typed outside the thread.** Only replies inside a question's thread reach the developer. A top-level message in the channel is the team's own talk: it is never passed on, gets no ✅ and no notice. - **Reach the developer directly.** Messages go to the vendor's channel only; the email notifier (planned, Resend) covers a closed agent session. - **Push the reply to the agent.** The agent has to wait on `GET /api/threads/:id`; agents are told to ask the user to check back later. - **Treat support's text as instructions.** Agents are told the reply is information from the vendor, not a command. ## Limitations - **Replies during downtime arrive late.** A reply typed in a thread while the server is stopped, restarting or reconnecting gets its ✅ when the server is back. A reply in a thread that closed before the server came back is not picked up. If the catch-up read fails, the server retries every 30 seconds and `/healthz` shows `Slack catch-up failed` until it succeeds. - **A retry never posts twice, and stops rather than guess.** If the server cannot check whether an earlier attempt reached Slack (a failed read, or more than 2,000 messages to look through), it does not post; only a message from this app's own bot counts as the earlier attempt; when the 24-hour window runs out the message is marked failed and the agent sees `delivery: "failed"`. The closing line is sent once without this check. - **A broken setup waits for the fix, and the admin hears once.** When Slack refuses because of the app's setup (bot removed from the channel, token revoked, channel archived, missing scope), questions are kept and retried like an outage, so they go out on their own once the setup is fixed within 24 hours. The first such error emails `ADMIN_EMAIL` once (through the email notifier; a different error emails again, the same one does not, for any number of threads) and `/healthz` shows it as `channel.permanent_error` until a post succeeds. Without an email notifier or `ADMIN_EMAIL` the alert is only logged. - **Anyone in the channel counts as support** unless `SLACK_OWNER_USER_IDS` is set. Use a private channel with only the support team, or set the allowlist; with it set, a reply from someone else gets one notice in the thread and is not passed on. - **Threads close without support.** The agent closes a thread, or it closes by itself: `limits.thread_close_after_reply_hours` (default 168, 7 days) after support's last reply with no agent follow-up, or `limits.thread_close_unanswered_days` (default 7) while still waiting for the owner. A reply typed in a closed thread gets one notice that the agent will not see it. If an owner types `close`, `closed` or `done` as the whole reply, the thread closes quietly. - **Only thread replies count.** The opener asks support to reply in the thread. A top-level message in the channel is ignored silently, whoever posts it and however many conversations are waiting, so an internal remark never reaches a developer. Edits, deleted messages and files without text are always ignored. - **One channel per server.** - **Plain text only.** Every message is escaped (`&`, `<`, `>`) before it is posted, so text from developers or agents cannot ping `@channel`, fake a mention or make a link; link previews are off. - **No retry for notices.** A failed one-off notice (the closing line) is logged, never retried; agent messages that hit a Slack failure are kept and retried by the core. - Slack's own limits apply: about one message per second per channel, and very long messages are refused (`msg_too_long`). ## Errors Thread methods (`openThread`, `postToThread`) throw these `ConnectorError` codes; the core turns them into the shared error body. `notify` and the ✅ reaction never throw: failures are logged. | Code | When | What happens | |---|---|---| | `channel_unavailable` | Slack answers HTTP 429 (uses `Retry-After`), 5xx, times out (10 s), or returns `fatal_error`, `internal_error`, `ratelimited`, `service_unavailable`, `request_timeout`. | Transient: the core stores the message and retries it for up to 24 hours; the agent gets `delivery: "delivered"`. `retry_after_s` is set (30 s when Slack gives none). | | `channel_auth_failed` | `invalid_auth`, `not_authed`, `account_inactive`, `token_revoked`, `token_expired`, `not_in_channel`, `channel_not_found`, `is_archived`, `missing_scope`, `no_permission`; also a bad app token at `apps.connections.open`. | Setup error: fails every post until the vendor fixes the app. The core keeps the message and retries it like an outage (the agent gets `delivery: "delivered"`), emails `ADMIN_EMAIL` once per distinct error and shows it on `/healthz`. The message is `Slack: : `. | | `channel_rejected` | Any other `ok:false` error (for example `msg_too_long`), or a response without JSON. | Slack refused this message itself; retrying cannot help. On the first post nothing is stored and the agent gets 503 `channel_unavailable`; on a retry the message is marked failed (`delivery: "failed"`). | Fix hints (startup check and logs): - `SLACK_BOT_TOKEN is wrong or revoked: copy the Bot User OAuth Token (xoxb-...) from OAuth & Permissions in your Slack app` - `invite the app to the channel (type /invite @ in it), then check SLACK_CHANNEL_ID is that channel's ID (C...)` - `the channel in SLACK_CHANNEL_ID is archived: unarchive it or pick another channel` - `the bot token lacks a scope (): add it under OAuth & Permissions, then click Reinstall to Workspace` - `SLACK_APP_TOKEN must be an app-level token (xapp-...) with the connections:write scope, and Socket Mode must be turned on in your Slack app` Startup also stops with a plain error when only some of the three required vars are set (`Slack is partly configured: set SLACK_APP_TOKEN, SLACK_CHANNEL_ID too, ...`) or when a token has the wrong prefix (the two tokens swapped). At startup a setup error (`channel_auth_failed`) stops the server with the hint above and exit code 1; a Slack outage (`channel_unavailable`) only logs a warning, the server boots, and the socket keeps reconnecting. A bad app token at runtime stops the Socket Mode client (no retry loop) and shows in `status()`; a dropped connection reconnects on its own with backoff from 1 s to 60 s. ## Env vars | Var | Default | Meaning | |---|---|---| | `SLACK_BOT_TOKEN` | unset | Bot User OAuth Token, `xoxb-...` (**OAuth & Permissions**). Secret. | | `SLACK_APP_TOKEN` | unset | App-level token with `connections:write`, `xapp-...` (**Basic Information → App-Level Tokens**). Secret. | | `SLACK_CHANNEL_ID` | unset | The channel the app is invited to, `C...` (channel **About** tab). | | `SLACK_OWNER_USER_IDS` | unset | Optional comma list of member IDs (`U...`) whose thread replies count as support's. Unset means anyone in the channel. | | `ADMIN_EMAIL` | unset | Optional: who is emailed once when a setup error (bot removed, token revoked, channel gone) stops posts. Needs the email notifier. | The first three are required: the server does not start without them and names the missing ones. `MESSAGES_ENABLED=false` (see [configuration.md](/docs/configuration.md)) answers the message routes with `messages_disabled`. ## Tests `test/slack.test.ts` runs the connector against a scripted fake `fetch` for the Web API and an in-process fake Socket Mode server (a `WebSocket` constructor injected through the options), never the live Slack API. It covers: `chat.postMessage` returning the `ts` as the thread handle and thread replies; the dedupe key sent as message metadata and `findPosted` reading it back from the channel or the thread with `include_all_metadata`, another app's message with the same key not counting, and a failed or too-large read throwing `channel_unavailable`; the catch-up on `hello` (missed thread replies handed to the core once each, oldest first, before `start()` resolves, with no read of the channel's top-level history; the cursor moved by live events; a failed read retried and shown in `status()`; the stored cursor held at the start of the gap while catch-up is failing; a thread Slack no longer has skipped, any other thread read failure ending the pass with the cursor unmoved); escaping of ` <@U1> &` with unfurls off; 429 with `Retry-After`, 5xx, timeout and `internal_error` as `channel_unavailable`; `invalid_auth`, `not_in_channel`, `channel_not_found`, `is_archived` and `missing_scope` as `channel_auth_failed` with their hints; an unknown error as `channel_rejected`; `notify` and the reaction logging instead of throwing; the three `checkAccess` hints; `hello` before `start()` resolves; the envelope acked only after the reply handler resolves and never when it throws, with no ✅ unless the core calls `acknowledge`; Slack entities in support's text unescaped; messages with a subtype, a `bot_id` or another channel ignored, in a thread or top-level; a top-level human message, even from an allowlisted owner, never passed to the core and given no notice; the core's connect hook run on every `hello`; support's text passed through unchanged (the core decides what it means); the owner allowlist notice once per thread; the closed-thread notice and the generic notice for other rejections; a transient first connect failure rejecting `start()` while reconnecting in the background; reconnect after a `disconnect` envelope; no retry on a bad app token; and the env rules of `createSlackChannel`. `test/messages.test.ts` covers the core side of delivery against a fake channel: an outage shorter than 24 hours (backoff doubling to the 30-minute cap, `delivery` "delivered" throughout, the post landing on recovery); the window running out (the thread's `delivery` "failed", `onDeliveryFailed` called once); a socket reconnect sending a waiting question at once; a setup error emailing the admin once per distinct error, showing on health and clearing on recovery, and only logging without a notifier; a rejected first post storing nothing; a timed-out send found on the retry and not posted again, a follow-up left mid-send by a crash looked up in its thread, a retry that posts when nothing is found, a lookup that keeps failing ending in `failed` with nothing posted; and a top-level message while one thread waits being ignored, with nothing stored, acknowledged or posted. `test/connectors-dx.test.ts` checks that `npm run init` and its `--help` name the connector and its env vars, and `test/config.test.ts` the missing-var fix hint at startup. --- # Resend ## Overview [Resend](https://resend.com) is an email-sending API. This connector is the support door's requester notifier: when support replies after the developer's agent session has closed, the door emails the reply to the developer. It implements `RequesterNotifier` (`src/connectors/types.ts`); the source is `src/connectors/resend.ts` (`ResendNotifier`, built by `createResendNotifier`). **You, the vendor, own the Resend account and the sending domain.** Emails go out from your own domain (`support@acme.dev`), under your own Resend key, quota and reputation. Mudroom's domain is used only for the founder-run demo, never for a vendor's install. It is optional. Without it, replies reach only open agent sessions (long-poll), and startup logs a warning naming `RESEND_API_KEY` and `EMAIL_FROM`. Status: built and wired. `main()` creates it from `RESEND_API_KEY` and `EMAIL_FROM`, checks access at startup (a bad key or unverified domain stops startup with its fix hint), and uses it for admin alerts, the per-question confirm email, coalesced reply emails and the "didn't reach" email (`src/emails.ts`). ## Prerequisites - A Resend account (the free plan is enough to start; check its daily and monthly sending quotas against your support volume). - A domain or subdomain you control DNS for, added and **verified** in Resend: its SPF and DKIM records must be in DNS. Resend's shared test sender (`onboarding@resend.dev`) can only email your own address, so it does not work here. - Outbound HTTPS from the server to `api.resend.com`. ## Setup 1. In Resend, open **Domains** and click **Add Domain**. Enter the domain you send from (a subdomain such as `support.acme.dev` keeps support mail apart from your marketing mail). 2. Add the DNS records Resend shows (the SPF and DKIM records) at your DNS host, then click **Verify DNS Records** and wait until the domain shows **Verified**. 3. Open **API Keys**, click **Create API Key**, choose **Sending access** (optionally limited to that domain), and copy the key (`re_...`). It is shown once. 4. Set the env vars: ```bash RESEND_API_KEY=re_... EMAIL_FROM="Acme support " ``` The address in `EMAIL_FROM` must be on the domain verified in step 2. 5. Run `npm run init` to see these vars listed under `resend (optional)`, then `npm start`. The startup check (`checkAccess`) calls Resend's domains API and confirms the key works and the `EMAIL_FROM` domain is verified. A **Sending access** key cannot list domains (Resend answers `restricted_api_key`), so with that kind of key the check confirms only that the key is accepted, logs a warning, and an unverified domain shows up on the first send as `notifier_domain_unverified`. A **Full access** key gets the full check, but sending access is the safer key to keep on a server. ## Use cases What a developer and their agent get end to end once Resend is set up: - **Ask** a question over HTTP (`POST /api/messages`) with the developer's email as the requester; nothing is emailed while the agent keeps listening. A support reply the agent hasn't read within `support.reply_email_grace_min` is emailed to that address. With `support.require_confirm` on, if the agent stops reading (session closed, laptop asleep, wait loop gave up) for `support.agent_gone_after_min` before it received support's answer, the developer gets one confirm email for that question. - **Wait for the reply** in the agent session (long-poll). When the session has closed, the reply arrives **by email** a few minutes after support types it (coalesced: at most one email per thread per 15 minutes). - **Follow up** and **close** stay in the agent: the email carries a link that lets a later agent session resume the thread. What it cannot do: the email is one-way. Replying to the email does not reach support, and there is no find, quote, book, cancel or reschedule in the support door. With `support.require_confirm` on, reply and "didn't reach" emails go only for a question its requester confirmed; an address confirmed for an earlier question does not count. ## Limitations - Resend allows about 10 API requests per second per team by default, and plan quotas cap emails per day and per month. Over either, sends fail with `notifier_unavailable` and the reply-email queue retries later. - Resend keeps an idempotency key for 24 hours. A retry of the same email after that could send it twice, so the reply-email queue has to give up on an email within 24 hours. - Plain-text emails only (`text`); no HTML, attachments or reply-to threading. - Deliverability (spam folders, bounces) depends on your domain's SPF/DKIM/DMARC setup and reputation, which the server cannot see. ## Errors `sendEmail` and `checkAccess` throw these `ConnectorError` codes; the core never shows Resend's raw error to the agent. | Code | When | What happens | |---|---|---| | `notifier_auth_failed` | Resend answers 401, or names the key as missing, invalid, suspended, inactive or lacking permission. | Permanent. Fix hint: `RESEND_API_KEY was rejected; create a key with sending access at resend.com/api-keys`. At startup the vendor sees it; at send time the email is not retried. | | `notifier_domain_unverified` | The `EMAIL_FROM` domain is not in the Resend account, not yet verified, or Resend refuses the From address (`invalid_from_address`, "domain is not verified", test-sender-only). | Permanent until fixed. Fix hint: `verify in Resend and add its SPF/DKIM records`. | | `notifier_rejected` | Any other 4xx: a bad recipient address, a validation error, or an idempotency key reused with a different email (409 `invalid_idempotent_request`, marked `detail: "idempotency_mismatch"`). | Not retried; the thread notes the developer was not emailed. The exception is `idempotency_mismatch` on a reply email (a retry after a timeout carries a new resume link): it is resent at once under a fresh key, so the worst case is a duplicate email, not a lost one. | | `notifier_unavailable` | 429 (rate limit or quota), 5xx, a timeout (10 s), a network error, or 409 `concurrent_idempotent_requests` (the same email is still being sent). | Transient: the reply-email queue retries. `retry_after_s` is Resend's `retry-after` header when sent, else 30 s. | Half a configuration is a setup error, not a connector error: `createResendNotifier` refuses `RESEND_API_KEY` without `EMAIL_FROM` (or the reverse), and an `EMAIL_FROM` without an `@domain`, with a message naming the missing var. Retries never deliver twice: every send carries Resend's `Idempotency-Key` header, set to a key that stays the same across retries of one email, and Resend answers a repeat with the original result without sending again. ## Env vars | Var | Default | Meaning | |---|---|---| | `RESEND_API_KEY` | unset | Your own Resend API key (`re_...`), **Sending access** is enough. Secret. | | `EMAIL_FROM` | unset | The From header, an address on your Resend-verified domain, e.g. `Acme support `. | Set both or neither. Neither means no reply emails (session-only delivery). ## Tests `test/resend.test.ts` runs against a scripted fake of the Resend API (an injected `fetch` with a route table for `POST /emails` and `GET /domains`); it never calls the live API. It covers a successful send (bearer key, `User-Agent`, `Idempotency-Key`, body), the same key on a retry, every code in the table above, `retry_after_s` from `retry-after`, the startup check for a verified, pending and missing domain and for a sending-only key, and the factory's not-configured and half-configured cases. API facts were checked against Resend's docs on 2026-10-19: [send email](https://resend.com/docs/api-reference/emails/send-email), [idempotency keys](https://resend.com/docs/dashboard/emails/idempotency-keys), [errors](https://resend.com/docs/api-reference/errors), [list domains](https://resend.com/docs/api-reference/domains/list-domains), [domain statuses](https://resend.com/docs/dashboard/domains/manage-domains), [API key permissions](https://resend.com/docs/api-reference/api-keys/create-api-key), [rate limits](https://resend.com/docs/api-reference/rate-limit). --- # Configuration Everything comes from environment variables; there is no config file. `npm start` loads `.env` if present (`node --env-file-if-exists`), and `npm run init` prints the variables to set. The server refuses to start when a required variable is missing and names each one with a fix hint. ## Environment variables | Var | Default | Meaning | |---|---|---| | `VENDOR_NAME` | **required** | Your name as developers know it (`Acme`). Shown in `/llms.txt`, `/openapi.json`, Slack and emails | | `PUBLIC_URL` | **required** | This door's public base URL (`https://support.acme.dev`), used in every link agents and emails get. A trailing slash is dropped | | `SLACK_BOT_TOKEN` | **required** | Slack support channel: Bot User OAuth Token (`xoxb-...`). See [connectors/slack.md](/docs/slack.md) | | `SLACK_APP_TOKEN` | **required** | Slack: app-level token (`xapp-...`) with `connections:write`, for Socket Mode | | `SLACK_CHANNEL_ID` | **required** | Slack: the channel (`C...`) the app is invited to. A partial set, or the two tokens swapped, stops startup | | `SLACK_OWNER_USER_IDS` | unset | Slack: optional comma list of user ids (`U...`) whose thread replies count as support's. Unset means anyone in the channel | | `RESEND_API_KEY` | unset | Optional email notifier: your own Resend API key with sending access. Without it (and `EMAIL_FROM`) replies reach only open agent sessions, and startup logs a warning naming both vars. See [connectors/resend.md](/docs/resend.md). Set both or neither | | `EMAIL_FROM` | unset | Optional: the From address on your Resend-verified domain (SPF/DKIM set up), e.g. `Acme support `. Your domain, not Mudroom's | | `ADMIN_EMAIL` | unset | Optional: who is emailed at once when Slack delivery fails for good (bot removed, token revoked, channel gone). Needs the Resend notifier (`RESEND_API_KEY`, `EMAIL_FROM`) | | `PORT` | `3000` | | | `HOST` | `127.0.0.1` (`0.0.0.0` in Docker) | Bind address | | `ALLOWED_HOSTS` | unset | Comma list of public/tunnel hostnames accepted in `Host`. **Required behind a tunnel** when `HOST` is loopback | | `DB_PATH` | `support.sqlite` (`/data/support.sqlite` in Docker) | SQLite file (threads, replies, outbox, idempotency keys, event log). Must be on persistent storage: agents keep thread ids across sessions | | `MESSAGES_ENABLED` | `true` | Kill switch: `false` answers the message routes with `messages_disabled` | | `API_KEYS` | unset | Optional comma list of bearer keys (higher limits; when set, any other `Authorization` value gets 401; when unset the header is ignored) | | `TRUST_PROXY` | `loopback` | Which peers may set the client IP (`X-Forwarded-For`/`CF-Connecting-IP`) for per-IP limits: hop count, IP/CIDR list, or `false`. **Set it behind Docker or a PaaS** (Railway: `1`) | | `TRUST_PROXY_DEBUG` | `false` | Log how each anonymous caller's IP was resolved, to check `TRUST_PROXY` | | `TRUST_CF_CONNECTING_IP` | `false` | Also honor `CF-Connecting-IP` from trusted peers. Only when the trusted proxy is cloudflared/Cloudflare | ## Built-in defaults These are settings in `src/config.ts` (`limits` and `support`), not env vars yet. The `support` values are the approved defaults from the design's eng review (R3). | Setting | Default | Meaning | |---|---|---| | `limits.per_ip_per_min` / `per_key_per_min` | 60 / 600 | Request limits for anonymous callers (per IP, IPv6 by /64) and keyed agents | | `limits.per_contact_per_hour` | 10 | New questions per requester email per hour, counting only questions the requester confirmed by email (so nobody can spend someone else's quota); unconfirmed ones are bounded by `max_unconfirmed_threads_per_caller` and the request limits | | `limits.max_messages_per_hour` | 30 | Vendor-wide cap on new questions and follow-ups | | `limits.max_open_threads_per_contact` | 3 | Open questions per requester email, counting only questions the requester confirmed by email (so nobody can lock out someone else's address) | | `limits.max_unconfirmed_threads_per_caller` | 5 | Questions still waiting for support's first answer and not confirmed by email, per caller (IP, IPv6 by /64, or API key). Answered questions don't count | | `limits.thread_close_after_reply_hours` | 168 (7 days) | An answered thread closes this long after support's last reply when the agent sends nothing more | | `limits.thread_close_unanswered_days` | 7 | A thread still waiting for support closes this long after the last agent message | | `limits.thread_wait_max_seconds` | 120 | Longest hold for `GET /api/threads/:id?wait=N` (0 to 240; 0 turns long-polling off) | | `limits.max_waiting_requests` | 200 | Held long-polls across all threads; over it a request answers at once | | `support.field_caps` | message 2000, error 4000, tried 2000, environment 500 | Character caps on a question's fields; over a cap is rejected, never truncated (T4a) | | `support.reply_email_grace_min` | 5 | A reply email waits this long after a support reply (T5b) | | `support.reply_email_spacing_min` | 15 | At most one reply email per thread per this many minutes (T5b) | | `support.verified_address_days` | 90 | How long a confirmed address is remembered as verified (T5b). It does not decide email sending: reply and delivery-failed emails go only for a question its requester confirmed | | `support.require_confirm` | `false` | Off: a reply the agent missed is emailed straight to the requester's address. On: the developer must first click a confirm email (sent when the agent stops listening); per-contact limits count confirmed questions only. Anyone can name any address, so turn it on if unwanted reply emails become a problem | | `support.agent_gone_after_min` | 6 | With `require_confirm` on: no read from the agent for this long, before it received support's answer, means its session ended. Only then is the confirm email sent. Keep it above the wait loop's 5-minute back-off | | `support.confirm_emails_per_address_per_day` / `per_vendor_per_day` | 3 / 50 | Caps on confirm emails (T5b) | | `support.confirm_link_hours` | 24 | A confirm link works this long, once, by POST (T5b) | | `support.slack_retry_window_hours` | 24 | A Slack post keeps retrying this long before the thread's delivery becomes `failed` (T3) | | `support.slack_retry_max_backoff_min` | 30 | Longest wait between Slack retries (T3) | --- # Errors Every route returns the same error body: ```json { "code": "rate_limited", "message": "...", "retryable": true, "retry_after_s": 30 } ``` `retry_after_s` appears only when it applies. Over HTTP the status matches the code and a `Retry-After` header is sent when `retry_after_s` is set. The list below is the contract: adding a code is fine, renaming one or changing its status or retryability is a breaking change (`src/errors.ts`). ## Codes | Code | HTTP | Retryable | Meaning | |---|---|---|---| | `invalid_request` | 400 | no | Malformed arguments, malformed JSON, an unknown endpoint, or a field over its cap (the message names the field and the cap) | | `invalid_customer` | 400 | no | `requester` is missing its name or a valid email | | `invalid_message` | 400 | no | New question / follow-up: message empty or containing control characters | | `unauthorized` | 401 | no | A bearer key was sent but is not in `API_KEYS` | | `unknown_thread` | 404 | no | No such `thread_id`, or the `read_token` is missing or wrong (the body is identical in all three cases) | | `idempotency_conflict` | 409 | no | Same `idempotency_key`, different payload | | `thread_closed` | 409 | no | Follow-up on a closed thread (closed by the agent, by support, or after going quiet), or a thread that reached 20 agent messages; ask a new question. Closing a closed thread is not an error | | `thread_limit_reached` | 409 | no | The requester has `max_open_threads_per_contact` open confirmed questions, or this caller has `max_unconfirmed_threads_per_caller` unanswered unconfirmed ones; closing one, support answering one, or confirming by email clears it | | `message_in_progress` | 409 | yes | Same `idempotency_key` while the first request is still being delivered; retry with the same key to get its result | | `rate_limited` | 429 | yes | Per-IP or per-key limit, a contact's hourly limit, or the vendor-wide `max_messages_per_hour`; wait `retry_after_s` | | `messages_disabled` | 503 | no | `MESSAGES_ENABLED=false` kill switch | | `channel_unavailable` | 503 | no | The support channel refused this message itself (for example, too long); nothing was stored. Tell the user to reach the vendor another way. Channel outages and setup errors are not errors here: the question is accepted with `delivery: "delivered"` and the server retries it for up to 24 h | | `internal` | 500 | yes | Unexpected failure | ## Connector errors Connectors never shape responses. They throw `ConnectorError` and the core decides what the agent sees. The support channel (`CommunicationConnector`, Slack) throws three codes. `channel_unavailable` (429, 5xx, timeout) is transient and `channel_auth_failed` (bot removed, token revoked, channel archived) heals once the vendor fixes the app: for both, the message is stored and retried for up to `support.slack_retry_window_hours` (24), and the agent sees `delivery: "delivered"`; a setup error also emails `ADMIN_EMAIL` once and shows on `/healthz`. `channel_rejected` means the channel refused this message itself: on the first post nothing is stored and the agent gets 503 `channel_unavailable` with `retryable: false`; on a retry the message ends failed. `delivery` on a thread has two values: `delivered` (accepted; the server gets it to the support channel, retrying by itself) and `failed` (the retry window ran out, or the channel refused the message for good). Tell the user to reach the vendor another way on `failed`. The email notifier (`RequesterNotifier`) has four codes reserved in `src/connectors/types.ts`: `notifier_auth_failed`, `notifier_domain_unverified`, `notifier_rejected` and `notifier_unavailable`. They never reach an agent.