REST API
Manage agents, keys, access and the journal from a script with a console key.
On this page
Address and key
Scripts call https://mcp.sallyport.cloud/v1 with a console key. An owner creates one in Settings, API keys with New API key. It starts with sp_ck_ and is shown once.
Send it as Authorization: Bearer $SP_CONSOLE_KEY. The key acts as an owner of the whole organization. It stops working when it is revoked, or when the owner who created it stops being an owner.
A few actions need a signed-in person and answer a console key with 403. They are creating console keys, answering approvals, OAuth sign-in, notification settings, acknowledging signals and downloading stored answers.
Agent tokens (sp_live_…, sp_test_…) are for /mcp and /p/. On /v1 they open one route: GET /v1/bodies/{body_ref} for the agent’s own calls.
export SP_CONSOLE_KEY='sp_ck_…'
curl --fail-with-body \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
https://mcp.sallyport.cloud/v1/tenants/me
curl --fail-with-body \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
https://mcp.sallyport.cloud/v1/workspacesAnswers and errors
A success is {"data": …, "request_id": "…"} with 200, or 201 when something was created. Request bodies are JSON up to 1 MiB. A field a route does not know is ignored, except retired rule fields such as class_policy on /bindings, which get 422.
An error has the same shape on every route. message is one short English clause; details carries what a program needs.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"details": {
"fields": [
{ "field": "workspace_id", "code": "not_a_uuid", "message": "Select a workspace" }
]
},
"request_id": "9e8df29b6763e0ddd8138df2790de68a"
}
}| Status and code | What it means |
|---|---|
401 AUTH_FAILED | The key is missing, wrong or revoked. |
403 FORBIDDEN | Your role is not enough. details.required_role names the role that is. |
404 NOT_FOUND, 404 CALL_NOT_FOUND | Nothing with that ID in your organization, or the ID is not a UUID. |
409 CONFLICT, 409 CALL_CONFLICT | The object is taken, changed, or already in that state. details says which. |
410 APPROVAL_EXPIRED | The approval card closed before the answer. |
422 VALIDATION_FAILED | A field is wrong. details.fields lists each one with field, code and message. |
400 BAD_REQUEST | A query parameter the route does not take. details.accepted lists the ones it does. |
503 UNAVAILABLE, 500 INTERNAL | A fault on Sallyport’s side. Repeat a 503 after details.retry_after_ms. |
Lists
Most lists return every row at once: data is {"items": […], "total": n, "page": 1, "limit": n}.
Calls, approvals and audit events come in pages of {"items", "next_cursor", "total", "truncated"}. limit is 50 by default and 200 at most. Send next_cursor back as cursor until it is null.
A filter with several values takes them comma-separated, as in state=completed,failed. Most list routes refuse a query parameter they do not know, so a misspelled filter never returns everything.
Give a script its own agent
POST /agents takes name and workspace_id. environment is live (default) or test; description, delegate_user_id and allowed_ips are optional. Take workspace_id from GET /workspaces.
A new agent gets every active connection of its workspace and environment at once, each running without asking. Then issue it a token. The sample uses jq to read the new agent’s ID.
export SP_WORKSPACE_ID='…'
AGENT_ID=$(curl --fail-with-body --silent \
--request POST \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
--header "Content-Type: application/json" \
--data "{\"name\":\"Nightly report\",\"workspace_id\":\"$SP_WORKSPACE_ID\"}" \
https://mcp.sallyport.cloud/v1/agents | jq -r .data.id)
curl --fail-with-body \
--request POST \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
--header "Content-Type: application/json" \
--data '{"label":"CI"}' \
"https://mcp.sallyport.cloud/v1/agents/$AGENT_ID/tokens"The token is data.token, and this answer is the only place it appears. It starts with sp_live_ for a live agent and sp_test_ for a test one.
label takes up to 100 characters, and expires_at is an optional RFC 3339 time in the future. DELETE /agents/{id}/tokens/{token_id} revokes a token.
Add a key
POST /credentials stores a key and creates its connections in the same step. Every agent of the same workspace and environment gets them at once.
export GITHUB_TOKEN='…'
curl --fail-with-body \
--request POST \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
--header "Content-Type: application/json" \
--data "{\"name\":\"GitHub\",\"workspace_id\":\"$SP_WORKSPACE_ID\",\"recipe_key\":\"github\",\"fields\":{\"value\":\"$GITHUB_TOKEN\"}}" \
https://mcp.sallyport.cloud/v1/credentialsrecipe_key names the service, and GET /recipes lists them. fields carries the secret under the names in the recipe’s credential_fields; most services have one field, value. environment is live by default.
The answer describes the key without its secret. data.used_by.bindings lists the new connections: an id from there is the {binding_id} of the HTTP proxy.
No route ever returns a secret. To replace one, send a new fields object to POST /credentials/{id}/rotate.
Change how an agent uses a connection
Each agent and connection pair has a rule. class_policy maps read, write and destructive to auto, ask or deny, and a new pair starts with all three on auto.
export SP_BINDING_ID='…'
curl --fail-with-body \
--request PUT \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
--header "Content-Type: application/json" \
--data '{"class_policy":{"read":"ask","write":"ask","destructive":"ask"}}' \
"https://mcp.sallyport.cloud/v1/agents/$AGENT_ID/bindings/$SP_BINDING_ID"PUT replaces the whole rule. A class you leave out becomes ask, and a left-out step_up, path_prefix_override, methods_override or rate_per_hour is cleared. Send the ones you want to keep.
Optional fields: step_up (none, or totp for an authenticator code when a destructive call is approved), path_prefix_override, methods_override and rate_per_hour. The last three may only narrow the connection.
DELETE /agents/{id}/bindings/{binding_id} takes a connection away. POST /agents/{id}/bindings gives connections back: binding_ids or "all": true, with an optional rule in the same shape.
Read calls and the journal
GET /calls pages through calls, newest first. Filters: workspace_id, agent_id, binding_id, state, class, decision, channel, host, q, session, from and to.
A call carries call_id, agent, binding, class, decision (run, ask or deny), state, summary, upstream_status, duration_ms, byte counts, error_code and body_ref.
States: admitted, awaiting_approval, dispatching, executing, completed, denied, failed, outcome_unknown and cancelled. GET /calls/{call_id} adds the intent, the timeline and the call’s journal sequence numbers.
GET /audit/events pages through the journal, newest first. GET /audit/export downloads the chain as JSON lines: a header, then one event per line. from_seq and to_seq take a range.
Limit an agent to known addresses
allowed_ips on POST /agents and PATCH /agents/{id} takes up to 100 IP addresses or CIDR ranges. An empty list means any address, and a PATCH without the field keeps the current list.
export SP_AGENT_IP='…'
curl --fail-with-body \
--request PATCH \
--header "Authorization: Bearer $SP_CONSOLE_KEY" \
--header "Content-Type: application/json" \
--data "{\"allowed_ips\":[\"$SP_AGENT_IP\"]}" \
"https://mcp.sallyport.cloud/v1/agents/$AGENT_ID"SP_AGENT_IP is the address the agent’s requests come from. GET /me/ip, run on that machine, shows the address Sallyport sees.
A request from any other address gets 403 SOURCE_IP_NOT_ALLOWED on /mcp, /p/ and /v1/bodies. Calls already running are not stopped.
Connected apps
GET /oauth/consents lists the AI clients that signed in with OAuth, one row per person, client and agent. A member gets their own rows; an owner, an admin or a console key gets everyone’s.
A row carries id, client_id, client_name, registration, verified, domain and publisher. registration is cimd for a client named by its website and dcr for one that registered itself. Then come redirect (kind: web, app or loopback, and host), agent (id, name), workspace_id, connected_by (id, name, email), connected_at, renewed_at, last_used_at and mine.
DELETE /oauth/consents/{id} disconnects the whole connection. Its calls stop at once, on every computer the client signed in from, and the client signs in again to come back.
The consent screen reads GET /oauth/consent?request=…, for a signed-in person only. Besides the client ID and redirect address it carries client.verified, client.domain, client.publisher, redirect (kind, host) and workspaces. Each workspace has access: grants (existing, all or none) and the connections the agent will use, each with id, name, channel and asks_first.
A row also carries sub_agents: the agents the client created for its machines, each with id, name and box, the machine it is for. In GET /agents such an agent has created_by_client with client_id, client_name and box. It has parent_agent_id only under a connection that is itself an agent, made with scope=mcp agents. Every other agent has null in both. A row with manages_agents: true is a platform’s connection: it manages agents and is no agent itself.
Tokens for a platform’s machines
A platform that runs agents on its own machines signs in once with scope=agents. That consent is an access to manage agents, not an agent: the person sees a short card with no service list. With the connection’s access token it creates an agent for each machine at POST https://mcp.sallyport.cloud/v1/agents. The agent starts with no access: a person chooses what it can use on its page in the console.
curl -sS https://mcp.sallyport.cloud/v1/agents \
-H "Authorization: Bearer $SP_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "api-worker", "box": "5f0c2d1e-9a4b-4c1e-8f3a-2b7d9e6c1a40"}'The answer carries agent_id and access_url, the agent’s access page. Add &return_to= with your own page, and the person comes back with one button. One machine has one agent: a second POST answers 409 with box_has_agent. GET /v1/agents lists the person’s agents with created_by_client, box and paused. PATCH and DELETE /v1/agents/{id} rename, pause or delete only the agents this client created. No call of this API gives or takes access. Send allowed_ips on POST or PATCH and the agent’s tokens work only from those addresses: a token copied off the machine is dead anywhere else. Any list the person sets in the console applies on top.
For a machine’s token, the platform exchanges the connection’s access token at POST https://mcp.sallyport.cloud/token (RFC 8693) and names the agent in sallyport_agent. The token lives one hour.
curl -sS https://mcp.sallyport.cloud/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=https://detachbox.com/.well-known/sallyport-client.json \
-d subject_token="$SP_ACCESS_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d sallyport_agent="$AGENT_ID"The answer carries access_token, expires_in, sallyport_agent_id, sallyport_agent_name and sallyport_agent_url. There is no refresh token: the platform exchanges again. A refusal carries sallyport_reason: subject_token_expired, consent_ended, agents_scope_missing, agent_not_bound, agent_deleted or agent_paused.
POST https://mcp.sallyport.cloud/revoke (RFC 7009) takes token and client_id. A machine’s token ends every token of that machine. The refresh token ends the whole connection, as “Disconnect” does.
GET https://mcp.sallyport.cloud/v1/access with Authorization: Bearer answers for the agent of that token, a machine’s. services is the number of connections it can use, access_url its access page, and connections holds the same entries as list_access. With no services yet, message says so in one line. A token that does not work gets 401 with WWW-Authenticate: Bearer and error="invalid_token". The connection’s own token gets services: 0 and a line saying it uses no service. On /mcp and /p/ it gets 403 with error="insufficient_scope" and scope="mcp".
Ready-made MCP servers
GET /v1/mcp-servers lists the remote MCP servers the console offers when you add one. They are the vendors’ own hosted servers, each with its address and the way it signs in. Any member reads it. Choosing one only fills in the form, and what you save is an ordinary remote MCP connection, see MCP clients and remote servers.
{
"data": {
"items": [
{
"key": "datadog-site",
"name": "Datadog (other sites)",
"vendor": "Datadog",
"url": "https://mcp.{site}/v1/mcp",
"url_part": {
"name": "site",
"label": { "en": "Your Datadog site domain, for example datadoghq.eu or us5.datadoghq.com", "ru": "…", "es": "…", "pt": "…", "fr": "…", "de": "…" },
"example": "datadoghq.eu"
},
"auth": "oauth",
"dynamic_registration": true,
"bearer_alternative": false,
"api_key_header": null,
"api_key_prefix": null,
"key_url": null,
"recipe": "datadog",
"docs_url": "https://docs.datadoghq.com/mcp_server/setup/",
"description": { "en": "Query logs, metrics, traces, monitors and incidents on your Datadog site.", "ru": "…", "es": "…", "pt": "…", "fr": "…", "de": "…" }
}
],
"total": 1,
"page": 1,
"limit": 1
},
"request_id": "5d0c2f7e8a1b4c6d9e0f1a2b3c4d5e6f"
}| Field | Meaning |
|---|---|
url, url_part | The server’s address. When url_part is not null, the address holds {name}. That part is each customer’s own: label asks for it in six languages, example shows one. |
auth | oauth signs in at the vendor, bearer takes a token made there, api_key sends a key in the header api_key_header behind api_key_prefix, none needs nothing. |
dynamic_registration | On the OAuth way the vendor registers Sallyport by itself. false means you enter a client ID made at the vendor. |
bearer_alternative, key_url | The OAuth server also takes a token made at the vendor, and the page where you make that token or key. |
recipe | The catalog service of the same vendor, or null. |
description, docs_url | What the server does, one sentence in each of six languages, and the vendor’s page about the server. |
Every route
Paths are under https://mcp.sallyport.cloud/v1. Member means any role in the organization. A console key counts as an owner.
| Routes | Who | What for |
|---|---|---|
GET /tenants/me, PATCH /tenants/me | member, owner | The organization: name, settings, counters. |
POST /tenants/me/pause, POST /tenants/me/resume | owner | Stop and restart every agent call. Pause takes reason. |
GET /me/ip | anyone | The address Sallyport sees for this caller. |
GET, POST /workspaces; GET, PATCH /workspaces/{id} | member, admin | Workspaces. Create one with slug, name and kind (team or client). |
GET /workspaces/{id}/report?period=YYYY-MM | auditor | One month of a workspace: totals, services, destructive calls. |
GET, POST /agents; GET, PATCH, DELETE /agents/{id} | member, admin | Agents. Delete answers 409 while a call of the agent is in flight. |
POST /agents/{id}/pause, POST /agents/{id}/resume | admin | Stop one agent and start it again. Pause takes reason. |
GET, POST /agents/{id}/tokens; DELETE /agents/{id}/tokens/{token_id} | admin | Agent tokens. |
GET, POST /agents/{id}/bindings; PUT, DELETE /agents/{id}/bindings/{binding_id} | member, admin | The connections an agent uses and its rule on each. |
GET, PUT /agents/{id}/limits | member, admin | concurrency_limit, rate_per_hour and read_budget.multiplier. |
GET, POST /credentials; GET, PATCH, DELETE /credentials/{id} | admin | Keys. Deleting a key deletes its connections. |
POST /credentials/{id}/rotate, PATCH /credentials/{id}/origin, GET /credentials/{id}/usage, POST /credentials/{id}/oauth/start | admin | Replace the secret, record whether the original is revoked at the vendor, see who uses the key. oauth/start begins signing in at the vendor and needs a signed-in person. |
GET, POST /bindings; GET, PATCH, DELETE /bindings/{id} | member, admin | Connections. PATCH checks If-Match against config_version when you send it. |
POST /bindings/{id}/pause, /resume, /check-key, /oauth/start | admin | Stop one connection, start it again, check its key again. oauth/start signs a remote MCP server in and needs a signed-in person. |
GET, POST /ssh-hosts; GET, PATCH, DELETE /ssh-hosts/{id} | admin | Servers for SSH commands. |
POST /database-connections/test | admin | Try a database address and password before saving them. |
POST /email-connections/test | admin | Try a mailbox and its password before saving them. |
GET /policies/rule-changes, POST /policies/rule-changes/{id}/revert | member, admin | The history of rule changes, and undoing one. |
GET /recipes, GET /recipes/{key} | member | The service catalog, see Recipes. |
GET /mcp-servers | member | Ready-made remote MCP servers for a new connection. |
GET, POST /grants; POST /grants/{id}/revoke | member, admin | Time-limited permissions of an agent on a connection. An approver may revoke one too. |
GET /approvals, GET /approvals/{call_id}, GET /approvals/{call_id}/payload | approver | Approval cards and the exact request behind each. An auditor may read the list. |
POST /approvals/{call_id}/open, /decide, /transfer | approver | Extend a card, answer it, hand it to another approver. Answering needs a signed-in person. |
GET /access-requests, GET /access-requests/{request_id} | member | Requests agents made for a service they could not use. |
POST /access-requests/{request_id}/provision, /decline | admin | Answer a request with a key, or refuse it with reason. |
GET /calls, GET /calls/live, GET /calls/{call_id} | auditor | Call history, calls in flight, one call. |
POST /calls/{call_id}/cancel | admin | Stop a call that has not finished. |
GET /bodies/{body_ref}/meta, GET /bodies/{body_ref} | auditor; the download also the agent | Size and expiry of a stored answer, and the answer itself. Download accepts Range. |
GET /audit/events, GET /audit/export | auditor | The journal, as pages or as one file of JSON lines. Only a role over the whole organization, not one workspace. |
GET /signals, POST /signals/{id}/ack | member, admin | Unusual activity, and marking it seen. |
GET /reports, GET /reports/{id}, GET /insights | member | Weekly reports and approval statistics. |
GET /billing, POST /billing/plan | admin, owner | Plan and usage, and asking for another plan. |
GET, POST /webhooks; PATCH, DELETE /webhooks/{id} | admin | Journal events posted to your HTTPS address. The signing secret is shown once. |
GET /webhooks/{id}/deliveries, POST /webhooks/{id}/deliveries/{delivery_id}/retry | admin | Delivery history, and sending a failed one again. |
GET /members, PATCH /members/{user_id} | member, admin | People and their roles. |
GET, POST /members/invitations; DELETE /members/invitations/{id} | admin | Invitations by email. |
GET /members/{user_id}/offboarding, POST /members/{user_id}/offboard | admin | Hand over what a leaving person held. |
GET, POST /guests; POST /guests/{id}/relink; DELETE /guests/{id} | admin | Client approvers who sign in with an email link. |
GET /onboarding, POST /onboarding/steps/{key} | member | The first-run checklist. |
GET, POST /console-keys; DELETE /console-keys/{id} | owner | Console keys. Creating one needs a signed-in owner. |
GET /oauth/consents, DELETE /oauth/consents/{id} | member, admin | Connected apps, and disconnecting one. A member handles their own, an admin everyone’s. |
/notifications/…, /oauth/consent, /guest/step-up | person | Console screens for a signed-in person. |