Skip to documentation
DocumentationREST API

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.

Shell · the organization and its workspaces
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/workspaces

Answers 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.

JSON · 422 from POST /v1/agents
{
  "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 codeWhat it means
401 AUTH_FAILEDThe key is missing, wrong or revoked.
403 FORBIDDENYour role is not enough. details.required_role names the role that is.
404 NOT_FOUND, 404 CALL_NOT_FOUNDNothing with that ID in your organization, or the ID is not a UUID.
409 CONFLICT, 409 CALL_CONFLICTThe object is taken, changed, or already in that state. details says which.
410 APPROVAL_EXPIREDThe approval card closed before the answer.
422 VALIDATION_FAILEDA field is wrong. details.fields lists each one with field, code and message.
400 BAD_REQUESTA query parameter the route does not take. details.accepted lists the ones it does.
503 UNAVAILABLE, 500 INTERNALA 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.

Shell · an agent and its token
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.

Shell · a GitHub key
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/credentials

recipe_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.

Shell · ask a person on every call
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.

Shell · allow one address
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.

Shell · an agent for one machine
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.

Shell · a token for one machine
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.

JSON · GET /v1/mcp-servers, one item shown
{
  "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"
}
FieldMeaning
url, url_partThe 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.
authoauth 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_registrationOn the OAuth way the vendor registers Sallyport by itself. false means you enter a client ID made at the vendor.
bearer_alternative, key_urlThe OAuth server also takes a token made at the vendor, and the page where you make that token or key.
recipeThe catalog service of the same vendor, or null.
description, docs_urlWhat 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.

RoutesWhoWhat for
GET /tenants/me, PATCH /tenants/memember, ownerThe organization: name, settings, counters.
POST /tenants/me/pause, POST /tenants/me/resumeownerStop and restart every agent call. Pause takes reason.
GET /me/ipanyoneThe address Sallyport sees for this caller.
GET, POST /workspaces; GET, PATCH /workspaces/{id}member, adminWorkspaces. Create one with slug, name and kind (team or client).
GET /workspaces/{id}/report?period=YYYY-MMauditorOne month of a workspace: totals, services, destructive calls.
GET, POST /agents; GET, PATCH, DELETE /agents/{id}member, adminAgents. Delete answers 409 while a call of the agent is in flight.
POST /agents/{id}/pause, POST /agents/{id}/resumeadminStop one agent and start it again. Pause takes reason.
GET, POST /agents/{id}/tokens; DELETE /agents/{id}/tokens/{token_id}adminAgent tokens.
GET, POST /agents/{id}/bindings; PUT, DELETE /agents/{id}/bindings/{binding_id}member, adminThe connections an agent uses and its rule on each.
GET, PUT /agents/{id}/limitsmember, adminconcurrency_limit, rate_per_hour and read_budget.multiplier.
GET, POST /credentials; GET, PATCH, DELETE /credentials/{id}adminKeys. Deleting a key deletes its connections.
POST /credentials/{id}/rotate, PATCH /credentials/{id}/origin, GET /credentials/{id}/usage, POST /credentials/{id}/oauth/startadminReplace 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, adminConnections. PATCH checks If-Match against config_version when you send it.
POST /bindings/{id}/pause, /resume, /check-key, /oauth/startadminStop 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}adminServers for SSH commands.
POST /database-connections/testadminTry a database address and password before saving them.
POST /email-connections/testadminTry a mailbox and its password before saving them.
GET /policies/rule-changes, POST /policies/rule-changes/{id}/revertmember, adminThe history of rule changes, and undoing one.
GET /recipes, GET /recipes/{key}memberThe service catalog, see Recipes.
GET /mcp-serversmemberReady-made remote MCP servers for a new connection.
GET, POST /grants; POST /grants/{id}/revokemember, adminTime-limited permissions of an agent on a connection. An approver may revoke one too.
GET /approvals, GET /approvals/{call_id}, GET /approvals/{call_id}/payloadapproverApproval cards and the exact request behind each. An auditor may read the list.
POST /approvals/{call_id}/open, /decide, /transferapproverExtend a card, answer it, hand it to another approver. Answering needs a signed-in person.
GET /access-requests, GET /access-requests/{request_id}memberRequests agents made for a service they could not use.
POST /access-requests/{request_id}/provision, /declineadminAnswer a request with a key, or refuse it with reason.
GET /calls, GET /calls/live, GET /calls/{call_id}auditorCall history, calls in flight, one call.
POST /calls/{call_id}/canceladminStop a call that has not finished.
GET /bodies/{body_ref}/meta, GET /bodies/{body_ref}auditor; the download also the agentSize and expiry of a stored answer, and the answer itself. Download accepts Range.
GET /audit/events, GET /audit/exportauditorThe 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}/ackmember, adminUnusual activity, and marking it seen.
GET /reports, GET /reports/{id}, GET /insightsmemberWeekly reports and approval statistics.
GET /billing, POST /billing/planadmin, ownerPlan and usage, and asking for another plan.
GET, POST /webhooks; PATCH, DELETE /webhooks/{id}adminJournal events posted to your HTTPS address. The signing secret is shown once.
GET /webhooks/{id}/deliveries, POST /webhooks/{id}/deliveries/{delivery_id}/retryadminDelivery history, and sending a failed one again.
GET /members, PATCH /members/{user_id}member, adminPeople and their roles.
GET, POST /members/invitations; DELETE /members/invitations/{id}adminInvitations by email.
GET /members/{user_id}/offboarding, POST /members/{user_id}/offboardadminHand over what a leaving person held.
GET, POST /guests; POST /guests/{id}/relink; DELETE /guests/{id}adminClient approvers who sign in with an email link.
GET /onboarding, POST /onboarding/steps/{key}memberThe first-run checklist.
GET, POST /console-keys; DELETE /console-keys/{id}ownerConsole keys. Creating one needs a signed-in owner.
GET /oauth/consents, DELETE /oauth/consents/{id}member, adminConnected apps, and disconnecting one. A member handles their own, an admin everyone’s.
/notifications/…, /oauth/consent, /guest/step-uppersonConsole screens for a signed-in person.