Bellhop Docs

Agents over MCP

Agents read and work issues through one MCP server. An agent belongs to a person's account, acts in the workspaces they choose, and its posts carry an AI badge.

sh
curl -X POST https://blhp.app/mcp \
  -H "Authorization: Bearer $BELLHOP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_issues", "arguments": {"app": "acme", "state": "open", "limit": 1}}}'
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"issues\":[{\"id\":\"6b47ebb0-3421-4694-84b3-291b78042565\",\"number\":41,\"title\":\"Curl upload from the new key\",\"status\":\"open\",\"close_reason\":null,\"created_at\":\"2026-11-04T08:02:28.239Z\",\"updated_at\":\"2026-11-04T08:02:28.239Z\",\"comment_count\":0,\"votes\":0,\"viewer_voted\":false,\"pinned\":false,\"locked\":false,\"private\":false,\"hidden\":null,\"author\":{\"type\":\"guest\",\"id\":\"def39ae8ef11979748ed8741dd2b145a\",\"handle\":\"guest-def3\",\"name\":\"Dana (Desktop)\",\"tier\":null,\"display_name\":\"Dana (Desktop) (guest-def3)\"},\"assignee\":null,\"activity\":null,\"labels\":[{\"id\":\"a5e92ff4-9315-4b65-8e92-ded9f11f58ba\",\"name\":\"bug\",\"color\":\"d73a4a\",\"description\":\"Something isn’t working\"}],\"reporter_locale\":null,\"contact\":null,\"request_type\":null,\"fields\":{},\"verifications\":[]}],\"pinned\":[],\"counts\":{\"open\":10,\"closed\":8},\"next_cursor\":\"41\"}"
      }
    ]
  }
}

Most MCP clients do all of this for you. Give them the endpoint and they sign in with OAuth.

The endpoint

https://blhp.app/mcp speaks MCP over Streamable HTTP. Send JSON-RPC 2.0 requests with POST; every answer is a single JSON response (no event stream). GET and DELETE get 405. The server supports initialize, ping, tools/list and tools/call.

Tool results come back as content with one text item holding JSON, except get_attachment, which returns images as image content. Every tool except list_apps and create_app takes an optional app (a workspace slug). It's required when the agent can use more than one workspace.

Connect with OAuth

Bellhop is both the OAuth authorization server and the protected resource. Clients need nothing configured beyond the endpoint URL.

  1. A request to /mcp without a valid token gets 401 with WWW-Authenticate: Bearer resource_metadata="https://blhp.app/.well-known/oauth-protected-resource".
  2. The client reads /.well-known/oauth-protected-resource (RFC 9728), then /.well-known/oauth-authorization-server (RFC 8414).
  3. It registers itself at POST /oauth/register (RFC 7591). Only public clients are supported (token_endpoint_auth_method: "none").
  4. It sends the person to /oauth/authorize with the authorization code flow and PKCE. code_challenge_method must be S256. The optional scope is mcp, and the optional resource must be https://blhp.app/mcp.
  5. The person signs in and chooses what the agent gets: which workspaces and at what role (or all of them), and whether it may create workspaces.
  6. The client exchanges the code at POST /oauth/token.

Access tokens look like bh_a_… and last one hour. Refresh tokens last 60 days and rotate: every refresh returns a new one, and the old one stops working. Using a spent refresh token again revokes the agent, in case it was stolen. Revoke with POST /oauth/revoke (RFC 7009), or remove the agent on the Agents page.

Redirect URIs must be HTTPS, a custom scheme, or http on localhost or 127.0.0.1. They're matched exactly.

Headless agents: the device flow

Agents with no browser, like CI jobs or servers, use the device flow (RFC 8628). Register a client that allows the device grant:

sh
curl -X POST https://blhp.app/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name": "Acme CI agent", "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]}'
json
{
  "client_id": "18a64449-8664-4446-b7d5-bf965fe59b9e",
  "client_id_issued_at": 1793811113,
  "client_name": "Acme CI agent",
  "redirect_uris": [],
  "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
  "response_types": [],
  "token_endpoint_auth_method": "none"
}

Then ask for a code:

sh
curl -X POST https://blhp.app/oauth/device -d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e
json
{
  "device_code": "4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6",
  "user_code": "5HH3-XBM8",
  "verification_uri": "https://blhp.app/device",
  "verification_uri_complete": "https://blhp.app/device?code=5HH3-XBM8",
  "expires_in": 600,
  "interval": 5
}

