Quickstart
Mint a key in the dashboard's API Keys tab, then make your first call. The example below works as-is once you drop in your key.
curl https://api.portal.ai/v1/chat/completions \
-H "Authorization: Bearer $PORTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "portal/m2-25",
"messages": [
{ "role": "user", "content": "Say hello from Portal." }
]
}'That's the whole integration: a base URL, a bearer key, and "model": "portal/m2-25".
Base URL
Point any OpenAI-compatible client at Portal's inference endpoint:
All inference routes live under /v1 - for example /v1/chat/completions and /v1/models.
Authentication
Authenticate every request with a bearer token in the Authorization header:
Authorization: Bearer sk-portal-...
Keys carry the sk-portal- prefix and are created in the dashboard's API Keys tab. The full secret is shown once at creation - store it somewhere safe. You can revoke a key anytime; revoked keys immediately return 401.
Never ship a secret key in client-side code or commit it to source control. Read it from an environment variable (for example PORTAL_API_KEY) on your server.
Choosing the model
Portal serves these customer model ids. Select one with the model field:
{ "model": "portal/m2-25" }- portal/m2-25 - Portal M2-25, the default agentic coding model (200K context).
- portal/m2-25-fast - Portal M2-25, priority tier (higher throughput, higher price).
- portal/m3-55-xh-1m - Portal M3-55 XH, Extra-High reasoning with a 1M context window.
- portal/m3-500k-fast - Portal M3 500K Fast, near-top reasoning at priority speed (500K context).
The pattern that works: portal/m2-25 for everyday and bulk calls, portal/m3-500k-fast when you want strong and fast at once, and portal/m3-55-xh-1m for the hardest reasoning and long-horizon work.
In the OpenAI SDKs this is the model="portal/m2-25" argument. See Models for pricing and context windows. The catalog contains Portal-branded customer routes; underlying routing and availability may change.
Chat completions
Send a list of messages and receive a completion. The request and response shapes match the OpenAI Chat Completions API.
curl https://api.portal.ai/v1/chat/completions \
-H "Authorization: Bearer $PORTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "portal/m2-25",
"messages": [
{ "role": "system", "content": "You are a precise senior engineer." },
{ "role": "user", "content": "Write a haiku about portals." }
]
}'from openai import OpenAI
client = OpenAI(
base_url="https://api.portal.ai/v1",
api_key="sk-portal-...", # from the API Keys tab
)
resp = client.chat.completions.create(
model="portal/m2-25",
messages=[
{"role": "user", "content": "Write a haiku about portals."},
],
)
print(resp.choices[0].message.content)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.portal.ai/v1",
apiKey: process.env.PORTAL_API_KEY, // sk-portal-...
});
const resp = await client.chat.completions.create({
model: "portal/m2-25",
messages: [
{ role: "user", content: "Write a haiku about portals." },
],
});
console.log(resp.choices[0].message.content);Install the SDK with pip install openai or npm install openai.
Streaming
Set "stream": true to receive tokens as server-sent events as they're generated, instead of waiting for the full response.
stream = client.chat.completions.create(
model="portal/m2-25",
messages=[{"role": "user", "content": "Stream a short poem."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)curl https://api.portal.ai/v1/chat/completions \
-H "Authorization: Bearer $PORTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "portal/m2-25",
"stream": true,
"messages": [{ "role": "user", "content": "Stream a short poem." }]
}'The stream ends with a final data: [DONE] event.
Tool / function calling
portal/m2-25 supports tool calling. Describe your functions with a JSON schema and the model decides when to call them, returning structured tool_calls you execute and feed back into the conversation.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
}
]
resp = client.chat.completions.create(
model="portal/m2-25",
messages=[{"role": "user", "content": "What is the weather in Tokyo?"}],
tools=tools,
)
for call in resp.choices[0].message.tool_calls or []:
print(call.function.name, call.function.arguments)Parallel tool calls are supported - the model may return several tool_calls in one response.
Use in Cursor Experimental
Portal is a direct HTTP API first (see the sections above). Using it inside the Cursor editor via BYOK is an experimental convenience, subject to the Cursor-side limitations below - it is not the primary way to use Portal.
Portal can be added as a custom model in Cursor via its OpenAI BYOK setting - Agent mode, tool calling, and streaming. In Settings → Models:
- Set the OpenAI API Key to your sk-portal-… key.
- Enable Override OpenAI Base URL and set it to https://api.portal.ai/v1.
- Add one or more of the tier models below and select one.
The Cursor model name picks the tier (Cursor's Context/Reasoning toggles don't pass through BYOK, so the name is the selector - add the ones you want):
- portal/m2-25 - Portal M2-25 standard ($0.12 / $0.59 per 1M).
- portal/m2-25-fast - Portal M2-25 priority ($0.70 / $3.50).
- portal/m3-55-xh-1m - Portal M3-55 XH, deep reasoning with a 1M context window.
- portal/m3-500k-fast - Portal M3 500K Fast, priority speed with a 500K context window.
Seeing "Unauthorized" after it worked?
Cursor caches an auth failure on the current chat: once a request fails (e.g. an attached image), Cursor short-circuits later sends in that chat with Unauthorized User Openai API key - they never reach Portal. Start a new chat to clear it. Set the model once and avoid switching models mid-chat.
Images / vision
Don't attach images over BYOK. For any multimodal request, Cursor runs a hardcoded key check against api.openai.com (ignoring your base URL), so it fails with Unauthorized and never reaches Portal. This is a confirmed Cursor bug affecting every BYOK provider - there is no proxy-side fix. For a message with a screenshot, switch to a Cursor built-in model for that message; keep a Portal tier (e.g. portal/m2-25) for text and coding.
Billing
BYOK requires a paid Cursor plan for Agent/Edit. You pay Portal for inference (your usage dashboard). On Team/Enterprise, Cursor also adds its own Cursor Token Rate ($0.25 / 1M tokens, incl. cached) on top - shown as "User API Key" cost on Cursor's dashboard. On the Pro individual plan there is no token fee, so you pay only Portal. Use Pro to avoid the surcharge.
Portal MCP
Portal is also available over the Model Context Protocol (Streamable HTTP). Any MCP client - Cursor, agent frameworks, the Portal SDK - can connect and use Portal as a set of tools: run prompts on Portal models, browse marketplace offers, and search skills.
Connect
Add the server to your MCP client config (Cursor: Settings → MCP → Add server, or ~/.cursor/mcp.json):
{
"mcpServers": {
"portal": {
"url": "https://api.portal.ai/mcp",
"headers": { "Authorization": "Bearer YOUR_PORTAL_KEY" }
}
}
}Replace YOUR_PORTAL_KEY with a key from the dashboard's API Keys tab (it starts with sk-portal-). Discovery tools work without a key; the key is required only for portal_call.
The main tool: portal_call
Runs a prompt on a Portal model on Portal compute, billed per token against your key's wallet. The default model is portal/m2-25 - the fast lane, so your first call answers in seconds.
- Input - prompt (plain text; calls are single-turn, so include all context in the prompt) and optionally model (an id from Choosing the model). Optional: system, max_tokens, temperature.
- Output - the model's plain-text reply, no wrapper.
- The pattern - run everyday and bulk calls on portal/m2-25; pick portal/m3-500k-fast for strong-and-fast work; route the deepest reasoning to portal/m3-55-xh-1m.
- Latency - the fast lane answers in seconds; the deep-reasoning tiers think for 10–120 seconds before answering - that is expected, wait for the reply.
All tools
- portal_call - run a prompt on a Portal model (needs your key; defaults to portal/m2-25).
- portal_models_list - callable model ids with pricing and context windows, in the recommended order (fast lane first, orchestrator tier next).
- portal_offers_list / portal_offer_get - marketplace model offers.
- portal_skills_search / portal_skill_get / portal_skill_categories - the live skills catalog.
- portal_activate - activation / checkout links for an offer or skill.
Same wallet, same metering, same Logs as the HTTP API - MCP is a second door to the exact same account.
List models
Returns the models available to your key, in OpenAI's list format.
curl https://api.portal.ai/v1/models \ -H "Authorization: Bearer $PORTAL_API_KEY"
{
"object": "list",
"data": [
{ "id": "portal/m2-25", "object": "model", "owned_by": "portal" },
{ "id": "portal/m2-25-fast", "object": "model", "owned_by": "portal" },
{ "id": "portal/m3-55-xh-1m", "object": "model", "owned_by": "portal" },
{ "id": "portal/m3-500k-fast", "object": "model", "owned_by": "portal" }
]
}See per-model pricing and the context window on the Models page.
Errors
Portal uses standard HTTP status codes and returns a JSON body with an error object on failure.
- 400 - malformed request (bad JSON or missing fields).
- 401 - missing, invalid, or revoked API key.
- 402 - insufficient balance; top up your wallet or enable auto-recharge in Billing.
- 429 - we're briefly at full capacity; safe to retry and not billed. See When we're busy.
- 500 - transient server error; retry with backoff.
{
"error": {
"type": "invalid_request_error",
"message": "Incorrect API key provided."
}
}Every call - success or failure - is recorded with a full transcript in the dashboard's Logs tab.
When we're busy
Sometimes demand briefly outruns capacity. When that happens, the API answers with an honest 429 instead of leaving you hanging: your request was not processed, nothing was lost, and the failed call is not billed. The response tells you exactly when to try again, in the Retry-After header and in the body as error.retry_after (seconds, always the same value).
HTTP/1.1 429 Too Many Requests
Retry-After: 5
{
"error": {
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "We're at full capacity for a brief moment. Your request is safe to retry in 5s: nothing was lost and this attempt is not billed. High demand, more capacity coming online daily. Thanks for building with us.",
"retry_after": 5
}
}The retry pattern that works:
- Single requests: wait the Retry-After seconds, then retry. One retry is usually enough.
- Batches and parallel work: back off adaptively. On each 429, double your wait and add random jitter; after a success, shrink it back. Keep concurrency bounded.
import random, time
def with_retry(send, tries=5):
for attempt in range(tries):
resp = send()
if resp.status_code != 429:
return resp
wait = float(resp.headers.get("retry-after", 2 ** attempt))
time.sleep(wait + random.uniform(0, wait / 4)) # jitter
return respTwo commitments behind this: a paid request is never silently dropped (when we can't serve it, you get an explicit error, not a hang), and failed calls are not billed. A 429 costs you nothing but the wait.