TL;DR
- Roam's hosted MCP server exposes 56 tools for meetings, chat, calendar, OnAir, and Magicasts. The v1 API adds streamed replies, polls, presence, group admin, audit logs, message export, and SCIM.
- OAuth MCP connections are Personal access: the agent sees only meetings the user attended and posts as their personal bot. Roam-wide meeting access needs an org API key.
- Roam has no client_credentials grant. OAuth access tokens last 7 days, and refresh tokens rotate.
- Default OAuth MCP grants include every scope, writes included, so least privilege has to come from your tool surface.
- Scalekit's Roam MCP connector handles DCR, token storage, and refresh per user. Virtual MCP servers scope the 56 tools per agent role, and every call lands in per-user tool call logs.
The Roam decision your agent is facing
Your agent needs to work inside Roam: pull yesterday's meeting summaries, chase action items, post into a group, schedule the follow-up. Roam ships a hosted MCP server and a documented v1 REST API, and both reach meetings and chat.
They are not interchangeable. They differ on what the agent can touch, whose identity it acts as, and how much of the workspace it can see. For a multi-tenant product, Roam's Personal vs Organization access split matters more than the transport. Here's how to pick.
What Roam MCP and Roam API actually are
Two surfaces, one vendor, one OAuth server. Start by confirming you are looking at the right Roam.
First, which Roam
This article is about Roam HQ, the virtual office product: chat, video meetings, AI transcripts, Magicast recordings, OnAir events, and a shared office map. Roam Research is an unrelated note-taking tool. Searches for "Roam MCP" mix the two, and many of the Roam Research results are community-built servers for a knowledge graph. Scalekit's Roam MCP connector targets Roam HQ.
The Roam MCP server
Roam maintains a hosted, remote MCP server on its API host, served over Streamable HTTP. Every tool maps to a Roam API endpoint and uses the same OAuth scopes.
Clients can authenticate three ways:
- OAuth. The MCP authorization flow with Dynamic Client Registration (DCR), with S256 PKCE required for public clients.
- Personal Access Token. A user-created token (rmp-) sent as a bearer token.
- Organization API key. An admin-issued key (rmk-) sent as a bearer token.
The server also ships eight built-in prompts, such as morning_brief and weekly_digest. By default, a Roam admin must approve every new MCP connection before it can access data.
The Roam API
The v1 REST API uses RPC-style methods such as /chat.post, /meeting.list, and /group.members. Responses use an ok envelope, and a dated Roam-Version header pins their shape. Alongside it sit the OnAir API, a Webhooks API with dotted event names like meeting.ended, and a SCIM 2.0 API. The frozen v0 Alpha surface remains for existing callers.
Auth is always a bearer token: an admin-issued API key, an OAuth access token, or a PAT. Roam also publishes TypeScript, Go, and Python SDKs.
Comparing them where it matters for agents
Four dimensions decide this: capability coverage, auth model, operational surface, and fit. The Roam-specific twist is that the access model often decides more than the transport.
What your agent can actually do
Both paths cover the core agent loop. Scalekit catalogs all 56 server tools, prefixed roammcp_. Roam's own tool reference table lists fewer, so check the live list before hardcoding names.
Meeting list with summaries, action items, chapters
Ask a question about one meeting
Natural-language meeting search
Chat, calendar, and events on both paths
Outside meetings, the overlap covers the actions an assistant takes on a user's behalf.
Chat history, search, post, edit bot messages
Calendar events with a Roam meeting link
OnAir events, guests, attendance
Subscribe, unsubscribe, failed deliveries
Where only the API reaches
The gap concentrates in real-time chat UX, workspace administration, and compliance.
Polls, ephemeral messages, typing indicator, link unfurls
Partial: create, join, list, info
Full: members, add, remove, rename, archive
Presence: Will Return, status bubbles, map activity
User audit log, daily message export
Where the gap bites
Agent builders usually hit the streaming gap first. Through MCP, an agent replying in Roam chat can post a message and then edit it. It cannot stream tokens into one message the way chat.startStream, chat.appendStream, and chat.stopStream allow.
The compliance gap is structural. messageevent.export and userauditlog.list are Organization-only, and the OAuth MCP path is Personal.
MCP does have one thing the API lacks: its eight built-in prompts. Those are MCP prompts, not tools, so a tool-calling agent should encode the same workflows in its own system prompt.
The auth path each one puts you on
On the MCP path, a client registers itself through DCR, runs Authorization Code with S256 PKCE, and gets a token bound to the MCP resource. Connections created this way are Personal access.
Dynamically registered clients that don't request specific scopes receive Roam's default MCP scope set. That set covers every tool, including writes like chat_post, calendar_event_create, and the OnAir tools. Per-connection scope selection for OAuth clients is planned but not shipped.
Roam also advertises every tool regardless of the scopes you were granted. Calling an out-of-scope tool returns 403.
What the API auth path adds
The REST API accepts three credentials:
- API key. Admin-issued, Organization access, posts with the app's bot persona.
- OAuth app. Installed as Organization (admin consent) or Personal (user consent), chosen at authorization time.
- PAT. Personal access. Lets the user pick narrow scope groups, but carries a 1,000-request daily quota.
There is no client_credentials grant; every call binds to a user or a Roam install. OAuth access tokens last 7 days and refresh tokens last 365 days. A refresh usually returns a rotated refresh token, and the old one should be discarded.
Access model decides how much of Roam the agent sees
This is the Roam-specific trap.
A Personal token sees only meetings the user attended, and it posts as that user's personal bot, a distinct persona such as "Alex's Notetaker". It never gets admin:meetings:read, because Roam strips that scope on Personal authorization. An Organization token without that scope usually sees an empty meeting set.
So a CRM-sync agent that must process every recorded call needs an org API key on either path. Custom MCP clients can send an rmk- key as a bearer token.
Personal access isolates per user by design. Organization access isolates per workspace.
What you own in production
On the MCP path. Roam maintains the tool schemas and model-facing descriptions. The meeting_list description, for example, tells the model to skip date filters for recent requests and use expand instead of fanning out. You still own token storage, refresh, revocation, and the mapping from your users to Roam grants. MCP tool schemas change when Roam updates the server.
On the API path. You own everything: schemas, cursors, the ok envelope, retries, and version pinning. Each Roam API client is created on the latest Roam-Version and never advances automatically. That prevents silent shape changes, but upgrades become your job.
Failure modes worth handling on both paths
Roam doesn't document separate MCP limits, so budget MCP calls against the API limits.
- Rate limits. Roam allows a burst of 10 requests, then 1 request per second sustained, with Retry-After on 429. A recap agent that calls meeting_info once per meeting hits this fast.
- Auth failures. invalid_token is worth a refresh. token_revoked is terminal: stop retrying and re-authorize.
- Transcripts. transcript_pending means wait. transcript_unavailable means the transcript will never exist.
- Webhooks. v1 deliveries can arrive twice with the same webhook-id, so deduplicate on it.
Use Roam MCP when
- You are building an interactive assistant where a user asks about their own meetings and chats, in Claude, Codex, or your own chat UI.
- The job is per-user meeting intelligence: recaps, action-item tracking, meeting prep from the user's calendar.
- You want Roam to own tool schemas and descriptions, and you scope the tool surface at your own layer.
- You orchestrate Roam alongside other MCP tools and want one transport for tool listing and execution.
Use the Roam API when
- The agent replies inside Roam chat and needs streamed responses, polls, typing indicators, or ephemeral messages.
- It runs headless and workspace-wide, such as syncing every call to a CRM with an org key holding admin:meetings:read.
- You need audit logs, message export, group membership, SCIM, or Guest Badges.
- You need Roam's consent screen to enforce narrow scopes, which OAuth MCP grants do not offer yet.
The credential problem that exists on both paths
Pick either path and you still hold one Roam credential per user or per workspace. Neither path gives you a vault, refresh coordination, or a revocation handler.
N users, N workspaces, N approval queues
Picture a B2B agent serving 200 customer workspaces on Personal access. It holds a Roam grant for every user who connected, and each access token expires in 7 days.
Refresh tokens rotate, so two workers refreshing the same grant can race. The loser may be holding a refresh token Roam has already replaced. Revocation arrives as a token.revoked webhook you have to subscribe to and handle. Each workspace's approval policy can also leave a new connection pending until an admin acts.
The token type differs between MCP and API. The storage, refresh, and revocation plumbing does not. For a deeper look at this challenge, see how to handle token refresh for AI agents.
Where Scalekit fits
Scalekit's Roam MCP connector runs DCR and OAuth 2.1 against Roam, stores each user's tokens in its vault, and refreshes them. Credentials never touch the agent runtime. The agent calls execute_tool with a user identifier, and Scalekit resolves that user's connected account.
For API-only endpoints, a custom connector routes REST calls through the same connected-account model. The MCP vs API choice stops being an auth infrastructure decision.
Building a Roam agent with Scalekit in Python
This walkthrough uses Python, the Scalekit SDK, and LangChain. It follows the connected-account model: authorize once, retrieve the tools this user can call, then execute.
Prerequisites and the connection name
First, create a Roam MCP connection in the Scalekit dashboard under AgentKit > Connections. Set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET in your .env file.
The connection_name in every call must exactly match the connection name in the dashboard. roammcp is the name Scalekit's docs use, and a mismatch here is the most common integration error. Full tool schemas live on the Roam MCP connector docs.
pip install scalekit-sdk-python python-dotenv langchain-anthropic "langchain-mcp-adapters>=0.3,<1"
Authorize each Roam user once
Each user completes Roam's consent screen once. If their workspace uses Roam's default Require Approval policy, tool calls fail until an admin approves the connection in Roam Administration > Developer.
import os
from dotenv import load_dotenv
from scalekit import ScalekitClient
load_dotenv()
scalekit_client = ScalekitClient(
env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
)
actions = scalekit_client.actions
# Must match the connection name configured in AgentKit > Connections exactly
CONNECTION_NAME = "roammcp"
IDENTIFIER = "user_123" # your app's stable user ID
def ensure_roam_connected(identifier: str) -> None:
response = actions.get_or_create_connected_account(
connection_name=CONNECTION_NAME,
identifier=identifier,
)
if response.connected_account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name=CONNECTION_NAME,
identifier=identifier,
)
print("Authorize Roam:", link.link)
input("Press Enter after completing Roam consent...")
response = actions.get_or_create_connected_account(
connection_name=CONNECTION_NAME,
identifier=identifier,
)
if response.connected_account.status != "ACTIVE":
raise RuntimeError(
f"Roam connection is {response.connected_account.status}. "
"Authorize it and run again."
)
ensure_roam_connected(IDENTIFIER)
Retrieve the tools this user is authorized to call
Before any agent loop runs, load the tool surface for this user. list_scoped_tools does not return a connector catalog. It returns the tools this user's connected account is authorized to call. execute_tool then runs one of them, with Scalekit injecting the user's Roam credentials.
response, _ = scalekit_client.tools.list_scoped_tools(
identifier=IDENTIFIER,
filter={"connection_names": [CONNECTION_NAME]},
page_size=100, # Roam exposes 56 tools; the default page can truncate
)
tool_names = [scoped.tool.definition["name"] for scoped in response.tools]
print(len(tool_names), "tools authorized for", IDENTIFIER)
result = actions.execute_tool(
tool_name="roammcp_meeting_list",
connection_name=CONNECTION_NAME,
identifier=IDENTIFIER,
tool_input={"limit": 5, "expand": "summary,actionItems"},
)
print(result.data)
Run a LangChain agent on a scoped Roam surface
An action-item agent needs 5 of the 56 tools. Passing tool_names to get_tools keeps the other 51 out of context.
At Scalekit's rule of thumb of about 200 tokens per tool, 56 tools cost roughly 11,000 tokens before the agent does any work. Treat that as a floor: Roam's descriptions run long, and the chat_search description alone is several hundred words. Scoping to five tools removes over 90% of that overhead. For more on this, see token-efficient tool calling strategies.
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
ROAM_TOOLS = [
"roammcp_get_me",
"roammcp_meeting_list",
"roammcp_meeting_info",
"roammcp_meeting_transcript",
"roammcp_chat_post",
]
tools = actions.langchain.get_tools(
identifier=IDENTIFIER,
connection_names=[CONNECTION_NAME],
tool_names=ROAM_TOOLS,
page_size=100,
)
tool_map = {t.name: t for t in tools}
llm = ChatAnthropic(model="claude-sonnet-5").bind_tools(tools)
messages = [
SystemMessage(
"You summarize Roam meetings. Prefer meeting_list with expand before "
"calling meeting_info. Only fetch a transcript if the summary lacks the answer."
),
HumanMessage(
"Collect my open action items from this week's meetings and DM them to me in Roam."
),
]
while True:
response = llm.invoke(messages)
messages.append(response)
if not response.tool_calls:
print(response.content)
break
for tc in response.tool_calls:
result = tool_map[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
Multi-tool agents: one Virtual MCP server for Roam and Linear
Most Roam agents act somewhere else too. A post-meeting agent that files Linear issues needs Roam read tools plus one Linear write tool, for every user in every tenant.
A Virtual MCP server declares that surface once per agent role and returns a static URL. There is no MCP server to deploy or host. Tool names for the second connection are on the Linear connector docs. For a broader discussion of how tool calling auth changes when you move from single-tenant to multi-tenant, see our dedicated guide.
from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping
vmcp = scalekit_client.actions.mcp.create_config(
name="roam-action-item-agent",
description="Reads Roam meeting summaries, files Linear issues",
connection_tool_mappings=[
McpConfigConnectionToolMapping(
connection_name="roammcp", # must match AgentKit > Connections
tools=[
"roammcp_meeting_list",
"roammcp_meeting_info",
"roammcp_chat_post",
],
),
McpConfigConnectionToolMapping(
connection_name="linear", # must match AgentKit > Connections
tools=["linear_teams_list", "linear_issue_create"],
),
],
)
config_id = vmcp.config.id
mcp_server_url = vmcp.config.mcp_server_url # static; store it with the agent definition
Mint a per-user session token and run
Before each run, confirm the user's Roam and Linear accounts are both active. Then mint a short-lived session token bound to that user and pass the URL and token to the agent. One server definition serves every user: the endpoint stays static while the identity changes per run.
import asyncio
from datetime import timedelta
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
config_id = os.environ["SCALEKIT_MCP_CONFIG_ID"]
mcp_server_url = os.environ["SCALEKIT_MCP_SERVER_URL"]
def mint_token_for(identifier: str) -> str:
accounts = scalekit_client.actions.mcp.list_mcp_connected_accounts(
config_id=config_id,
identifier=identifier,
include_auth_link=True,
)
pending = [
(a.connection_name, a.authentication_link)
for a in accounts.connected_accounts
if a.connected_account_status != "ACTIVE"
]
if pending:
raise RuntimeError(f"Re-authorization needed: {pending}")
return scalekit_client.actions.mcp.create_session_token(
mcp_config_id=config_id,
identifier=identifier,
expiry=timedelta(minutes=30),
).token
async def run(identifier: str, task: str) -> None:
token = mint_token_for(identifier)
client = MultiServerMCPClient(
{
"scalekit": {
"transport": "streamable_http",
"url": mcp_server_url,
"headers": {"Authorization": f"Bearer {token}"},
}
}
)
tools = await client.get_tools()
tool_map = {t.name: t for t in tools}
llm = ChatAnthropic(model="claude-sonnet-5").bind_tools(tools)
messages = [HumanMessage(task)]
while True:
response = await llm.ainvoke(messages)
messages.append(response)
if not response.tool_calls:
print(response.content)
break
for tc in response.tool_calls:
result = await tool_map[tc["name"]].ainvoke(tc["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
asyncio.run(run("user_123", "File a Linear issue for every open action item from yesterday's standup."))
Taking the API path through Scalekit
Scalekit's catalog ships Roam as an MCP connector. For API-only endpoints such as polls or streaming, register a custom REST connector that points Scalekit's Tool Proxy at Roam's v1 API.
The setup takes three steps:
- Create an OAuth app in Roam Administration > Developer.
- Create the custom connector with the payload below.
- Add a connection for it in the dashboard with the Roam app's client ID and secret, and register Scalekit's redirect URI on the Roam app.
For a headless, workspace-wide agent, use a BEARER auth pattern instead, so each customer's admin supplies an rmk- key once.
{
"display_name": "Roam API",
"description": "Roam HQ v1 REST API through Scalekit Tool Proxy",
"auth_patterns": [
{
"type": "OAUTH",
"display_name": "OAuth 2.0",
"description": "Authorize with a Roam OAuth app registered in Roam Administration > Developer",
"fields": [],
"oauth_config": {
"authorize_uri": "https://ro.am/oauth/authorize",
"token_uri": "https://ro.am/oauth/token",
"user_info_uri": "https://api.ro.am/v1/token.info",
"available_scopes": [
{"scope": "chat:history", "display_name": "Read chat history", "description": "Read messages in chats the user can access", "required": true},
{"scope": "chat:send_message", "display_name": "Send messages", "description": "Post messages and polls", "required": true},
{"scope": "meetings:read", "display_name": "Read meetings", "description": "Meetings, summaries, and transcripts the user attended", "required": false},
{"scope": "user:read", "display_name": "Read users", "description": "Resolve workspace members", "required": false}
]
}
}
],
"proxy_url": "https://api.ro.am/v1",
"proxy_enabled": true
}
# Get a Scalekit management token
curl --location "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$SCALEKIT_CLIENT_ID" \
--data-urlencode "client_secret=$SCALEKIT_CLIENT_SECRET"
# Create the custom Roam REST connector
curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers" \
--header "Authorization: Bearer $env_access_token" \
--header "Content-Type: application/json" \
--data @roam-api-connector.json
Calling an API-only endpoint
Once a user authorizes the new connection, actions.request proxies calls with their credentials. The path is relative to the connector's proxy_url. Pin Roam-Version explicitly, and stay under the 1 request per second sustained limit.
ROAM_API_CONNECTION = "roam-api" # must match the connection name in AgentKit > Connections
ROAM_VERSION = {"Roam-Version": "2026-08-25"}
def roam_post_poll(identifier: str, group_id: str, question: str, options: list[str]) -> dict:
response = actions.request(
connection_name=ROAM_API_CONNECTION,
identifier=identifier,
method="POST",
path="/chat.post",
headers=ROAM_VERSION,
body={
"groupId": group_id,
"poll": {"question": question, "options": options},
},
)
data = response.json()
if not data.get("ok"):
raise ValueError(f"Roam error: {data.get('error')}")
return {"chatId": data.get("chatId")}
What going through Scalekit buys a Roam agent
The auth plumbing is the obvious win. Three other benefits show up once the agent runs for real users.
Tool call logs, per user
AgentKit records every tool call with its timestamp, connection, tool name, user identifier, and latency. Failed calls also carry the error code and full message.
The dashboard separates two kinds of failure:
- Connector errors. Roam itself returned the error.
- API errors. The call was rejected before it left Scalekit, for example because of invalid parameters or an expired token.
Say meeting_transcript starts failing for one customer. You filter to that connection and see whether it is Roam's transcript_pending, a missing scope, or a revoked grant, and exactly which users were affected. For more on surfacing this data effectively, see agent tool observability.
Virtual MCP for multi-tool, multi-tenant agents
Roam's OAuth MCP grants carry the full default scope set, so Roam will not narrow an agent's access for you. A Virtual MCP server enforces least privilege at the tool level: a recap agent whose surface excludes chat_delete and onair_event_cancel cannot call them.
One definition serves every tenant, and per-run session tokens keep each user's Roam grant isolated. What the user can't do, the agent can't do. Understanding credential ownership across agent tool-calling patterns is key to getting this isolation right.
One credential model across both paths
Built-in MCP connectors and custom REST connectors share connections, connected accounts, and the same authorization flow. Starting on MCP and adding an API-only capability later does not add a second credential system.
Which one to build against
Choose by what the agent has to do:
- Start with Roam MCP through Scalekit if your agent answers one user's questions about their own Roam meetings and chats. Scope it with a Virtual MCP server.
- Build against the API for the parts that reply inside Roam chat with streaming or polls, run headless across a whole workspace, or touch compliance and admin surfaces.
Most production Roam agents end up using both. The credential problem is identical on either side, which is why it belongs in infrastructure, not in your agent code. For a broader look at agent tool calling auth production patterns and anti-patterns, see our in-depth guide.
Start building your Roam agent
Roam agent builders can get implementation help from the Scalekit team: talk to us.