MCP
Connect Claude, ChatGPT or any MCP app to AssistLoop with one URL, then go further: your own MCP client, a client built from scratch, and the full tool, error and limit reference.
Connect AssistLoop to an AI app such as Claude or ChatGPT, and you can ask the app to work in AssistLoop for you: read conversations, reply to customers, handle tickets and train your agents. The connection uses MCP, an open standard for linking AI apps to other tools.
Server URL
https://api.assistloop.ai/mcpIt is the same URL the AssistLoop dashboard shows on the MCP page (organization sidebar → MCP → Server URL, with a copy button). That URL is all an AI app needs. You don't need an API key or a password: the app opens AssistLoop in your browser, and you approve it there.
Your organization must be on a premium plan. The app acts as you: it has your role and sees only the agents you see in the dashboard.
Connect in three steps
- Add the URL to your AI app. In Claude, open Customize → Connectors, click + Add → Add custom connector. For ChatGPT and other apps, see Connect an AI app.
- Sign in and approve. The app opens the AssistLoop sign-in page. Sign in, check what the app will be allowed to do, and click Authorize.
- Ask. Start a new chat and check that AssistLoop is turned on (in Claude: + →
Connectors; in ChatGPT: type
@and pick AssistLoop). Then try a prompt below.
Prompts to try
List my AssistLoop agents.Show the open conversations for my agent and summarize what customers are asking.List the open high-priority tickets and draft a reply for the oldest one. Don't send anything yet.Train my agent on this text: "We deliver within 2-3 business days."Crawl https://example.com, show me the pages it found, and train my agent on the pricing page.Most AI apps ask before they run a tool that changes something, such as sending a reply to a customer. Read what the app is about to do before you approve.
Pick your path
The sections below go from simple to technical. Most people need only the first one.
| You have | Read |
|---|---|
| Claude, ChatGPT, Claude Code, VS Code, Cursor or another app that accepts an MCP server URL | Connect an AI app |
| Your own program or agent built with an MCP SDK or an agent framework | Use your own MCP client |
| Code that must sign in and call the server without an MCP SDK | Build your own client |
Reference: Tools and Errors and limits.
Connect an AI app
Every app below needs only the server URL:
https://api.assistloop.ai/mcpCopy it from the dashboard's MCP page or from here. Leave any OAuth client ID or client secret field empty: the app sets itself up with AssistLoop and opens the AssistLoop sign-in page in your browser.
Menu names change between app versions. If a menu below looks different, look for Connectors, MCP or Integrations in the app's settings.
What happens when you connect
- The app opens the AssistLoop sign-in page. Sign in if you aren't already.
- The page shows the app's name, the site it returns you to, and the access it asks for.
- Claude and ChatGPT show a Verified: Claude or Verified: ChatGPT badge.
- Other apps show Unverified app. That is normal for developer tools: check that you started the connection yourself, then continue.
- Apps that return only to your own computer (
localhostor127.0.0.1), such as Claude Code and other command-line apps, also warn that any program on your computer could use the connection. Continue only if you just started it.
- Leave Allow changes ticked to let the app reply to customers, update tickets and train agents. Untick it for read-only access. If you don't see Allow changes, the app asked for read-only access.
- Click Authorize. The browser returns to the app, and the app shows AssistLoop as connected.
Claude (claude.ai and Claude Desktop)
Works on Claude's paid plans and the Free plan. The Free plan allows one custom connector.
- Open Customize → Connectors.
- Click + Add, then Add custom connector.
- Enter AssistLoop as the name and
https://api.assistloop.ai/mcpas the URL, then click Continue. Keep the detected settings and click Continue again. - For authentication, choose Sign in now. For the OAuth client, keep Use Claude's published identity (Recommended) or choose Register automatically. Don't choose Use your own OAuth client.
- Click Add, then Connect, and approve on the AssistLoop page.
- In a chat, click + → Connectors and turn on AssistLoop.
Some accounts still show an older one-screen dialog with Advanced settings. Leave those fields empty.
A connector added on claude.ai also works in Claude Desktop and the Claude mobile apps.
Claude Team and Enterprise: an Owner (or Primary Owner) adds the connector once in Organization settings → Connectors → Add → Custom → Web. Each member then opens Customize → Connectors, finds AssistLoop (marked Custom), clicks Connect and approves with their own AssistLoop account.
ChatGPT
ChatGPT adds custom MCP servers as plugins, on the web. On Business, Enterprise and Edu plans, the workspace must allow creating plugins with MCP servers.
- Go to chatgpt.com/plugins, click +, then Add custom MCP server.
- Enter AssistLoop as the name and, under Connection,
https://api.assistloop.ai/mcpas the Server URL. - Choose OAuth for authentication and leave the client ID and secret empty.
- Click I understand and want to continue, then Create as a plugin.
- Install the plugin and approve on the AssistLoop page. ChatGPT may ask you to sign in only when it first uses an AssistLoop tool.
- In a chat, type
@and pick AssistLoop.
Claude Code
claude mcp add --transport http assistloop https://api.assistloop.ai/mcpThen start Claude Code, run /mcp, choose assistloop and follow the sign-in. Approve in the
browser that opens.
To use AssistLoop in every project, add --scope user:
claude mcp add --transport http --scope user assistloop https://api.assistloop.ai/mcpUse --scope project instead to share it with your team through .mcp.json.
VS Code (GitHub Copilot)
- Open the Command Palette and run MCP: Add Server.
- Choose HTTP (HTTP or Server-Sent Events) and paste
https://api.assistloop.ai/mcp. - Enter
assistloopas the server ID. - Choose where to save it (for example Global), and approve the sign-in in the browser.
Or add a .mcp.json file at the root of your workspace:
{
"mcpServers": {
"assistloop": {
"type": "http",
"url": "https://api.assistloop.ai/mcp"
}
}
}Use the tools from an agent session in the Chat view.
Cursor
Add the server to ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project):
{
"mcpServers": {
"assistloop": {
"url": "https://api.assistloop.ai/mcp"
}
}
}Then open Cursor's Customize page (older versions: Cursor Settings → MCP) and sign in next to assistloop.
Older Cursor versions sign in through a cursor:// return address, which AssistLoop does not accept. Cursor then shows a registration error (invalid_redirect_uri) and no AssistLoop page opens. Update Cursor: current versions return to http://localhost:8787, which works.
Gemini CLI
gemini mcp add --transport http assistloop https://api.assistloop.ai/mcpThis saves it for the current project. Add --scope user to use it in every project. Then,
inside Gemini CLI, run /mcp auth assistloop and approve in the browser.
OpenAI Codex CLI
codex mcp add assistloop --url https://api.assistloop.ai/mcp
codex mcp login assistloopThe second command opens the browser to approve.
Any other app
AssistLoop works with any app that can add a remote MCP server (also called Streamable HTTP) and sign in with OAuth. Give it the server URL and nothing else.
The app must return to an https address or to http://localhost, http://127.0.0.1 or
http://[::1]. Apps that use a custom address such as myapp://, even alongside an https one,
can't connect yet.
The dashboard's MCP page, under Connect an app → Other apps, also has a prompt you can paste into an AI coding agent (Cursor, Codex, Gemini CLI) so it adds the server itself.
If the app only accepts a fixed access token, it can't sign in by itself. See Build your own client to get a token in code.
If something goes wrong
| What you see | What to do |
|---|---|
| The app says it can't reach the server | Check the URL ends in /mcp and starts with https://. |
| The sign-in page says the app isn't recognized | Remove AssistLoop from the app and add it again. |
The app reports invalid_redirect_uri, or never opens the AssistLoop page | The app uses a custom return address (such as cursor://). Update the app, or use another app. |
Every tool answers premium_required | Upgrade the organization to a premium plan. |
| The app can read but not change anything | On the dashboard's MCP page the app shows Read only. Click Disconnect, confirm, then connect again from the app and leave Allow changes ticked. |
| The app stopped working after a while | It lost its sign-in. Connect it again from the app. |
Use your own MCP client
Use this section when you write the program yourself and use an MCP SDK (Python, TypeScript and others) or an agent framework with MCP support. The SDK finds the sign-in endpoints, registers your app and handles PKCE (the code check that protects the sign-in). To connect an existing app such as Claude or Cursor instead, see Connect an AI app.
Before you start
| Requirement | Details |
|---|---|
| Plan | The organization must be on a premium plan. list_organizations also shows free organizations (with premium: false), but their agents are not listed and every tool refuses them. A person with no premium organization gets premium_required from every tool. |
| Account | A person with an AssistLoop account signs in once, in a browser, and approves the connection. Your system then acts as that person, with that person's permissions. |
| Membership | That person must be an owner, admin or member of the organization. A member sees only the agents assigned to them, unless their membership gives access to all agents. |
| Redirect address | Your system needs a web address the browser can return to after approval: an https address, or an http address on the machine itself (127.0.0.1, localhost or [::1]). |
Server details
| What | Value |
|---|---|
| Server URL | https://api.assistloop.ai/mcp |
| Transport | Streamable HTTP: JSON-RPC 2.0 in POST requests. GET /mcp answers 405. |
| MCP protocol versions | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 |
| Sign-in | OAuth 2.1 authorization code flow with PKCE (S256 only), as the MCP authorization specification describes |
| Scopes | mcp:read (read tools), mcp:write (write tools; includes read) |
| Access key lifetime | 1 hour |
| Refresh key lifetime | 30 days. Every refresh returns a new refresh key and revokes the old one. |
| MCP features | Tools only. The server offers no MCP resources or prompts. |
Pick a client
| Your system | Use |
|---|---|
| A developer tool for testing | MCP Inspector: npx @modelcontextprotocol/inspector, choose Streamable HTTP, enter the server URL and click Connect. |
| Your own program or AI agent | An MCP SDK or an agent framework with MCP support. See the Python example below. |
| An LLM API that accepts remote MCP servers | Pass the server URL and an access key you get yourself: see Build your own client. |
| A fully automated job with no person to approve it | MCP is not a fit: there is no sign-in without a person, and AssistLoop API keys do not work on /mcp. Use the Public API with an API key instead. |
Connect with the Python MCP SDK
This program signs in once through the browser and stores its keys in a file. Later runs reuse the stored keys and refresh them, so no browser opens again until the refresh key expires or someone disconnects the app.
It was tested with the official MCP Python SDK, mcp version 1.30.0.
pip install "mcp==1.30.0"
export ASSISTLOOP_MCP_URL="https://api.assistloop.ai/mcp"
python assistloop_mcp.py"""Connect your own program to the AssistLoop MCP server."""
import asyncio
import json
import os
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import httpx
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
SERVER_URL = os.environ["ASSISTLOOP_MCP_URL"]
CALLBACK_PORT = 8765
REDIRECT_URI = f"http://127.0.0.1:{CALLBACK_PORT}/callback"
STATE_FILE = Path(os.environ.get("ASSISTLOOP_MCP_STATE", "assistloop-mcp.json"))
APPROVAL_TIMEOUT_SECONDS = 600 # how long to wait for the person to approve
class FileTokenStorage(TokenStorage):
"""Keeps the registered app and its keys in a file, so the browser step happens once."""
def read(self) -> dict:
return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
def write(self, data: dict) -> None:
STATE_FILE.write_text(json.dumps(data))
STATE_FILE.chmod(0o600)
async def get_tokens(self) -> OAuthToken | None:
data = self.read()
return OAuthToken.model_validate(data["tokens"]) if "tokens" in data else None
async def set_tokens(self, tokens: OAuthToken) -> None:
data = self.read()
data["tokens"] = tokens.model_dump(mode="json", exclude_none=True)
self.write(data)
async def get_client_info(self) -> OAuthClientInformationFull | None:
data = self.read()
return OAuthClientInformationFull.model_validate(data["client"]) if "client" in data else None
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
data = self.read()
data["client"] = client_info.model_dump(mode="json", exclude_none=True)
self.write(data)
async def refresh_stored_keys(storage: FileTokenStorage) -> None:
"""Swap stored keys for fresh ones before connecting.
The SDK refreshes only keys it received in the same process: with keys loaded from the file,
an expired access key would send the person through the browser again. Refreshing on every
start gives each session a full hour.
"""
data = storage.read()
if "tokens" not in data or "client" not in data:
# No keys: the person approves again. Register again too, because AssistLoop deletes
# registrations that hold no keys.
data.pop("client", None)
storage.write(data)
return
server = urlparse(SERVER_URL)
async with httpx.AsyncClient(timeout=30) as http:
resource = (await http.get(f"{server.scheme}://{server.netloc}/.well-known/oauth-protected-resource{server.path}")).json()
issuer = resource["authorization_servers"][0]
metadata = (await http.get(f"{issuer}/.well-known/oauth-authorization-server")).json()
response = await http.post(metadata["token_endpoint"], data={
"grant_type": "refresh_token",
"client_id": data["client"]["client_id"],
"refresh_token": data["tokens"]["refresh_token"],
"resource": SERVER_URL,
})
if response.status_code == 200:
await storage.set_tokens(OAuthToken.model_validate(response.json()))
elif response.status_code in (400, 401):
# The refresh key expired, or the app was disconnected: the next connect registers again
# and asks the person to approve.
data.pop("tokens")
data.pop("client")
storage.write(data)
else:
# Rate limited or a server error: keep the keys and try again later.
response.raise_for_status()
callback: dict[str, str] = {}
received = threading.Event()
class CallbackHandler(BaseHTTPRequestHandler):
"""Receives the browser after the person approves or cancels on the consent page."""
def do_GET(self) -> None:
url = urlparse(self.path)
if url.path == "/callback":
callback.update({key: values[0] for key, values in parse_qs(url.query).items()})
received.set()
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"You can close this tab and return to the app.")
def log_message(self, *args) -> None:
pass
async def open_consent_page(authorization_url: str) -> None:
callback.clear()
received.clear()
server = HTTPServer(("127.0.0.1", CALLBACK_PORT), CallbackHandler)
server.timeout = 1
def serve() -> None:
deadline = time.monotonic() + APPROVAL_TIMEOUT_SECONDS
while not received.is_set() and time.monotonic() < deadline:
server.handle_request()
server.server_close()
threading.Thread(target=serve, daemon=True).start()
print("Approve the connection in your browser:", authorization_url)
webbrowser.open(authorization_url)
async def wait_for_callback() -> tuple[str, str | None]:
if not await asyncio.to_thread(received.wait, APPROVAL_TIMEOUT_SECONDS):
raise TimeoutError("Nobody approved the connection in time.")
if "code" not in callback:
raise RuntimeError(f"The connection was not approved: {callback.get('error', 'no code')}")
return callback["code"], callback.get("state")
async def main() -> None:
storage = FileTokenStorage()
await refresh_stored_keys(storage)
auth = OAuthClientProvider(
server_url=SERVER_URL,
client_metadata=OAuthClientMetadata(
client_name="Acme support automation",
redirect_uris=[REDIRECT_URI],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
scope="mcp:read mcp:write",
),
storage=storage,
redirect_handler=open_consent_page,
callback_handler=wait_for_callback,
)
async with httpx.AsyncClient(auth=auth, timeout=httpx.Timeout(30, read=300)) as http:
async with streamable_http_client(SERVER_URL, http_client=http) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("Tools:", ", ".join(tool.name for tool in tools.tools))
result = await session.call_tool("list_organizations", {})
if result.isError:
print(result.content[0].text)
else:
print(json.dumps(result.structuredContent, indent=2))
if __name__ == "__main__":
asyncio.run(main())What happens on the first run:
- The SDK calls
/mcpwithout a key and gets401with the address of the sign-in metadata. - It reads the metadata, registers your app through
POST /oauth/register, and opens the AssistLoop consent page in the browser. - The person signs in to AssistLoop if needed, checks the app's name and return address, chooses whether to allow changes, and clicks Authorize.
- The browser returns to
http://127.0.0.1:8765/callback, and the SDK exchanges the code for keys and stores them.
On later runs, refresh_stored_keys swaps the stored keys for fresh ones, then the SDK connects with them. No browser opens. If the refresh key was rejected (it expired, or the app was disconnected), the program drops the keys and the registration, registers again and asks the person to approve.
Things to know about this example:
- An access key lasts an hour, and SDK 1.30.0 does not refresh keys it loaded from storage. Open a new session for each batch of work, as
main()does, and don't keep one session open for more than an hour. A session that outlives its key opens the browser again. - Each refresh replaces the refresh key and revokes the old one. Don't run two copies of the program on the same state file at once: the second refresh would fail and drop the keys.
- If the sign-in request is wrong, the consent page shows an error and stays open; the browser never comes back to your callback. The program gives up after 10 minutes.
- On a read-only connection, calling a write tool makes the SDK open the browser again to ask for write access, instead of returning an error.
- Keep the state file private: it holds keys that act as that person.
Next: Tools and Errors and limits.
Build your own client
Use these steps when your code gets the access key itself: an LLM API that takes a remote MCP server and a bearer token, or a client written without an MCP SDK. See the requirements and server details above.
1. Find the sign-in endpoints
A request to /mcp without a key answers 401 with this header:
WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://api.assistloop.ai/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write"GET https://api.assistloop.ai/.well-known/oauth-protected-resource/mcp returns:
{
"resource": "https://api.assistloop.ai/mcp",
"authorization_servers": ["https://api.assistloop.ai"],
"scopes_supported": ["mcp:read", "mcp:write"],
"bearer_methods_supported": ["header"]
}GET https://api.assistloop.ai/.well-known/oauth-authorization-server returns:
{
"issuer": "https://api.assistloop.ai",
"authorization_endpoint": "https://assistloop.ai/oauth/authorize",
"token_endpoint": "https://api.assistloop.ai/oauth/token",
"client_id_metadata_document_supported": true,
"registration_endpoint": "https://api.assistloop.ai/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp:read", "mcp:write"]
}The authorization endpoint is the consent page in the AssistLoop web app. Read every URL from this metadata instead of building it yourself.
2. Identify your app
Your app needs a client_id. There are two ways to get one.
Register your app (POST /oauth/register, RFC 7591):
POST /oauth/register HTTP/1.1
Host: api.assistloop.ai
Content-Type: application/json
{
"client_name": "Acme support automation",
"redirect_uris": ["https://acme.example/assistloop/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}The answer is 201:
{
"client_id": "9f7c2a4e-...",
"client_name": "Acme support automation",
"redirect_uris": ["https://acme.example/assistloop/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "mcp:read mcp:write",
"client_id_issued_at": 1793116800
}Store the client_id and reuse it while your app holds keys. AssistLoop deletes a registration that is more than 24 hours old and holds no keys: one that nobody approved goes after about a day; one whose keys all expired or were revoked goes about 31 days after its last access key expired. If the consent page says your app is not recognized, or the token endpoint answers invalid_client, register again.
Registration rules:
client_nameis required, at most 100 characters.redirect_urisholds 1 to 10 addresses. Each ishttps, orhttpon127.0.0.1,localhostor[::1]only. No fragment (#), no user name or password in the address, no comma, at most 2000 characters. Custom schemes such asmyapp://are refused.- Apps are public clients: there is no client secret.
token_endpoint_auth_methodmay only benone. grant_typesmay only listauthorization_codeandrefresh_token;response_typesonlycode.- Other metadata, such as
logo_uri, is ignored. - Each IP address can register 10 apps a minute and 100 a day.
A refused registration answers 400: "error": "invalid_client_metadata" when a field is missing or has a wrong value, or "error": "invalid_redirect_uri" when a redirect address breaks the rules above. Past the registration limit, it answers 429.
Or publish a Client ID Metadata Document. Your client_id is then an https URL that serves a JSON document about your app:
{
"client_id": "https://acme.example/assistloop/client.json",
"client_name": "Acme support automation",
"redirect_uris": ["https://acme.example/assistloop/callback"],
"token_endpoint_auth_method": "none"
}client_idin the document must equal its URL exactly.- The URL needs a path, and no query or fragment.
- The host must be written in lower case.
- AssistLoop fetches it from a public address only and follows no redirects. The answer must be status
200, with a JSON content type, not compressed, within 3 seconds and at most 64 KB. token_endpoint_auth_method, if present, must benone.client_nameandredirect_urisfollow the registration rules above.- AssistLoop caches the document for its
Cache-Controlmax-age, between 5 minutes and 24 hours (1 hour without one). After a failed fetch, it waits 5 minutes before fetching that URL again, so a fix can take that long to show.
3. Send the person to the consent page
Create a PKCE pair: a random code_verifier of 43 to 128 characters (letters, digits, -, ., _, ~), and code_challenge = base64url(SHA-256(code_verifier)) without padding. Then open the authorization_endpoint in the person's browser with these query parameters:
| Parameter | Value |
|---|---|
response_type | code |
client_id | Your client_id |
redirect_uri | One of your registered redirect addresses |
state | A random value. Required. |
code_challenge | Your PKCE challenge. Required. |
code_challenge_method | S256. A missing method or plain is refused. |
resource | https://api.assistloop.ai/mcp, exactly the resource from the metadata. Required. |
scope | Optional: mcp:read, or mcp:read mcp:write. Without it, the app asks for both. |
The consent page shows your app's name, the hosts of its redirect addresses, and the access it asks for. It marks an app as Verified only for Claude and ChatGPT. Your app shows as Unverified app, with a note that AssistLoop cannot confirm who made it. When every redirect address is on the local machine, the page also warns that any program on that computer could use the connection.
The person can turn off Allow changes to give read access only. An app that asks for mcp:read alone never gets write access.
After the person clicks Authorize, the browser goes to:
https://acme.example/assistloop/callback?code=...&state=...Check that state is the value you sent. If the person clicks Cancel, the browser returns with error=access_denied and your state. If redirect_uri is not one of your registered addresses, Cancel stays in AssistLoop instead.
The consent page refuses a missing client_id, redirect_uri or state, or a response_type other than code, as soon as it opens. It checks everything else when the person clicks Authorize. If the request is wrong (for example a missing resource, a wrong code_challenge_method, or a redirect_uri that is not registered), the page shows "Failed to authorize the application" and stays open; the browser does not come back to your app. For an unknown client_id, the page says the app is not recognized. Give up waiting after a while.
The redirect_uri must match a registered address exactly. For http://127.0.0.1 and http://[::1] addresses, the port may differ; for localhost it may not.
4. Exchange the code for keys
The code works once, within 10 minutes.
POST /oauth/token HTTP/1.1
Host: api.assistloop.ai
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=9f7c2a4e-...
&redirect_uri=https%3A%2F%2Facme.example%2Fassistloop%2Fcallback
&code=...
&code_verifier=...
&resource=https%3A%2F%2Fapi.assistloop.ai%2Fmcpresource is required here too. The answer:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "...",
"refresh_token": "..."
}5. Call the server
Send the access key in the Authorization header of every request:
POST /mcp HTTP/1.1
Host: api.assistloop.ai
Authorization: Bearer ACCESS_KEY
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_organizations", "arguments": {}}}Start with initialize, then tools/list and tools/call, as the MCP specification describes. The server answers each request with one JSON response (application/json), not an event stream. tools/list returns all the connection's tools in one page. The key works only on /mcp, not on the dashboard API or the Public API.
6. Refresh before the access key expires
POST /oauth/token HTTP/1.1
Host: api.assistloop.ai
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&client_id=9f7c2a4e-...
&refresh_token=...
&resource=https%3A%2F%2Fapi.assistloop.ai%2Fmcpresource is optional on a refresh; if you send it, it must be the /mcp URL. The answer has the same shape as in step 4. Store the new refresh_token at once: the old access and refresh keys stop working. If your system does not refresh within 30 days, the person has to approve the connection again.
A refresh key that expired, was already used, or belongs to a disconnected app answers HTTP 401 with "error": "invalid_request" (not invalid_grant). A deleted registration answers 401 with "error": "invalid_client". In both cases, send the person through the consent page again.
Read-only connections
On a read-only connection, tools/list shows only the 17 read tools. A call to a write tool answers HTTP 403:
WWW-Authenticate: Bearer realm="mcp", error="insufficient_scope", scope="mcp:read mcp:write", resource_metadata="https://api.assistloop.ai/.well-known/oauth-protected-resource/mcp"{
"error": "insufficient_scope",
"error_description": "This connection is read-only. Reconnect and allow changes to use this tool."
}To get write access, send the person through the consent page again (step 3) with scope=mcp:read mcp:write.
Next: Tools and Errors and limits.
Tools
Every id is a uuid that another tool returned. Start with list_organizations, then list_agents.
Read tools (scope mcp:read)
| Tool | What it does | Inputs (required in bold) |
|---|---|---|
list_organizations | Your organizations: id, name, slug, your role, plan name, and whether it is premium. | none |
list_agents | The agents you can see in your premium organizations. | organization_id, status (active, disabled) |
get_agent | One agent: name, description, model, handoff settings, whether it is disabled and why, its organization, and whether ticketing is on. | agent_id |
list_ai_models | The AI models an agent can use, newest first, with the credits one reply costs. | none |
list_conversations | One agent's conversations, most recently active first. | agent_id, page, per_page |
get_conversation | One conversation: status, response mode, channel, the customer and the last message. | conversation_id |
list_conversation_messages | One conversation's messages, newest first. Cursor-paged. | conversation_id, cursor, per_page |
list_sources | One agent's training sources, oldest first, with their training state. | agent_id, page, per_page |
get_website_source | One website page source: its stored text (first 4000 characters) and content_hash. | source_id |
get_crawl_results | A website crawl's status and, once completed, the pages it found. | crawl_task_id |
get_training_progress | One agent's running and recent website page fetches (last 24 hours, at most 20). | agent_id |
get_training_status | The training state of one source. | source_id |
list_tickets | One agent's tickets, newest first. | agent_id, page, per_page, status, priority, assigned_to, q |
get_ticket | One ticket, with its linked conversation. | ticket_id |
list_ticket_messages | A ticket's customer thread, oldest first. | ticket_id, page, per_page |
list_ticket_comments | A ticket's internal notes, newest first. The customer never sees them. | ticket_id, page, per_page |
list_ticket_assignees | The teammates who can be assigned this agent's tickets. | agent_id |
Write tools (scope mcp:write)
| Tool | What it does | Inputs (required in bold) |
|---|---|---|
reply_to_conversation | Sends a reply to the customer as a teammate, on the channel the conversation came from (for example the chat widget or WhatsApp), and switches the conversation to human mode, so the AI stops answering. Plain text. Cannot be taken back. | conversation_id, message (at most 10000 characters) |
update_conversation | Opens or closes a conversation, or switches who answers it. A closed conversation must be reopened before its mode can change. | conversation_id, status (open, closed), response_mode (ai, human); at least one |
update_agent | Changes an agent's name, model, human_intervention or only_human. | agent_id, name, ai_model_id, human_intervention, only_human; at least one |
update_agent_prompt | Replaces an agent's system prompt. The old prompt cannot be restored through MCP. | agent_id, prompt (at most 20000 characters) |
create_ticket | Creates a ticket. The customer is not contacted. | agent_id, subject, description, priority, assigned_to, requester_name, requester_email, conversation_id |
update_ticket | Changes a ticket's status, priority, assignee or subject. | ticket_id, status, priority, assigned_to, subject; at least one |
add_ticket_comment | Adds an internal note. The customer never sees it. | ticket_id, body |
reply_to_ticket_customer | Emails the ticket's customer and stores the message on the ticket. The ticket needs a requester email. Cannot be taken back. | ticket_id, body |
add_text_source | Adds a text training source. Training runs in the background. Returns a source_id. | agent_id, text, title |
add_qa_source | Adds a question-and-answer training source. Returns a source_id. | agent_id, questions (a list, at least one), answer |
start_website_crawl | Starts crawling a website to find its pages. Returns a crawl_task_id. | agent_id, url |
train_website_pages | Fetches website pages and trains the agent on them. Returns a task_id. | agent_id, urls (1 to 100), replace_existing |
update_website_source_content | Replaces the stored text of a website page source. Only stores the text: run retrain_source afterwards. Content containing the [truncated] marker is refused. | source_id, content, base_content_hash, title |
retrain_source | Trains one source again. | source_id |
Every write tool also takes an optional idempotency_key; see Retries.
Ticket text you send (description, body) is plain text or markdown. AssistLoop converts it to HTML, and the length limits (10000 for description, 20000 for body) apply to the HTML, so a text just under the limit can still be refused as invalid_input. Ticket messages and notes come back as HTML. reply_to_conversation takes plain text only.
tools/list returns each tool's full input schema and a description written for AI models. The tools also carry MCP annotations: read tools are readOnlyHint and idempotentHint; update_agent and update_conversation are also idempotentHint; update_agent_prompt and update_website_source_content are destructiveHint, and the two tools that reach customers (reply_to_conversation, reply_to_ticket_customer) are openWorldHint. If an AI model calls the tools, ask a person to confirm those before they run.
Extra permissions
Beyond seeing the agent, some tools need more:
| Tools | Needs |
|---|---|
update_agent with name or ai_model_id, update_agent_prompt | Owner or admin of the organization |
get_website_source, get_crawl_results, and all training write tools | Permission to manage the agent's training sources, as in the dashboard |
| All ticket tools | Ticketing switched on for the agent |
Results
A successful call returns the result as MCP structured content, and the same JSON as text for clients that read only text:
{
"data": [
{ "id": "0fcd3a02-3010-4df3-b4f6-b5acacac514a", "name": "Acme", "role": "owner", "premium": true }
],
"meta": { "total": 1, "has_more": false }
}The example shows only some of the fields.
dataholds one record or a list.- Every list has
meta.has_more. Page-numbered lists addpage,per_pageandtotal; lists without pages addtotal.list_conversation_messagesaddsper_pageandnext_cursor: passnext_cursorback ascursorto get the next page. per_pageis at most 50 (default 20).pageis at most 10000.- Ids are uuids. A record's own id is in
id. Ids of other records keep their names, such asorganization_id, and the background tools return theirs assource_id,crawl_task_idortask_id. - Text longer than 4000 characters is cut and ends with the
[truncated]marker.
Work that runs in the background
Training and crawling don't finish during the call. The tool returns at once, and you check progress with a read tool:
start_website_crawlreturns acrawl_task_id. If the agent is already crawling the same page, it returns that crawl's id with statusalready_running.- Call
get_crawl_resultsuntilstatusiscompleted(orfailed). - Pass the page URLs you want to
train_website_pages. Pages already saved are refused unlessreplace_existingistrue. train_website_pagesreturns atask_id. Find it inget_training_progressto follow the fetch, then check the sources withlist_sourcesorget_training_status. (get_crawl_resultsaccepts only acrawl_task_idfromstart_website_crawl; it answersnot_foundfor a fetch'stask_id.)
add_text_source, add_qa_source and retrain_source return a source_id: follow it with get_training_status.
Errors and limits
Tool errors
A tool that fails returns an MCP result with isError: true and one line of text:
[not_found] Not found, or you do not have access to it. (request 6f1c0d7e-...)The code in brackets is stable, so your system can match on it. Quote the request id when you contact support.
| Code | Meaning |
|---|---|
unauthenticated | The connection expired. Reconnect. (A missing or expired key normally gets HTTP 401 before any tool runs.) |
not_found | The id does not exist, or you have no access to it. The two cases answer the same way. |
forbidden | Your role does not allow this action, or ticketing is off for the agent. |
scope_required | The connection is read-only. Reserved: a write call on a read-only connection gets HTTP 403 insufficient_scope before any tool runs. |
premium_required | The organization is not on a premium plan. For list_organizations, list_ai_models and list_agents without organization_id: none of your organizations is. list_agents with the id of a free organization also answers premium_required. |
invalid_input | An argument is invalid. The text usually names the fields. |
rate_limited | Too many requests. The text says how many seconds to wait. |
duplicate_request | A write with this idempotency_key already ran, or is still running. |
agent_disabled | The agent is disabled. |
agent_busy | Another settings change for this agent is in progress. Try again shortly. |
byok_engine | The agent runs on the organization's own API key. Change its model and prompt in the dashboard. |
model_unavailable | The model does not exist, or this agent cannot run it. |
conversation_closed | The conversation is closed. Reopen it first. |
visitor_blocked | The visitor is blocked, so replies are paused. |
channel_window_closed | The messaging window for this customer is closed (for example WhatsApp's 24 hours). The customer must write first. |
channel_not_connected | The messaging channel is not connected for this agent. |
channel_conversation_closed | The channel conversation is closed. |
channel_attachments_unsupported | The channel does not accept these attachments. |
cannot_deliver | The reply cannot be delivered to this customer. |
requester_email_required | The ticket has no requester email. |
storage_limit | The organization reached its training storage limit. |
training_in_progress | The source is still training. |
crawl_already_running | train_website_pages asked for pages that are already being fetched for this agent. Check get_training_progress. |
pages_already_saved | The pages are already saved. Send replace_existing: true to fetch them again. |
content_not_fetched | The page has no stored text yet. |
content_changed | The page text changed since you read it, or base_content_hash is wrong. Read it again. |
content_too_long | The page is longer than get_website_source shows. Edit it in the dashboard. |
agent_service_unavailable | AssistLoop's AI service did not respond or refused the change. Check the agent or source before you retry: part of the change may have happened. |
crawl_service_unavailable | The crawl service did not respond. Try again later. |
internal_error | Something went wrong on our side. |
Some errors come before any tool runs, as HTTP status codes:
| Status | When |
|---|---|
401 | No key, or the key expired or was revoked. Refresh it or sign in again. |
403 with insufficient_scope | A write tool on a read-only connection. |
429 | More than 300 requests to /mcp in a minute for one person, across all of that person's connections. Every request counts, including initialize and tools/list. |
Others are JSON-RPC errors: an unknown tool name answers error code -32602, and an unexpected failure outside a tool answers -32603.
Limits
Tool calls are counted per minute, for each organization and for each user:
| Kind of tool | Per organization | Per user |
|---|---|---|
| Read tools | 600 | 240 |
update_agent, update_agent_prompt, update_conversation, create_ticket, update_ticket, add_ticket_comment | 120 | 30 |
Tools that reach customers: reply_to_conversation, reply_to_ticket_customer | 60 | 10 |
start_website_crawl, train_website_pages | 10 | 5 |
add_text_source, add_qa_source, update_website_source_content, retrain_source | 30 | 10 |
Each row is one shared count: for example, update_agent and create_ticket calls add up toward the same 30 per user. The per-user counts cover every app the person connected: if they also use AssistLoop from Claude or ChatGPT, those calls count too. list_organizations, list_agents and list_ai_models count against the user only.
A call counts once it passes the input, access and idempotency checks, even if the tool then fails (for example with storage_limit). Calls refused by those checks, or with rate_limited, do not count.
An organization can have at most 5 crawls and page-fetch batches (one per train_website_pages call) queued or running at once, including those started from the dashboard or the Public API. Past that, the crawl tools answer rate_limited with a 60-second wait. start_website_crawl for a page the agent is already crawling still returns that crawl (already_running).
Retries and idempotency
A write tool can time out after it has done its work. To retry safely, send an idempotency_key (8 to 64 characters) with every write, and the same key on the retry:
- For 10 minutes, a second call with the same key, user and tool answers
duplicate_requestinstead of running again. Check the result with a read tool. - When the first call was refused before anything changed (for example
invalid_inputorrate_limited), the key is released, so you can fix the input and retry with the same key. - When the first call failed with
agent_service_unavailable,crawl_service_unavailableorinternal_error, the key stays used for the 10 minutes, because the change may have happened. Check with a read tool; to try again, send a new key. - A retry sent while the first call is still running also answers
duplicate_request.
Disconnecting
The person who approved the connection can see and disconnect your app in the AssistLoop dashboard, under MCP in the organization's sidebar (click Disconnect, then confirm). The page lists the person's apps across all their organizations. Disconnecting revokes every key the app holds for that person at once, and the next request answers 401.
Security
- Store refresh keys like passwords. They act as the person who approved the connection.
- Customers write conversation and ticket text. If an AI model reads it, treat it as data, never as instructions.
- Two tools reach real customers and cannot be undone:
reply_to_conversationandreply_to_ticket_customer.
Not available through MCP
- Deleting anything, including sources, and creating agents.
- Uploading files as training sources.
- Sending messages as the visitor.
- Agents that run on the organization's own AI provider key (BYOK): their model and prompt are changed in the dashboard.
- Signing in with an AssistLoop API key, or without a person (no client credentials flow).
- Custom URL schemes as redirect addresses.