Skip to documentation
DocumentationHTTP proxy

HTTP proxy

Send any HTTP request to a connected service through Sallyport. The gateway adds the key, so your script never holds it.

On this page

The address

Send the request to https://mcp.sallyport.cloud/p/{binding_id}/{path} with an agent token in Authorization: Bearer. Sallyport adds the connection’s key, calls the service and streams its answer back.

The service receives https://{host}{path_prefix}, then the path you wrote after the ID, then your query string. A catalog connection has the prefix /, so write the full path from the vendor’s API documentation.

A connection that covers every host of a service, such as AWS on *.amazonaws.com, takes the host as the first segment: /p/{binding_id}/sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15.

What you need

  • An agent token. Open the agent in Agents and press New token in the Tokens section. The value is shown once.
  • The ID of an HTTP connection. Open Bindings and click the connection: the ID is the last part of the page address.
  • An agent and a connection in the same workspace and environment. Sallyport gives every new key to every agent of its workspace, so this already holds unless somebody removed it.

Read your GitHub account

Shell · GET through a GitHub connection
export SP_AGENT_TOKEN='sp_live_…'
export SP_BINDING_ID='…'

curl --fail-with-body \
  --header "Authorization: Bearer $SP_AGENT_TOKEN" \
  "https://mcp.sallyport.cloud/p/$SP_BINDING_ID/user"

GitHub’s own status, headers and JSON come back, with X-Sallyport-Call-Id naming the call. The answer streams in chunks without Content-Length, and every header comes through, Set-Cookie too.

Send a write

Shell · POST with an intent
curl --fail-with-body \
  --request POST \
  --header "Authorization: Bearer $SP_AGENT_TOKEN" \
  --header "Content-Type: application/json" \
  --header "X-Sallyport-Intent: File the bug the user reported" \
  --data '{"title":"Checkout fails on Safari"}' \
  "https://mcp.sallyport.cloud/p/$SP_BINDING_ID/repos/acme/web/issues"
HeaderWhat it does
X-Sallyport-IntentOne sentence, up to 500 characters, on why the call is made. A person reads it on the approval card. A call that waits for a person without it gets 400 with reason: "missing_intent".
X-Sallyport-Idempotency-KeyYour own key for a write. A repeat with the same key and the same request gets the first answer for 24 hours instead of running again.
X-Sallyport-Call-IdContinues a call you already hold an ID for, whatever else the request says. No second call is created.

Sallyport never forwards these three headers. It also drops Authorization, Cookie, Host, Content-Length, Expect, Forwarded, Via and every X-Forwarded-* header.

A header in the key’s own place, such as GitLab’s PRIVATE-TOKEN, is dropped. Its name goes into the journal, and the call in Activity shows its value, marked as not sent. Method overrides such as _method and X-HTTP-Method-Override are removed, so the method you send is the method that runs.

Repeats

A GET, HEAD or OPTIONS request always runs. A write without your own key gets one made from the request itself, so an identical write is one call.

Sent again while the first waits for a person, it joins the same card. While the first runs, it gets that answer or 409 IN_FLIGHT. For 2 minutes after the end, it gets the first answer back.

A returned answer carries X-Sallyport-Deduplicated: true and the first call’s X-Sallyport-Call-Id. When the service refused the write with a 4xx, the key is freed and a corrected repeat runs. The same key with a different request gets 409 IDEMPOTENCY_CONFLICT.

When a person has to approve

A call waits when the agent’s rule for that connection says ask, for example after Ask every call is turned on for it. The answer comes at once, with 202, Retry-After and this body:

JSON · 202 while the call waits
{
  "code": "APPROVAL_PENDING",
  "instruction": "Show the user approve_url. After poll_after_ms repeat this same call with call_id — you get the result or \"still waiting\" again. This continues the same wait; it is not an idempotent cache. Do not create a new call.",
  "call_id": "sp_c_4pQx…",
  "approve_url": "https://my.sallyport.cloud/approve/sp_c_4pQx…",
  "class": "write",
  "summary": "POST api.github.com/repos/acme/web/issues",
  "expires_at": "2026-09-29T19:05:00Z",
  "poll_after_ms": 5000,
  "expected_wait_ms": 42000,
  "poll_url": "https://mcp.sallyport.cloud/p/calls/sp_c_4pQx…"
}

Show approve_url to the person. Then ask poll_url with the same token after Retry-After seconds. Any method works on it, and nothing runs twice.

Poll answerMeaning
202 with APPROVAL_PENDINGNobody has answered yet. Ask again after Retry-After.
The service’s own answerThe person approved. The call runs now and its answer streams to this poll.
202 with stateThe call is still with the service. Ask again in a second.
403 APPROVAL_DENIEDThe person refused. reason and decided_by say why and who. Nothing was sent.
410 APPROVAL_EXPIREDNobody answered in time. Send the request again without X-Sallyport-Call-Id for a new card.
404 CALL_NOT_FOUNDThe call_id belongs to another agent or does not exist. Make a new call.
200 with state and bytesThe call is over and its answer already went out. status is the service’s status, and body_ref is set when the answer was stored.
error_code with state and bytesThe call is over and ended in a failure, such as UPSTREAM_TOO_LARGE. The status line is that code’s status, and instruction says what to do.

