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
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
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"| Header | What it does |
|---|---|
X-Sallyport-Intent | One 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-Key | Your 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-Id | Continues 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:
{
"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 answer | Meaning |
|---|---|
202 with APPROVAL_PENDING | Nobody has answered yet. Ask again after Retry-After. |
| The service’s own answer | The person approved. The call runs now and its answer streams to this poll. |
202 with state | The call is still with the service. Ask again in a second. |
403 APPROVAL_DENIED | The person refused. reason and decided_by say why and who. Nothing was sent. |
410 APPROVAL_EXPIRED | Nobody answered in time. Send the request again without X-Sallyport-Call-Id for a new card. |
404 CALL_NOT_FOUND | The call_id belongs to another agent or does not exist. Make a new call. |
200 with state and bytes | The 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 bytes | The 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.
ws packageimport 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.
| Limit | Value | Past the limit |
|---|---|---|
| Session length | 60 minutes | Both sides get close 1001 with the reason. |
| One message, either way | 1 MiB | Both sides get close 1009. The message is not passed on. |
| Silence both ways | 10 minutes without a frame | Both 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.
| Services | Where 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, Gladia | In the handshake, as on every call |
| Rev.ai, Hume | In the query: access_token, api_key |
| Alpaca market data, CoinGecko, CoinMarketCap, Alchemy | In the handshake |
| Finnhub, Twelve Data | In the query: token, apikey |
| Massive, Financial Modeling Prep, Alpaca trade updates | In the first message you send |
| Binance, Bybit, OKX, Kraken, Coinbase, Coinbase Exchange | Nowhere: the public streams need none, and Kraken’s private stream takes the token its GetWebSocketsToken call answers with |
| Discord gateway | In the first message you send: IDENTIFY’s d.token |
| Slack Socket Mode, Twitch EventSub, RingCentral, Vapi, Hetzner console | Nowhere: open the URL the service answered with, ticket and all |
| Railway, Render, Koyeb, Daytona, Mattermost, ntfy, Grafana Cloud | In the handshake |
| Browserless, Steel, Browserbase | In 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.
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()
})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 code | What happened |
|---|---|
401 AUTH_FAILED | The 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_ALLOWED | The request came from an address outside the agent’s allowed list. |
423 SUSPENDED | The agent, its workspace or the organization is paused. |
404 ACCESS_REQUEST_CREATED | The 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_DENY | The agent’s rule for this connection refuses this class of call. |
404 CALL_NOT_FOUND | The X-Sallyport-Call-Id or the polled call belongs to another agent or does not exist. Make a new call. |
400 BAD_REQUEST | reason 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_PREFIX | The path leaves the connection’s path limit, for example through ... |
409 codes | IDEMPOTENCY_CONFLICT, IN_FLIGHT, CALL_CONFLICT or APPROVAL_SUPERSEDED. The instruction says what to do. |
426 UPGRADE_NOT_SUPPORTED | An upgrade to any protocol other than WebSocket. |
426 WEBSOCKET_REQUIRED | A poll of an approved WebSocket session. Open the WebSocket again with X-Sallyport-Call-Id. |
429 RATE_LIMITED | A rate limit of the agent or the connection. Wait Retry-After and repeat. |
424 codes | EGRESS_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 INTERNAL | A 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.
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.