diff --git a/.gitignore b/.gitignore
index 7a24bc8..0a3c73e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ config/credentials/
 tools/teams-channel/.env
 tools/email-channel/.env
 tools/rippling-mcp/.env
+tools/curogram-mcp/.env
 .mcp.json
 .mcp.json.golden
 
@@ -71,6 +72,7 @@ __pycache__/
 
 # Lock files (regenerated)
 tools/rippling-mcp/bun.lock
+tools/curogram-mcp/bun.lock
 tools/ringcentral-mcp/bun.lock
 tools/advancedmd-mcp/bun.lock
 tools/mcp-shared/bun.lock
diff --git a/supervisors/run-curogram-mcp.sh b/supervisors/run-curogram-mcp.sh
new file mode 100644
index 0000000..e5116f0
--- /dev/null
+++ b/supervisors/run-curogram-mcp.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# curogram-mcp supervisor.
+#
+# Streamable HTTP MCP server on port 18817 (Curogram patient messaging, v1).
+# Bun + restart-on-crash. Locked with flock so multiple invocations are safe.
+#
+# Env file (~/.config/curogram-mcp.env, mode 0600) must define:
+#   CUROGRAM_AGENT_USERNAME   service-account email (agent@exulthealthcare.com)
+#   CUROGRAM_AGENT_PASSWORD   service-account password
+#   MCP_BEARER_TOKEN          gates the HTTP transport (same token as the
+#                             rendered .mcp.json Authorization header)
+# Optional:
+#   CUROGRAM_PRACTICE_ID      pin the Exult tenant per session (multi-practice)
+#   CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN   CDP-harvest fallback if MFA-gated
+set -euo pipefail
+
+LOCK_FILE="/tmp/curogram-mcp.lock"
+LOG_FILE="/tmp/curogram-mcp.log"
+HEALTH_FILE="/tmp/curogram-mcp-health.json"
+ENV_FILE="$HOME/.config/curogram-mcp.env"
+RESTART_DELAY=3
+STABILITY_THRESHOLD=60
+SERVER_DIR="$HOME/repos/exult-agent/tools/curogram-mcp"
+
+cleanup() {
+  rm -f "$LOCK_FILE" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+# Single-instance guard
+if [ -f "$LOCK_FILE" ]; then
+  other=$(cat "$LOCK_FILE" 2>/dev/null || true)
+  if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then
+    echo "[$(date)] another instance running (pid $other). Exiting." >&2
+    exit 0
+  fi
+  rm -f "$LOCK_FILE"
+fi
+echo $$ > "$LOCK_FILE"
+
+log_msg() { echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] $1" | tee -a "$LOG_FILE"; }
+
+BUN_BIN="$(command -v bun || echo "$HOME/.bun/bin/bun")"
+[ -x "$BUN_BIN" ] || { log_msg "ERROR: bun binary not found"; exit 1; }
+
+[ -d "$SERVER_DIR" ] || { log_msg "ERROR: server dir missing: $SERVER_DIR"; exit 1; }
+
+# Load env (CUROGRAM creds + MCP_BEARER_TOKEN)
+if [ -f "$ENV_FILE" ]; then
+  set -a
+  # shellcheck disable=SC1090
+  source "$ENV_FILE"
+  set +a
+else
+  log_msg "ERROR: env file missing: $ENV_FILE"
+  exit 1
+fi
+
+# Required env sanity. Either programmatic creds OR a harvested cookie pair.
+if [ -z "${MCP_BEARER_TOKEN:-}" ]; then
+  log_msg "ERROR: MCP_BEARER_TOKEN not set in $ENV_FILE"
+  exit 1
+fi
+if [ -z "${CUROGRAM_AGENT_USERNAME:-}" ] || [ -z "${CUROGRAM_AGENT_PASSWORD:-}" ]; then
+  if [ -z "${CUROGRAM_COOKIE:-}" ] || [ -z "${CUROGRAM_XSRF_TOKEN:-}" ]; then
+    log_msg "ERROR: need CUROGRAM_AGENT_USERNAME+CUROGRAM_AGENT_PASSWORD or CUROGRAM_COOKIE+CUROGRAM_XSRF_TOKEN in $ENV_FILE"
+    exit 1
+  fi
+fi
+export CUROGRAM_AGENT_USERNAME CUROGRAM_AGENT_PASSWORD MCP_BEARER_TOKEN
+export CUROGRAM_PRACTICE_ID CUROGRAM_COOKIE CUROGRAM_XSRF_TOKEN
+
+consecutive_failures=0
+last_failure_ts=0
+restarts_today=0
+
+backoff() {
+  if [ "$consecutive_failures" -ge 5 ]; then echo 60
+  elif [ "$consecutive_failures" -ge 3 ]; then echo 15
+  else echo "$RESTART_DELAY"; fi
+}
+
+while true; do
+  start_ts=$(date +%s)
+  log_msg "starting bun server (port=18817 failures=$consecutive_failures)"
+
+  "$BUN_BIN" "$SERVER_DIR/server.ts" >>"$LOG_FILE" 2>&1 || true
+
+  end_ts=$(date +%s)
+  dur=$((end_ts - start_ts))
+
+  if [ "$dur" -gt "$STABILITY_THRESHOLD" ]; then
+    consecutive_failures=0
+    log_msg "session ran ${dur}s (stable)"
+  else
+    if [ "$last_failure_ts" -gt 0 ] && [ $((end_ts - last_failure_ts)) -gt 300 ]; then
+      consecutive_failures=1
+    else
+      consecutive_failures=$((consecutive_failures + 1))
+    fi
+    last_failure_ts="$end_ts"
+    log_msg "session ran ${dur}s (consecutive failure #$consecutive_failures)"
+  fi
+
+  restarts_today=$((restarts_today + 1))
+  cat >"$HEALTH_FILE" <<EOF
+{"restarts_today":$restarts_today,"last_restart":"$(date -u +%FT%TZ)","last_duration_seconds":$dur,"consecutive_failures":$consecutive_failures}
+EOF
+
+  delay=$(backoff)
+  log_msg "restart in ${delay}s"
+  sleep "$delay"
+done
diff --git a/tools/curogram-mcp/curogram-auth.ts b/tools/curogram-mcp/curogram-auth.ts
new file mode 100644
index 0000000..ae04914
--- /dev/null
+++ b/tools/curogram-mcp/curogram-auth.ts
@@ -0,0 +1,313 @@
+/// <reference types="bun-types" />
+/**
+ * Curogram session manager.
+ *
+ * Curogram has NO API token. Auth is a cookie + XSRF (Angular convention):
+ * the server sets session cookies on login, and every authenticated call
+ * must echo the XSRF-TOKEN cookie value back in an X-XSRF-TOKEN header.
+ *
+ * This manager:
+ *   1. Logs in via the GraphQL `Login` mutation on api-v2 using the
+ *      CUROGRAM_AGENT_USERNAME / CUROGRAM_AGENT_PASSWORD service creds.
+ *   2. Harvests Set-Cookie + XSRF-TOKEN into an in-memory session.
+ *   3. Optionally pins the active practice tenant (multi-practice account).
+ *   4. Auto-relogins when a call returns 401 (session expired).
+ *
+ * PHI / secrets discipline:
+ *   - The password is read from env once and never logged.
+ *   - Cookie / XSRF values are never logged.
+ *   - Message bodies and patient identifiers are never logged here.
+ *
+ * If the agent account is MFA-gated, `Login` returns an MfaListSchema and
+ * this manager throws a clear, actionable error (no OTP transport is wired
+ * for the unattended server). The documented fallback is a CDP cookie
+ * harvest (see .claude/skills/curogram/scripts/cdp_cookies.py) feeding
+ * CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN env, which this manager honors.
+ */
+
+export const CUROGRAM_HOSTS = {
+  apiV2: "https://api-v2.curogram.com",
+  patients: "https://patients.curogram.com",
+} as const;
+
+const LOGIN_MUTATION =
+  "mutation Login($email: Email!, $password: String!, $source: LoginPage!) {\n" +
+  "  login(email: $email, password: $password, source: $source) {\n" +
+  "    ... on MfaListSchema { mfa { title send id } challenge { value expiresAt } }\n" +
+  "    ... on ProviderTokenSchema { expiresAt accountId }\n" +
+  "  }\n" +
+  "}";
+
+interface Session {
+  /** Full Cookie header string to replay on each request. */
+  cookie: string;
+  /** Value of the XSRF-TOKEN cookie, echoed as X-XSRF-TOKEN. */
+  xsrf: string;
+  /** accountId returned by login, when available. */
+  accountId?: string;
+}
+
+export interface CurogramAuthOptions {
+  username?: string;
+  password?: string;
+  /** Optional pre-harvested cookie (CDP fallback). */
+  cookie?: string;
+  /** Optional pre-harvested XSRF token (CDP fallback). */
+  xsrf?: string;
+  /** Optional practice tenant id to pin after login (multi-practice). */
+  practiceId?: string;
+}
+
+/** Parse the XSRF-TOKEN value out of a Cookie header string. */
+function extractXsrf(cookieHeader: string): string | undefined {
+  for (const part of cookieHeader.split(";")) {
+    const eq = part.indexOf("=");
+    if (eq === -1) continue;
+    const name = part.slice(0, eq).trim();
+    if (name === "XSRF-TOKEN") {
+      return decodeURIComponent(part.slice(eq + 1).trim());
+    }
+  }
+  return undefined;
+}
+
+/**
+ * Collapse a Set-Cookie list into a Cookie header. Bun's Headers exposes
+ * getSetCookie() per the Fetch standard. We keep only name=value (drop
+ * attributes like Path/HttpOnly/Secure) since that's what a Cookie header
+ * carries.
+ */
+function setCookiesToHeader(headers: Headers, prior?: string): string {
+  const jar = new Map<string, string>();
+  // Seed from prior session so partial Set-Cookie responses don't drop
+  // cookies the server didn't re-issue.
+  if (prior) {
+    for (const part of prior.split(";")) {
+      const eq = part.indexOf("=");
+      if (eq === -1) continue;
+      jar.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
+    }
+  }
+  const setCookies =
+    typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [];
+  for (const sc of setCookies) {
+    const first = sc.split(";")[0];
+    const eq = first.indexOf("=");
+    if (eq === -1) continue;
+    jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim());
+  }
+  return Array.from(jar, ([k, v]) => `${k}=${v}`).join("; ");
+}
+
+export class CurogramAuth {
+  private session: Session | null = null;
+  private loginInFlight: Promise<void> | null = null;
+  private readonly opts: CurogramAuthOptions;
+
+  constructor(opts: CurogramAuthOptions) {
+    this.opts = opts;
+  }
+
+  /**
+   * Ensure a live session exists. Concurrent callers share one login.
+   */
+  async ensureSession(): Promise<Session> {
+    if (this.session) return this.session;
+    if (!this.loginInFlight) {
+      this.loginInFlight = this.login().finally(() => {
+        this.loginInFlight = null;
+      });
+    }
+    await this.loginInFlight;
+    if (!this.session) {
+      throw new Error("curogram-auth: login produced no session");
+    }
+    return this.session;
+  }
+
+  /** Force a fresh login (used on 401). */
+  async relogin(): Promise<Session> {
+    this.session = null;
+    return this.ensureSession();
+  }
+
+  private async login(): Promise<void> {
+    // CDP fallback: a pre-harvested cookie/xsrf short-circuits programmatic
+    // login entirely. Useful when the account is MFA-gated.
+    if (this.opts.cookie && this.opts.xsrf) {
+      this.session = { cookie: this.opts.cookie, xsrf: this.opts.xsrf };
+      if (this.opts.practiceId) await this.pinPractice(this.opts.practiceId);
+      return;
+    }
+
+    const { username, password } = this.opts;
+    if (!username || !password) {
+      throw new Error(
+        "curogram-auth: no credentials. Set CUROGRAM_AGENT_USERNAME + " +
+          "CUROGRAM_AGENT_PASSWORD, or provide CUROGRAM_COOKIE + " +
+          "CUROGRAM_XSRF_TOKEN (CDP harvest fallback).",
+      );
+    }
+
+    const res = await fetch(`${CUROGRAM_HOSTS.apiV2}/graphql`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Accept: "application/json",
+        "X-Curogram-Frontend": "web",
+      },
+      body: JSON.stringify({
+        operationName: "Login",
+        query: LOGIN_MUTATION,
+        variables: { email: username, password, source: "PROVIDER" },
+      }),
+    });
+
+    if (!res.ok) {
+      throw new Error(`curogram-auth: login HTTP ${res.status}`);
+    }
+
+    const cookie = setCookiesToHeader(res.headers);
+    const body = (await res.json()) as {
+      data?: { login?: Record<string, unknown> };
+      errors?: Array<{ message?: string }>;
+    };
+
+    if (body.errors && body.errors.length > 0) {
+      // Do not echo server message verbatim if it could contain creds;
+      // Curogram login errors are generic ("Invalid credentials"), so a
+      // short surfaced reason is safe and useful.
+      throw new Error(
+        `curogram-auth: login rejected (${body.errors[0]?.message ?? "unknown"})`,
+      );
+    }
+
+    const login = body.data?.login;
+    if (!login) {
+      throw new Error("curogram-auth: login returned no payload");
+    }
+
+    // MFA-gated: the response is an MfaListSchema (has an `mfa` array).
+    // We cannot complete OTP unattended, so fail with guidance.
+    if ("mfa" in login) {
+      throw new Error(
+        "curogram-auth: account is MFA-gated; unattended login cannot " +
+          "complete OTP. Provide a CDP-harvested session via " +
+          "CUROGRAM_COOKIE + CUROGRAM_XSRF_TOKEN env, or disable MFA on " +
+          "the service account.",
+      );
+    }
+
+    const xsrf = extractXsrf(cookie);
+    if (!cookie || !xsrf) {
+      throw new Error(
+        "curogram-auth: login succeeded but no session cookie / XSRF-TOKEN " +
+          "was set on the response.",
+      );
+    }
+
+    this.session = {
+      cookie,
+      xsrf,
+      accountId:
+        typeof login.accountId === "string" ? login.accountId : undefined,
+    };
+
+    if (this.opts.practiceId) {
+      await this.pinPractice(this.opts.practiceId);
+    }
+  }
+
+  /** Pin the active practice tenant for this session. */
+  private async pinPractice(practiceId: string): Promise<void> {
+    if (!this.session) return;
+    const res = await fetch(
+      `${CUROGRAM_HOSTS.apiV2}/authenticate/practice/${encodeURIComponent(practiceId)}`,
+      { method: "PUT", headers: this.authHeaders(this.session) },
+    );
+    // Merge any refreshed cookies; ignore non-OK (best-effort tenant pin).
+    const merged = setCookiesToHeader(res.headers, this.session.cookie);
+    if (merged) {
+      const xsrf = extractXsrf(merged) ?? this.session.xsrf;
+      this.session = { ...this.session, cookie: merged, xsrf };
+    }
+  }
+
+  /** The 5 required headers for any authenticated Curogram call. */
+  private authHeaders(session: Session): Record<string, string> {
+    return {
+      "X-Curogram-Frontend": "web",
+      "X-XSRF-TOKEN": session.xsrf,
+      Cookie: session.cookie,
+      "Content-Type": "application/json",
+      Accept: "application/json",
+    };
+  }
+
+  /** accountId from the active session, if known. */
+  async accountId(): Promise<string | undefined> {
+    const s = await this.ensureSession();
+    return s.accountId;
+  }
+
+  /**
+   * Authenticated fetch with one automatic relogin on 401. `init.headers`
+   * is merged on top of the 5 required Curogram headers.
+   */
+  async fetch(url: string, init: RequestInit = {}): Promise<Response> {
+    const session = await this.ensureSession();
+    const doFetch = (s: Session): Promise<Response> =>
+      fetch(url, {
+        ...init,
+        headers: { ...this.authHeaders(s), ...(init.headers ?? {}) },
+      });
+
+    let res = await doFetch(session);
+    if (res.status === 401) {
+      const fresh = await this.relogin();
+      res = await doFetch(fresh);
+    }
+    return res;
+  }
+
+  /**
+   * Authenticated GraphQL POST against a Curogram GraphQL host (api-v2 or
+   * patients microservice). Throws on GraphQL errors. Never logs variables
+   * (they may carry patient ids).
+   */
+  async graphql<T = unknown>(
+    host: string,
+    operationName: string,
+    query: string,
+    variables: Record<string, unknown>,
+  ): Promise<T> {
+    const res = await this.fetch(`${host}/graphql`, {
+      method: "POST",
+      body: JSON.stringify({ operationName, query, variables }),
+    });
+    if (!res.ok) {
+      throw new Error(`curogram ${operationName}: HTTP ${res.status}`);
+    }
+    const body = (await res.json()) as {
+      data?: T;
+      errors?: Array<{ message?: string }>;
+    };
+    if (body.errors && body.errors.length > 0) {
+      throw new Error(
+        `curogram ${operationName}: ${body.errors[0]?.message ?? "graphql error"}`,
+      );
+    }
+    return body.data as T;
+  }
+}
+
+/** Build a CurogramAuth from process env. */
+export function authFromEnv(): CurogramAuth {
+  return new CurogramAuth({
+    username: process.env.CUROGRAM_AGENT_USERNAME,
+    password: process.env.CUROGRAM_AGENT_PASSWORD,
+    cookie: process.env.CUROGRAM_COOKIE,
+    xsrf: process.env.CUROGRAM_XSRF_TOKEN,
+    practiceId: process.env.CUROGRAM_PRACTICE_ID,
+  });
+}
diff --git a/tools/curogram-mcp/package.json b/tools/curogram-mcp/package.json
new file mode 100644
index 0000000..86b108a
--- /dev/null
+++ b/tools/curogram-mcp/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "curogram-mcp",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "start": "bun run server.ts",
+    "test": "bun test"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "1.28.0"
+  }
+}
diff --git a/tools/curogram-mcp/server.ts b/tools/curogram-mcp/server.ts
new file mode 100644
index 0000000..6637768
--- /dev/null
+++ b/tools/curogram-mcp/server.ts
@@ -0,0 +1,464 @@
+#!/usr/bin/env bun
+/// <reference types="bun-types" />
+/**
+ * Curogram patient-messaging MCP server (v1 = messaging scope).
+ *
+ * Curogram is the HIPAA-compliant 2-way SMS platform Exult Healthcare uses
+ * for patient outreach. This server calls the same private API the
+ * app.curogram.com dashboard uses (cookie + XSRF, no API token).
+ *
+ * Env:
+ *   CUROGRAM_AGENT_USERNAME / CUROGRAM_AGENT_PASSWORD  (login creds)
+ *   MCP_BEARER_TOKEN                                   (gates HTTP transport)
+ *   CUROGRAM_PRACTICE_ID (optional)                    (pin tenant per session)
+ *   CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN (optional)   (CDP harvest fallback)
+ *
+ * Transport: Streamable HTTP via mcp-shared on MCP_PORTS.curogram (18817).
+ * The Tailscale funnel at /curogram terminates TLS and proxies to
+ * 127.0.0.1:18817.
+ *
+ * SAFETY (Exult-specific):
+ *   - curogram_send_text is CONFIRM-GATED: it refuses unless confirmed:true
+ *     AND the patient's CommunicationPreferences show consent + allowSms.
+ *     Default behavior returns a dry-run preview of what WOULD be sent.
+ *   - PHI: message bodies and patient identifiers are NEVER logged.
+ */
+
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import {
+  ListToolsRequestSchema,
+  CallToolRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+import { serveMcpOverHttp, MCP_PORTS } from "../mcp-shared/index.ts";
+import { authFromEnv, CUROGRAM_HOSTS, type CurogramAuth } from "./curogram-auth.ts";
+
+const auth: CurogramAuth = authFromEnv();
+
+// ---------------------------------------------------------------------------
+// GraphQL operation strings (lifted from the curogram skill's captured ops).
+// ---------------------------------------------------------------------------
+const GQL = {
+  getConversationList:
+    "query GetConversationList($skip: Int, $take: Int, $q: String, $unreadOnly: Boolean) {\n" +
+    "  conversations(skip: $skip, take: $take, q: $q, unreadOnly: $unreadOnly) {\n" +
+    "    totalItemCount\n" +
+    "    items { id title type updatedAt unreadCount lastMessage { text statusUpdate } }\n" +
+    "  }\n}",
+  getPatientList:
+    "query GetPatientList($skip: Int, $take: Int, $q: String) {\n" +
+    "  patients(skip: $skip, take: $take, q: $q) {\n" +
+    "    totalItemCount\n    items { id displayName dob }\n  }\n}",
+  patientInfo:
+    "query PatientInfo($id: ID!) {\n" +
+    "  patient(id: $id) { id displayName dob }\n}",
+  communicationPreferences:
+    "query CommunicationPreferences($patientId: PatientId!) {\n" +
+    "  communicationPreferences(patientId: $patientId) {\n" +
+    "    allowCalls allowEmailMessages allowMarketingMessages allowSmsMessages consent\n" +
+    "  }\n}",
+} as const;
+
+interface CommPrefs {
+  allowCalls: boolean;
+  allowEmailMessages: boolean;
+  allowMarketingMessages: boolean;
+  allowSmsMessages: boolean;
+  consent: boolean;
+}
+
+/** Fetch a patient's comm/consent prefs from the patients microservice. */
+async function getCommPrefs(patientId: string): Promise<CommPrefs> {
+  const data = await auth.graphql<{ communicationPreferences: CommPrefs }>(
+    CUROGRAM_HOSTS.patients,
+    "CommunicationPreferences",
+    GQL.communicationPreferences,
+    { patientId },
+  );
+  return data.communicationPreferences;
+}
+
+/**
+ * Build a fresh MCP Server per session (see rippling-mcp for the rationale:
+ * the SDK's Protocol.connect refuses a second transport on one Server, so
+ * concurrent clients each need their own instance). All shared state lives
+ * in the module-scoped `auth` singleton.
+ */
+function buildServer(): Server {
+  const server = new Server(
+    { name: "curogram-mcp", version: "0.1.0" },
+    { capabilities: { tools: {} } },
+  );
+
+  server.setRequestHandler(ListToolsRequestSchema, async () => ({
+    tools: [
+      // ---- Messaging ----
+      {
+        name: "curogram_unread_count",
+        description: "Get the count of unread Curogram conversations (inbox badge).",
+        inputSchema: { type: "object", properties: {} },
+      },
+      {
+        name: "curogram_list_conversations",
+        description:
+          "List Curogram conversations (inbox). Returns id, title, type, unreadCount, and last message preview. Use unread_only to triage.",
+        inputSchema: {
+          type: "object",
+          properties: {
+            skip: { type: "number", description: "Pagination offset (default 0)" },
+            take: { type: "number", description: "Page size (default 20, max 50)" },
+            q: { type: "string", description: "Optional search query" },
+            unread_only: { type: "boolean", description: "Only unread threads" },
+          },
+        },
+      },
+      {
+        name: "curogram_read_thread",
+        description:
+          "Read messages in a conversation thread, newest-first paginated. Returns the message list for the given conversation id.",
+        inputSchema: {
+          type: "object",
+          properties: {
+            conversation_id: { type: "string", description: "Conversation ObjectId" },
+            skip: { type: "number", description: "Pagination offset (default 0)" },
+            take: { type: "number", description: "Page size (default 20, max 50)" },
+          },
+          required: ["conversation_id"],
+        },
+      },
+      {
+        name: "curogram_send_text",
+        description:
+          "Send a text message to a patient in a conversation. CONFIRM-GATED: without confirmed=true this returns a DRY-RUN preview (what would be sent) and does NOT send. Even with confirmed=true, the send is refused unless the patient's communication preferences show consent AND allowSmsMessages (TCPA). Provide patient_id so the consent check can run.",
+        inputSchema: {
+          type: "object",
+          properties: {
+            conversation_id: { type: "string", description: "Conversation ObjectId to send into" },
+            patient_id: {
+              type: "string",
+              description:
+                "Patient ObjectId for the consent/TCPA check. Required to actually send; omit only for a dry-run preview.",
+            },
+            message: { type: "string", description: "Message text to send" },
+            confirmed: {
+              type: "boolean",
+              description: "Must be true to actually send. Defaults to false (dry-run preview).",
+            },
+            send_securely: {
+              type: "boolean",
+              description: "Send as a secure (link-gated) message. Default false.",
+            },
+          },
+          required: ["conversation_id", "message"],
+        },
+      },
+      {
+        name: "curogram_mark_read",
+        description:
+          "Mark a conversation thread as read (whole thread, or up to a specific message id).",
+        inputSchema: {
+          type: "object",
+          properties: {
+            conversation_id: { type: "string", description: "Conversation ObjectId" },
+            message_id: {
+              type: "string",
+              description: "Optional: mark read up to this message id. Omit to mark the whole thread.",
+            },
+          },
+          required: ["conversation_id"],
+        },
+      },
+      // ---- Patients (read-only) ----
+      {
+        name: "curogram_search_patients",
+        description:
+          "Search patients by name or phone. Returns id, displayName, dob. Use to resolve a patient before addressing or consent-checking a send.",
+        inputSchema: {
+          type: "object",
+          properties: {
+            q: { type: "string", description: "Search term (name or phone)" },
+            skip: { type: "number", description: "Pagination offset (default 0)" },
+            take: { type: "number", description: "Page size (default 20, max 50)" },
+          },
+          required: ["q"],
+        },
+      },
+      {
+        name: "curogram_get_patient",
+        description: "Get a single patient's demographic record by id.",
+        inputSchema: {
+          type: "object",
+          properties: {
+            patient_id: { type: "string", description: "Patient ObjectId" },
+          },
+          required: ["patient_id"],
+        },
+      },
+      {
+        name: "curogram_get_comm_prefs",
+        description:
+          "Get a patient's communication preferences (consent, allowSmsMessages, allowCalls, allowEmailMessages, allowMarketingMessages). Check consent + allowSmsMessages before any outbound text (TCPA).",
+        inputSchema: {
+          type: "object",
+          properties: {
+            patient_id: { type: "string", description: "Patient ObjectId" },
+          },
+          required: ["patient_id"],
+        },
+      },
+    ],
+  }));
+
+  server.setRequestHandler(CallToolRequestSchema, async (req) => {
+    const { name, arguments: args } = req.params;
+    try {
+      switch (name) {
+        case "curogram_unread_count": {
+          const res = await auth.fetch(
+            `${CUROGRAM_HOSTS.apiV2}/conversations/unread-count`,
+          );
+          if (!res.ok) return err(`unread-count HTTP ${res.status}`);
+          return ok(await res.json());
+        }
+
+        case "curogram_list_conversations": {
+          const data = await auth.graphql(
+            CUROGRAM_HOSTS.apiV2,
+            "GetConversationList",
+            GQL.getConversationList,
+            {
+              skip: numArg(args, "skip", 0),
+              take: clampTake(numArg(args, "take", 20)),
+              q: optStr(args, "q"),
+              unreadOnly: optBool(args, "unread_only"),
+            },
+          );
+          return ok(data);
+        }
+
+        case "curogram_read_thread": {
+          const convId = requireString(args, "conversation_id");
+          const skip = numArg(args, "skip", 0);
+          const take = clampTake(numArg(args, "take", 20));
+          const res = await auth.fetch(
+            `${CUROGRAM_HOSTS.apiV2}/conversations/${encodeURIComponent(convId)}/messages?skip=${skip}&take=${take}`,
+          );
+          if (!res.ok) return err(`read-thread HTTP ${res.status}`);
+          return ok(await res.json());
+        }
+
+        case "curogram_send_text":
+          return await handleSendText(args);
+
+        case "curogram_mark_read": {
+          const convId = requireString(args, "conversation_id");
+          const msgId = optStr(args, "message_id");
+          const path = msgId
+            ? `/conversations/${encodeURIComponent(convId)}/messages/mark-read/${encodeURIComponent(msgId)}`
+            : `/conversations/${encodeURIComponent(convId)}/messages/mark-read`;
+          const res = await auth.fetch(`${CUROGRAM_HOSTS.apiV2}${path}`, {
+            method: "POST",
+          });
+          if (!res.ok) return err(`mark-read HTTP ${res.status}`);
+          return ok({ marked_read: true, conversation_id: convId });
+        }
+
+        case "curogram_search_patients": {
+          const data = await auth.graphql(
+            CUROGRAM_HOSTS.apiV2,
+            "GetPatientList",
+            GQL.getPatientList,
+            {
+              q: requireString(args, "q"),
+              skip: numArg(args, "skip", 0),
+              take: clampTake(numArg(args, "take", 20)),
+            },
+          );
+          return ok(data);
+        }
+
+        case "curogram_get_patient": {
+          const data = await auth.graphql(
+            CUROGRAM_HOSTS.apiV2,
+            "PatientInfo",
+            GQL.patientInfo,
+            { id: requireString(args, "patient_id") },
+          );
+          return ok(data);
+        }
+
+        case "curogram_get_comm_prefs": {
+          const prefs = await getCommPrefs(requireString(args, "patient_id"));
+          return ok(prefs);
+        }
+
+        default:
+          return err(`Unknown tool: ${name}`);
+      }
+    } catch (e) {
+      return err(e instanceof Error ? e.message : String(e));
+    }
+  });
+
+  return server;
+}
+
+/**
+ * Confirm-gated, consent-checked send. The default path returns a dry-run
+ * preview without contacting the send endpoint.
+ */
+async function handleSendText(
+  args: Record<string, unknown> | undefined,
+): Promise<ToolResult> {
+  const convId = requireString(args, "conversation_id");
+  const message = requireString(args, "message");
+  const confirmed = optBool(args, "confirmed") === true;
+  const patientId = optStr(args, "patient_id");
+  const sendSecurely = optBool(args, "send_securely") === true;
+
+  // Gate 1: explicit confirmation. Without it, never touch the wire.
+  if (!confirmed) {
+    return ok({
+      sent: false,
+      reason: "not_confirmed",
+      note: "Dry run. Set confirmed=true (and provide patient_id for the consent check) to actually send.",
+      would_send: {
+        conversation_id: convId,
+        send_securely: sendSecurely,
+        message_length: message.length,
+      },
+    });
+  }
+
+  // Gate 2: TCPA consent. We require a patient_id so consent can be verified.
+  if (!patientId) {
+    return ok({
+      sent: false,
+      reason: "consent_uncheckable",
+      note: "patient_id is required to verify TCPA consent (consent + allowSmsMessages) before sending. Resolve the patient first.",
+      would_send: { conversation_id: convId, message_length: message.length },
+    });
+  }
+
+  let prefs: CommPrefs;
+  try {
+    prefs = await getCommPrefs(patientId);
+  } catch (e) {
+    return ok({
+      sent: false,
+      reason: "consent_check_failed",
+      note: `Could not verify communication preferences: ${e instanceof Error ? e.message : String(e)}. Refusing to send.`,
+    });
+  }
+
+  if (!prefs.consent || !prefs.allowSmsMessages) {
+    return ok({
+      sent: false,
+      reason: "no_consent",
+      note: "Patient has not consented to SMS (consent and/or allowSmsMessages is false). Refusing to send per TCPA.",
+      prefs: { consent: prefs.consent, allowSmsMessages: prefs.allowSmsMessages },
+    });
+  }
+
+  // All gates passed -> send.
+  const res = await auth.fetch(
+    `${CUROGRAM_HOSTS.apiV2}/conversations/${encodeURIComponent(convId)}/messages`,
+    {
+      method: "POST",
+      body: JSON.stringify({ message, sendSecurely }),
+    },
+  );
+  if (!res.ok) return err(`send HTTP ${res.status}`);
+
+  // x-frequency-warning: 5 means rate-limit hit -> surface so caller backs off.
+  const freqWarning = res.headers.get("x-frequency-warning");
+  const created = await res.json();
+  return ok({
+    sent: true,
+    conversation_id: convId,
+    rate_limit_warning: freqWarning ?? null,
+    message: created,
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Result + argument helpers (mirrors rippling-mcp conventions).
+// ---------------------------------------------------------------------------
+interface ToolResult {
+  content: Array<{ type: "text"; text: string }>;
+  isError?: boolean;
+}
+
+function ok(data: unknown): ToolResult {
+  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+function err(msg: string): ToolResult {
+  return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
+}
+
+function requireString(
+  args: Record<string, unknown> | undefined,
+  key: string,
+): string {
+  const v = args?.[key];
+  if (typeof v !== "string" || v.length === 0) {
+    throw new Error(`Missing required string argument: ${key}`);
+  }
+  return v;
+}
+
+function optStr(
+  args: Record<string, unknown> | undefined,
+  key: string,
+): string | undefined {
+  const v = args?.[key];
+  return typeof v === "string" && v.length > 0 ? v : undefined;
+}
+
+function optBool(
+  args: Record<string, unknown> | undefined,
+  key: string,
+): boolean | undefined {
+  const v = args?.[key];
+  return typeof v === "boolean" ? v : undefined;
+}
+
+function numArg(
+  args: Record<string, unknown> | undefined,
+  key: string,
+  fallback: number,
+): number {
+  const v = args?.[key];
+  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
+}
+
+/** Clamp page size to a sane ceiling. */
+function clampTake(take: number): number {
+  if (take <= 0) return 20;
+  return Math.min(take, 50);
+}
+
+// ---------------------------------------------------------------------------
+// Boot.
+// ---------------------------------------------------------------------------
+const token = process.env.MCP_BEARER_TOKEN;
+if (!token) {
+  process.stderr.write("curogram-mcp: MCP_BEARER_TOKEN required\n");
+  process.exit(1);
+}
+
+try {
+  serveMcpOverHttp({
+    serverFactory: buildServer,
+    port: MCP_PORTS.curogram,
+    token,
+  });
+} catch (e) {
+  const msg = e instanceof Error ? e.message : String(e);
+  process.stderr.write(`curogram-mcp: fatal startup error: ${msg}\n`);
+  process.exit(1);
+}
+
+process.stderr.write(
+  `curogram-mcp: server started on http :${MCP_PORTS.curogram} (v0.1.0)\n`,
+);
diff --git a/tools/mcp-config/channels/curogram.ts b/tools/mcp-config/channels/curogram.ts
new file mode 100644
index 0000000..dbcd1f2
--- /dev/null
+++ b/tools/mcp-config/channels/curogram.ts
@@ -0,0 +1,35 @@
+/**
+ * Curogram patient-messaging channel.
+ *
+ * v1 (messaging scope): served as Streamable HTTP from a single VM-hosted
+ * bun process (port 18817, fronted by Tailscale funnel at /curogram).
+ * Clients connect over HTTP with a bearer token instead of spawning a
+ * local subprocess. The same per-host renderer fills in the bearer at
+ * render time from the host's gitignored .env file; tests substitute a
+ * redacted placeholder so goldens stay reproducible.
+ *
+ * Server-side auth: the supervisor process reads CUROGRAM_AGENT_USERNAME /
+ * CUROGRAM_AGENT_PASSWORD from its own gitignored env file (NOT the
+ * rendered .mcp.json) and logs in to Curogram to mint a cookie/XSRF
+ * session. Those creds are never embedded in the client config -- only
+ * MCP_BEARER_TOKEN is, exactly like the rippling channel.
+ */
+
+import type { ChannelDef } from "../types.ts";
+
+export const curogram: ChannelDef = {
+  name: "curogram",
+  defaultLaunchStyle: "http",
+  toolDirRel: "tools/curogram-mcp",
+  entryFile: "server.ts",
+  // See advancedmd.ts for rationale -- hard-coded claude-cloud URL so every
+  // consuming host reaches the real server, not its own tailnet.
+  httpUrl: () => "https://claude-cloud.tail053faf.ts.net/curogram/mcp",
+  httpBearerEnvKey: "MCP_BEARER_TOKEN",
+  env: [
+    // Client-side env: the bearer token used to populate the
+    // Authorization header at render time. Not embedded in the launched
+    // process env -- it lives in the rendered headers block instead.
+    { key: "MCP_BEARER_TOKEN", kind: "secret", required: true },
+  ],
+};
diff --git a/tools/mcp-config/channels/index.ts b/tools/mcp-config/channels/index.ts
index d11ab6a..5a8d380 100644
--- a/tools/mcp-config/channels/index.ts
+++ b/tools/mcp-config/channels/index.ts
@@ -15,6 +15,7 @@ import { teams } from "./teams.ts";
 import { teamsMcp } from "./teams-mcp.ts";
 import { bluebubbles } from "./bluebubbles.ts";
 import { advancedmd } from "./advancedmd.ts";
+import { curogram } from "./curogram.ts";
 import { rippling } from "./rippling.ts";
 import { ringcentral } from "./ringcentral.ts";
 import { ringcentralAdmin } from "./ringcentral-admin.ts";
@@ -27,6 +28,7 @@ export const channels: Record<string, ChannelDef> = {
   "teams-mcp": teamsMcp,
   bluebubbles,
   advancedmd,
+  curogram,
   rippling,
   ringcentral,
   // Hyphenated channel name: cannot be a bare identifier, so use the
@@ -53,6 +55,7 @@ export {
   teamsMcp,
   bluebubbles,
   advancedmd,
+  curogram,
   rippling,
   ringcentral,
   ringcentralAdmin,
diff --git a/tools/mcp-config/golden/claude-cloud.mcp.json b/tools/mcp-config/golden/claude-cloud.mcp.json
index 38acd20..60aef0b 100644
--- a/tools/mcp-config/golden/claude-cloud.mcp.json
+++ b/tools/mcp-config/golden/claude-cloud.mcp.json
@@ -7,6 +7,13 @@
       "type": "streamable-http",
       "url": "https://claude-cloud.tail053faf.ts.net/advancedmd/mcp"
     },
+    "curogram": {
+      "headers": {
+        "Authorization": "Bearer <REDACTED_MCP_BEARER_TOKEN>"
+      },
+      "type": "streamable-http",
+      "url": "https://claude-cloud.tail053faf.ts.net/curogram/mcp"
+    },
     "docstrange": {
       "headers": {
         "Authorization": "Bearer <REDACTED_MCP_BEARER_TOKEN>"
diff --git a/tools/mcp-config/hosts/claude-cloud.env.example b/tools/mcp-config/hosts/claude-cloud.env.example
index bf16929..7f8380a 100644
--- a/tools/mcp-config/hosts/claude-cloud.env.example
+++ b/tools/mcp-config/hosts/claude-cloud.env.example
@@ -10,9 +10,18 @@ MSTEAMS_APP_ID=
 MSTEAMS_APP_PASSWORD=
 MSTEAMS_TENANT_ID=
 
-# advancedmd (Phase B), rippling + ringcentral (Phase C): bearer token sent
+# advancedmd (Phase B), rippling + ringcentral + curogram: bearer token sent
 # in the Authorization header of the rendered mcpServers["advancedmd"],
-# mcpServers["rippling"], and mcpServers["ringcentral"] entries. Read once
-# from ~/.config/mcp-bearer-token and shared with the per-server supervisors'
-# ~/.config/<name>-mcp.env on the VM.
+# mcpServers["rippling"], mcpServers["ringcentral"], and
+# mcpServers["curogram"] entries. Read once from ~/.config/mcp-bearer-token
+# and shared with the per-server supervisors' ~/.config/<name>-mcp.env on the VM.
 MCP_BEARER_TOKEN=
+
+# curogram (v1 messaging): the supervisor (~/run-curogram-mcp.sh) reads these
+# from its OWN gitignored env file (~/.config/curogram-mcp.env, mode 0600) --
+# NOT from this client-side .env and NOT embedded in the rendered .mcp.json.
+# Listed here only for documentation. The server logs in to Curogram with
+# these to mint a cookie/XSRF session and auto-relogins on 401.
+#   CUROGRAM_AGENT_USERNAME=agent@exulthealthcare.com
+#   CUROGRAM_AGENT_PASSWORD=...
+#   MCP_BEARER_TOKEN=...   (same token as above; gates the HTTP transport)
diff --git a/tools/mcp-config/hosts/claude-cloud.ts b/tools/mcp-config/hosts/claude-cloud.ts
index 0a05cc0..03b8f43 100644
--- a/tools/mcp-config/hosts/claude-cloud.ts
+++ b/tools/mcp-config/hosts/claude-cloud.ts
@@ -44,6 +44,13 @@ export const host: HostDef = {
       // .env at render time and embedded in the Authorization header.
       channel: "advancedmd",
     },
+    {
+      // Curogram v1 (messaging): HTTP-launched MCP on :18817, funneled at
+      // /curogram. Supervisor: ~/run-curogram-mcp.sh reads CUROGRAM_AGENT_*
+      // creds from its own env file and logs in to mint a session. Bearer
+      // token is read from the per-host .env at render time.
+      channel: "curogram",
+    },
     {
       // Phase C: HTTP-launched MCP. Bearer token is read from the per-host
       // .env at render time and embedded in the Authorization header.
diff --git a/tools/mcp-config/render.test.ts b/tools/mcp-config/render.test.ts
index 9a97031..7b8f40a 100644
--- a/tools/mcp-config/render.test.ts
+++ b/tools/mcp-config/render.test.ts
@@ -146,6 +146,7 @@ describe("mcp-config renderer parity (phase 1)", () => {
       allowMissing: new Set([
         "sendblue",
         "advancedmd",
+        "curogram",
         "rippling",
         "ringcentral",
         "ringcentral-admin",
@@ -156,6 +157,7 @@ describe("mcp-config renderer parity (phase 1)", () => {
     });
     expect(result.skippedChannels).toContain("sendblue");
     expect(result.skippedChannels).toContain("advancedmd");
+    expect(result.skippedChannels).toContain("curogram");
     expect(result.skippedChannels).toContain("rippling");
     expect(result.skippedChannels).toContain("ringcentral");
     expect(result.skippedChannels).toContain("ringcentral-admin");
@@ -164,6 +166,7 @@ describe("mcp-config renderer parity (phase 1)", () => {
     expect(result.skippedChannels).toContain("teams-mcp");
     expect(result.json).not.toContain("sendblue-channel");
     expect(result.json).not.toContain('"advancedmd"');
+    expect(result.json).not.toContain('"curogram"');
     expect(result.json).not.toContain('"rippling"');
     expect(result.json).not.toContain('"ringcentral"');
     expect(result.json).not.toContain('"ringcentral-admin"');
@@ -281,6 +284,20 @@ describe("mcp-config renderer parity (phase 1)", () => {
     expect(ripp.command).toBeUndefined();
     expect((ripp as { args?: unknown }).args).toBeUndefined();
     expect((ripp as { env?: unknown }).env).toBeUndefined();
+
+    // Curogram v1 (messaging) renders the same shape on the same host.
+    const curo = parsed.mcpServers["curogram"];
+    expect(curo).toBeDefined();
+    expect(curo.type).toBe("streamable-http");
+    expect(curo.url).toBe(
+      "https://claude-cloud.tail053faf.ts.net/curogram/mcp",
+    );
+    expect(curo.headers?.Authorization).toBe(
+      "Bearer <REDACTED_MCP_BEARER_TOKEN>",
+    );
+    expect(curo.command).toBeUndefined();
+    expect((curo as { args?: unknown }).args).toBeUndefined();
+    expect((curo as { env?: unknown }).env).toBeUndefined();
   });
 
   test("dotenv parser handles quoted + unquoted values", () => {
diff --git a/tools/mcp-shared/ports.ts b/tools/mcp-shared/ports.ts
index a8132b4..31b3b51 100644
--- a/tools/mcp-shared/ports.ts
+++ b/tools/mcp-shared/ports.ts
@@ -19,6 +19,7 @@ export const MCP_PORTS = {
   "ringcentral-admin": 18814,
   docstrange: 18815,
   rippling: 18816,
+  curogram: 18817,
 } as const;
 
 export type McpPortName = keyof typeof MCP_PORTS;