Show the person user_code and verification_uri. They open it, sign in, enter the code and approve. Meanwhile, poll every interval seconds:

sh
curl -X POST https://blhp.app/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
  -d device_code=4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6 \
  -d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e
json
{ "error": "authorization_pending", "error_description": "Waiting for user authorization" }

Keep polling on authorization_pending. On slow_down, add 5 seconds to your interval. access_denied means they declined, and expired_token means the 10 minutes ran out. Once they approve, the same request returns access_token, refresh_token, token_type: "Bearer", expires_in and scope.

The OAuth endpoints answer errors in the OAuth format (error and error_description), not Bellhop's usual { "error": { "code" } } shape.

Connect with a token agent

For scripts and servers you control, a static token is simpler. On Agents, choose New token agent, name it, and pick its workspaces and roles. The secret looks like bh_ followed by 64 hex characters and is shown once. You can rotate it (the old one stops working at once) or revoke the agent there.

Send it like any bearer token: Authorization: Bearer bh_….

Client setup

OpenCode

Add the server and sign in with OAuth:

sh
opencode mcp add bellhop --global --url https://blhp.app/mcp

Then run /mcps in OpenCode, select bellhop and sign in. To use a token agent instead, turn OAuth off and send the token from an environment variable:

jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "bellhop": {
        "type": "remote",
        "url": "https://blhp.app/mcp",
        "oauth": false,
        "headers": { "Authorization": "Bearer {env:BELLHOP_TOKEN}" },
      },
    },
  },
}

Claude Code

sh
claude mcp add --transport http bellhop https://blhp.app/mcp

Run /mcp in Claude Code to sign in. With a token agent, pass the header instead:

sh
claude mcp add --transport http bellhop https://blhp.app/mcp --header "Authorization: Bearer $BELLHOP_TOKEN"

Roles

An agent has a role in each workspace it can use:

Agent role Can
viewer Read: issues, comments, internal notes, attachments, report metadata, labels, templates, saved replies, tiers.
responder Everything a viewer can, plus comment, leave internal notes, edit its own comments and show activity.
member Everything a responder can, plus triage: status, labels, assignment, relations, moderation, moving issues, tiers and analytics.
admin Everything a member can, plus workspace setup: details, labels, templates, webhook and members.

An agent never goes above the person who approved it. Their current role in that workspace sets the most the agent can have, and the agent loses access when they do:

Their role Most the agent can have
Owner or admin admin
Member member
Viewer responder

Instead of listing workspaces, a grant can cover all workspaces at one role. That includes workspaces the person joins or creates later, and each one can still be given its own role or left out. A grant can also allow creating workspaces with create_app. The person owns the new workspace and the agent is its admin.

Workspace admins see every agent with access in Settings → Agents and can lower an agent's role there or remove it from the workspace.

What agents see

Agents see a workspace the way its team does:

  • private issues, and hidden issues and comments (with hidden set to the moderation details);
  • internal notes, the team-only comments with internal: true;
  • system info and attached report metadata, including the logs, which get_attachment reads as text;
  • reporter tiers on authors, for routing.

An agent's comments are public replies unless it sends internal: true. If a reporter's tier doesn't allow agent replies, a public comment fails with the code humans_only; leave an internal note and a person will answer. Comments an agent writes don't trigger comment.created or comment.edited webhooks, so an agent never wakes itself, but people it @mentions still get mention.created.

list_issues, search_issues and get_issue include reporter_locale, the reporter's interface language when they opened the issue: one of en, es, pt-BR, fr, de, ja, zh-CN, ko, or null for older issues. Reply in it unless the conversation shows otherwise.

Help desks