An answer longer than 1 MiB is also stored for 30 days by default. A poll of the finished call carries its body_ref, and the call in Activity offers Download the full response. Download it with GET https://mcp.sallyport.cloud/v1/bodies/{body_ref} and the same agent token; Range works.

WebSocket

Open wss://mcp.sallyport.cloud/p/{binding_id}/{path} with the same agent token in Authorization: Bearer. Sallyport puts the connection’s key where the service reads it, in the handshake for most services, and opens wss://{host}{path} on the service. Every frame passes both ways as it was sent: text, binary, ping, pong and the close code. The subprotocol you ask for in Sec-WebSocket-Protocol reaches the service, and its choice comes back.

JavaScript · ElevenLabs realtime speech to text, with the ws package
import WebSocket from 'ws'

const socket = new WebSocket(
  `wss://mcp.sallyport.cloud/p/${process.env.SP_BINDING_ID}/v1/speech-to-text/realtime` +
    '?model_id=scribe_v2_realtime&audio_format=pcm_16000&commit_strategy=vad',
  { headers: { Authorization: `Bearer ${process.env.SP_AGENT_TOKEN}` } },
)

// 16 kHz 16-bit mono PCM, every 20–100 ms
function sendAudio(pcm) {
  socket.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: pcm.toString('base64') }))
}

socket.on('message', (data) => {
  const event = JSON.parse(data.toString())
  if (event.message_type === 'committed_transcript') console.log(event.text)
})

// 202: the session waits for a person. 4xx: the refusal, as JSON.
socket.on('unexpected-response', (_request, response) => console.error(response.statusCode))

The session is one call, decided like any other by the agent’s rule for the connection. ElevenLabs realtime transcription is a billed write and runs straight through unless Ask every call is on. The call in Activity shows when the session opened and closed, and who closed it with which code. It also shows what each side sent and every message both ways, as it was sent. The messages are kept 30 days, like a long answer.

LimitValuePast the limit
Session length60 minutesBoth sides get close 1001 with the reason.
One message, either way1 MiBBoth sides get close 1009. The message is not passed on.
Silence both ways10 minutes without a frameBoth sides get close 1001 with the reason.

When the session has to wait for a person, the handshake gets 202 and the APPROVAL_PENDING body above instead of 101. Once the person approves, open the WebSocket again with X-Sallyport-Call-Id: {call_id} and the approved session starts. A poll of poll_url answers 426 WEBSOCKET_REQUIRED then, because a session runs only on a WebSocket. A service that refuses the handshake, for a wrong key or a wrong parameter, sends its own status and body.

Services that work over WebSocket

Each of these opens its WebSocket API at /p/, and Sallyport puts the key where the vendor reads it. You write the address and the messages the vendor documents. list_access with service says the same for each connection.

ServicesWhere Sallyport puts the key
OpenAI Realtime, Azure OpenAI Realtime, Gemini Live, xAI, Mistral, Deepgram, AssemblyAI, Speechmatics, Cartesia, MiniMax, Together AI, fal, Murf, Telnyx, ElevenLabs, Retell, GladiaIn the handshake, as on every call
Rev.ai, HumeIn the query: access_token, api_key
Alpaca market data, CoinGecko, CoinMarketCap, AlchemyIn the handshake
Finnhub, Twelve DataIn the query: token, apikey
Massive, Financial Modeling Prep, Alpaca trade updatesIn the first message you send
Binance, Bybit, OKX, Kraken, Coinbase, Coinbase ExchangeNowhere: the public streams need none, and Kraken’s private stream takes the token its GetWebSocketsToken call answers with
Discord gatewayIn the first message you send: IDENTIFY’s d.token
Slack Socket Mode, Twitch EventSub, RingCentral, Vapi, Hetzner consoleNowhere: open the URL the service answered with, ticket and all
Railway, Render, Koyeb, Daytona, Mattermost, ntfy, Grafana CloudIn the handshake
Browserless, Steel, BrowserbaseIn the query: token, apiKey

Some services run their WebSocket on a host of their own: gateway.discord.gg, agent.deepgram.com, ws.finnhub.io, the wss-….slack.com of a Socket Mode ticket. Open such a host through that service’s connection, as the first segment of the path: wss://mcp.sallyport.cloud/p/{binding_id}/gateway.discord.gg/?v=10&encoding=json. Only a WebSocket reaches it.

Where the key goes in the first message, send the message the vendor documents with "{{credential}}" at the key, or leave that field out. Sallyport writes the key in before the message reaches the service, and the call in Activity shows your message as you sent it.

JavaScript · OpenAI Realtime, a text answer
import WebSocket from 'ws'

const socket = new WebSocket(
  `wss://mcp.sallyport.cloud/p/${process.env.SP_BINDING_ID}/v1/realtime?model=gpt-realtime`,
  { headers: { Authorization: `Bearer ${process.env.SP_AGENT_TOKEN}` } },
)