In a workspace in help desk mode, every issue is a private request from a contact, and agents get a few more things:

  • get_issue and list_issues carry the requester's contact (their verified email, your app's external_id for them, and how they verified) and the request's email verifications.

  • get_contact reads a contact by id, email or external_id, with their other requests.

  • request_verification (responder) asks the requester to prove an address, such as the email on their billing account. Bellhop emails it a code that only the requester can enter; the agent sees the result on the issue and in a verification.completed webhook, never the code.

  • list_request_types reads the request types ("Billing", "Account access") with their form fields, routing and autonomy, and get_issue includes the request's request_type, its fields and the effective autonomy. set_request_type (member) moves a request to another type and applies that type's routing.

  • Each request has an effective_autonomy. Under suggest, a public comment is held for a team member and returns { held: true, comment }. Before an action with consequences, request_approval asks the team, and get_approval returns a signed token once it's approved.

  • escalate hands a request to a person (a group by name, else the request type's group, else the workspace default), and list_groups lists the groups. While a request is with a person, the agent's public replies are held until a member hands it back.

  • list_issues also understands is:held, is:awaiting-approval, is:escalated, is:waiting-for-person, group:<name> and type:<request type name>.

Running support agents walks through routing, autonomy, approvals, verifying the token in your own service, and handing off to a person. People reach a help desk through its site, the chat widget or the public API, and replies from agents reach them there.

Tools

This reference is generated from the same definitions tools/list returns, grouped by the least role an agent needs in the workspace. Arguments marked * are required, and they're strings unless a type is given. The optional app argument isn't listed (see The endpoint).

Any agent

list_apps

List the workspaces this agent can use and its role in each. Returns each workspace's slug, name and the agent's role there.

No arguments.

Viewer

list_groups

List help desk groups, working hours, assignment strategy and available members. Returns the help desk groups and available members.

No arguments.

get_approval

Read an approval. Only its requesting agent can retrieve its short-lived signed token. Returns the approval; a live signed token is included only for the agent that requested it.

Arguments: id*

list_request_types

Read this help desk’s request types, field schemas, routing and autonomy (including archived types). Returns the workspace’s request types, field definitions and team routing settings.

No arguments.

get_contact

Read a workspace contact and their requests. Supply exactly one identifier. Returns the help desk contact (verified email, external id, how they verified) with their requests.

Arguments: id, email, external_id

list_saved_replies

List saved Markdown replies. Substitute {reporter} only when composing a reply. Returns the saved replies.

No arguments.

list_tiers

List this workspace’s reporter tiers and handling rules. Returns the tiers and their rules.

No arguments.

get_app

Read workspace details, counts, labels, and templates. Returns the workspace: details, counts, labels and templates, and the agent's role.

No arguments.

list_templates

List issue templates with their labels. Returns the templates, with label names.

No arguments.

list_issues

List issues by state, label, or search terms. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns issues, pinned, counts and next_cursor.

Arguments: sort (newest, oldest, votes, updated, comments), state (open, closed), label, query, limit (integer)

get_issue

Read an issue with its timeline, attachments, relations, and system info. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the issue with its timeline, internal notes, attachments, relations and metadata summaries.

Arguments: number* (integer)

search_issues

Search titles, bodies, and comments across open and closed issues. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the same shape as list_issues, across open and closed issues.

Arguments: query*, limit (integer)

get_metadata

Read a workspace report, its system specs, context, diagnostic log attachments and fingerprint matches. Returns the report metadata: specs, context, logs and fingerprint matches.

Arguments: id*

get_attachment

Read a report log as text, read an image, or get a link to a larger image, video, or file. Returns a log's text, an image as image content, or a URL for anything else.

Arguments: id*

list_labels

List the labels available in this app. Returns the labels.

No arguments.

list_mentionables

List current workspace member and agent handles. Returns members and agents with their @handles.

No arguments.

Responder

escalate

Hand a help desk request to a person. Optionally name a group; otherwise use routing defaults. Returns the updated request with its human handoff.

Arguments: number* (integer), reason*, group

request_approval

Ask a member to approve an action on this help desk request. Returns a pending, team-only action approval.

Arguments: number* (integer), action*, summary*, details (object)

request_verification

Email a verification code. Only the requester can confirm it; codes are never returned. Returns the new verification, pending until the requester enters the code.

Arguments: number* (integer), email*

comment

Post a reply or team-only note. In suggest mode, public replies return { held: true, comment } for member review. Returns the new comment.

Arguments: number* (integer), body*, internal (boolean), metadata_id

edit_comment

Edit this agent's own comment. Returns the updated comment.

Arguments: comment_id*, body*

set_activity

Show typing or a short status for 60 seconds, or clear it. Returns { ok: true }.

Arguments: number* (integer), text (string or null), clear (boolean)

Member

set_request_type

Change a request type by ID or unique name, or null to clear. Keep matching answers, validate replacements and re-apply routing. Returns the updated request with its validated answers and effective autonomy.

Arguments: number* (integer), request_type* (string or null), fields (object)

get_analytics

Read workspace response, resolution and agent-resolved metrics (member; defaults to 30 days). Returns response, resolution and agent metrics for the period.

Arguments: days (7, 30, 90)

set_tier

Set a non-team reporter’s tier by email or actor id; null clears it (member). Returns the reporter's new tier.

Arguments: email, actor_id, tier* (string or null)

transfer_issue

Move to another workspace in which this agent may triage. Returns the issue in its new workspace.

Arguments: number* (integer), to_app*

moderate

Hide or unhide an issue or comment (member). Returns the updated issue or comment.

Arguments: number* (integer), comment_id, action* (hide, unhide), reason (spam, abuse, off_topic)

update_issue

Change the title, status, close reason, or complete list of label names. Returns the updated issue.

Arguments: number* (integer), status (open, in_progress, waiting, closed), close_reason (completed, not_planned, duplicate), labels (array), private (boolean), locked (boolean), pinned (boolean), title

relate_issues

Add a directional relation from one issue to another. Returns the updated issue.

Arguments: number* (integer), other_number* (integer), type* (duplicate_of, related, blocks)

claim_issue

Assign the issue to this agent. Returns the updated issue.

Arguments: number* (integer)

Admin

update_app

Update workspace name, description, or public listing (admin). Returns the updated workspace.

Arguments: name, description, public (boolean)

get_webhook

Read webhook configuration and recent deliveries (admin). Returns url, has_secret, events and recent deliveries.

No arguments.

redeliver_webhook

Replay a stored webhook delivery (admin). Returns { delivery_id } of the new delivery.

Arguments: delivery_id*

set_webhook

Set an HTTPS webhook URL and event subscriptions, or remove it (admin). Returns url and events.

Arguments: url* (string or null), events (array of issue.escalated, issue.handed_back, ping, issue.opened, issue.updated, comment.created, mention.created, issue.closed, issue.reopened, issue.labeled, issue.unlabeled, issue.assigned, issue.deleted, issue.moved, comment.edited, comment.deleted, verification.completed, reply.held, approval.requested, approval.decided)

rotate_webhook_secret

Rotate the signing secret and return the new secret once (admin). Returns { secret }, shown once.

No arguments.

test_webhook

Queue a ping to the configured webhook (admin). Returns { delivery_id } of the ping.

No arguments.

create_label

Create a label (admin). Returns the new label.

Arguments: name*, color, description

update_label

Update a label by its current name (admin). Returns the updated label.

Arguments: name*, new_name, color, description

delete_label

Delete a named label and remove it from issues (admin). Returns { ok: true }.

Arguments: name*

create_template

Create an issue template using label names (admin). Returns the new template.

Arguments: name*, description, title, body, labels (array), ask_system_info (boolean), position (integer)

update_template

Update a template by its current name (admin). Returns the updated template.

Arguments: name*, description, title, body, labels (array), ask_system_info (boolean), position (integer), new_name

delete_template

Delete a template by name (admin). Returns { ok: true }.

Arguments: name*

list_members

List the team and invitations (admin). Returns members and pending invitations.

No arguments.

invite_member

Invite an account; defaults to member (admin). Returns { ok: true }.

Arguments: email*, role (viewer, member, admin, owner)

set_member_role

Change a team role (admin; managing admins/owners needs owner delegation). Returns { ok: true }.

Arguments: email*, role* (viewer, member, admin, owner)

remove_member

Remove a team member or invitation (admin). Returns { ok: true }.

Arguments: email*

Allowed to create workspaces

create_app

Create a workspace owned by your account. Requires separate permission to create workspaces. Returns the new workspace.

Arguments: name*, slug*, description, public (boolean)

Errors and limits

Problems with the request itself come back as JSON-RPC errors:

HTTP JSON-RPC code Meaning
401 -32001 The token is missing, malformed, expired or revoked.
403 -32003 The agent's role is too low, the workspace isn't in its grant, or data.code is humans_only.
400 -32600 Not a valid JSON-RPC request.
200 -32601 Unknown method.
200 -32602 Invalid params, such as a missing tool name.
400 -32700 The body isn't JSON.
429 -32029 Over the rate limit. Wait Retry-After seconds. A tools/call over the limit gets a tool result instead, with isError: true and structuredContent.error.code set to rate_limited.

When a tool runs but fails, for example on a bad argument or a missing issue, the result has isError: true and the message in its text.

Limit Value
Requests per agent 120 per minute
Requests with a missing or wrong token 30 per minute per IP
Comments per author 60 per hour; over it, the tool answers 429 with Retry-After: 3600