socket.on('open', () => {
  socket.send(JSON.stringify({ type: 'session.update', session: { type: 'realtime', output_modalities: ['text'] } }))
  socket.send(JSON.stringify({
    type: 'conversation.item.create',
    item: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Say hello in one sentence.' }] },
  }))
  socket.send(JSON.stringify({ type: 'response.create' }))
})

socket.on('message', (data) => {
  const event = JSON.parse(data.toString())
  if (event.type === 'response.output_text.delta') process.stdout.write(event.delta)
  if (event.type === 'response.done') socket.close()
})
JavaScript · Deepgram live transcription
import WebSocket from 'ws'

const socket = new WebSocket(
  `wss://mcp.sallyport.cloud/p/${process.env.SP_BINDING_ID}/v1/listen` +
    '?model=nova-3&encoding=linear16&sample_rate=16000&interim_results=true',
  { headers: { Authorization: `Bearer ${process.env.SP_AGENT_TOKEN}` } },
)

// 16 kHz 16-bit mono PCM, as it arrives
function sendAudio(pcm) {
  socket.send(pcm)
}

socket.on('message', (data) => {
  const result = JSON.parse(data.toString())
  if (result.type === 'Results' && result.is_final) console.log(result.channel.alternatives[0].transcript)
})

// When the audio ends, Deepgram sends the last results and closes.
function finish() {
  socket.send(JSON.stringify({ type: 'CloseStream' }))
}

Refusals

A refusal is JSON: {"error": {"code": …, "instruction": …}}, with the fields its code needs beside them. instruction is one English sentence on what to do next.

Four codes are results, not errors, and come as a plain object without the error wrapper: APPROVAL_PENDING, ACCESS_REQUEST_CREATED, ALREADY_EXECUTED and UPSTREAM_ERROR. On /p/ an UPSTREAM_ERROR body is the 424 answer when the service never answered. A service that answers with an error sends you its own answer.

Status and codeWhat happened
401 AUTH_FAILEDThe token is missing, wrong, revoked, or issued for /mcp only. WWW-Authenticate tells an OAuth client where to get the right one.
403 SOURCE_IP_NOT_ALLOWEDThe request came from an address outside the agent’s allowed list.
423 SUSPENDEDThe agent, its workspace or the organization is paused.
404 ACCESS_REQUEST_CREATEDThe agent cannot use this connection: no access, a paused connection, or an unknown ID. Show approve_url to a person, then repeat the call.
403 POLICY_DENYThe agent’s rule for this connection refuses this class of call.
404 CALL_NOT_FOUNDThe X-Sallyport-Call-Id or the polled call belongs to another agent or does not exist. Make a new call.
400 BAD_REQUESTreason names the fix: missing_intent, intent_too_long, body_too_large with max_bytes, missing_host, query_in_path, mcp_binding, database_binding, email_binding, email_operation, sftp_operation, websocket_handshake and others.
400 PATH_OUT_OF_PREFIXThe path leaves the connection’s path limit, for example through ...
409 codesIDEMPOTENCY_CONFLICT, IN_FLIGHT, CALL_CONFLICT or APPROVAL_SUPERSEDED. The instruction says what to do.
426 UPGRADE_NOT_SUPPORTEDAn upgrade to any protocol other than WebSocket.
426 WEBSOCKET_REQUIREDA poll of an approved WebSocket session. Open the WebSocket again with X-Sallyport-Call-Id.
429 RATE_LIMITEDA rate limit of the agent or the connection. Wait Retry-After and repeat.
424 codesEGRESS_DENIED, SERVICE_NOT_CONNECTED, UPSTREAM_TOO_LARGE, OUTCOME_UNKNOWN or UPSTREAM_ERROR. Read the instruction before a repeat: OUTCOME_UNKNOWN means the service may have acted.
503 UNAVAILABLE, 500 INTERNALA fault on Sallyport’s side. Repeat a 503 after Retry-After.

Describe the request in the body

A client that cannot set the method or the path can POST a description with Content-Type: application/vnd.sallyport.request+json. Every field is optional and replaces that part of the request.

Shell · a DELETE described in a POST
curl --fail-with-body \
  --request POST \
  --header "Authorization: Bearer $SP_AGENT_TOKEN" \
  --header "Content-Type: application/vnd.sallyport.request+json" \
  --data '{"method":"DELETE","path":"/repos/acme/web/labels/stale","intent":"Remove the unused label"}' \
  "https://mcp.sallyport.cloud/p/$SP_BINDING_ID/"

The fields are method, path, query, headers, body, intent and idempotency_key. path takes no ? or #: parameters go in query. A JSON body is sent as JSON, a string as its raw text.

An answer may be up to 512 MiB. The connection page shows its own limit under Limits, as Largest answer from the service. The answer streams as it arrives, so one past the limit is cut off. Its status and headers are already sent, and the body stops short. A poll of the call named in X-Sallyport-Call-Id then shows UPSTREAM_TOO_LARGE.