Developer API

OpenAI-compatible inference,
billed to your Privateer account

Point any OpenAI SDK at Privateer and run inference through your account — chat, vision, image and video generation, and speech — the same models, routing, and privacy posture the app uses. No new client libraries: if it speaks the OpenAI format, it works. Web search and page fetch ride the same key, so a grounded answer needs no second vendor.

Base URL https://api.privateer.pro/v1 Auth Bearer sk-priv-… Chat · Images · Video · Audio · Search Streaming SSE

Quickstart

Create an API key in the Privateer app under Settings → API keys, then set it as your OpenAI base URL and key. The key looks like sk-priv-… and is shown only once.

Python (openai)
from openai import OpenAI

client = OpenAI(
    base_url="https://api.privateer.pro/v1",
    api_key="sk-priv-…",
)

resp = client.chat.completions.create(
    model="",  # "" = your account's default model; or an id from /v1/models
    messages=[{"role": "user", "content": "Hello!"}],
)
print(resp.choices[0].message.content)
Node (openai)
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.privateer.pro/v1",
  apiKey: process.env.PRIVATEER_API_KEY,
});

const resp = await client.chat.completions.create({
  model: "",
  messages: [{ role: "user", content: "Hello!" }],
});
console.log(resp.choices[0].message.content);
curl
curl https://api.privateer.pro/v1/chat/completions \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"model":"","messages":[{"role":"user","content":"Hello!"}]}'

Authentication

Every request needs a Privateer API key in the Authorization header:

Authorization: Bearer sk-priv-…

Create, name, and revoke keys in the Privateer app under Settings → API keys. The full key is shown once at creation — we store only a hash, so if you lose it, revoke it and mint a new one. A key inherits its owner's plan, rate limits, and credit balance; revoking it stops every request using it immediately.

Pricing. API usage is pay-as-you-go, drawn from your account credit — across chat, image, video and audio. See the current per-model rate card at privateer.pro/api-pricing.

Keys are managed in the app, not over this API. There is deliberately no /v1 endpoint that mints or lists keys — a key cannot create another key, so a leaked one cannot quietly grow itself a replacement. Minting and revoking happen in a signed-in session, in the app.

🔑

Keep keys server-side. Anyone holding a key can spend your account balance. Never ship one in a mobile app, browser bundle, or public repo.


Privacy & encryption

In the Privateer app, your content is encrypted on your device or in our cloud, with inference on hardware-attested enclaves. This API is different by nature: you send prompts to our server, which forwards them to the model provider to run inference. That traffic is processed in the clear and is not encrypted.

⚠️

What we actually guarantee is retention, not secrecy in transit. Inference is a stateless pass-through: we persist only billing metadata (timestamps, model id, token counts, cost) — never your prompts or the responses. By default requests are pinned to Zero-Data-Retention providers, and confidential models (NEAR AI, Tinfoil) run inside Trusted Execution Environments. Set "requireZdr": false in the request body to opt out of ZDR pinning.

🛡️

Over HTTP, enclave routing is asserted by us — not verified by you. The app earns its Verified badge by checking a TEE attestation on the device, on every response. An API client does no such check, so treat confidential routing here as our policy rather than as proof. If cryptographic verification is what you need, the app and the CLI are the surfaces that can give it to you.


Endpoints

All paths are relative to https://api.privateer.pro/v1.

POST /v1/chat/completions
GET /v1/models
POST /v1/images/generations
POST /v1/videos
GET /v1/videos/{id}
GET /v1/models3d
POST /v1/models3d
GET /v1/models3d/{id}
POST /v1/audio/transcriptions
POST /v1/audio/speech
POST /v1/audio/sfx
POST /v1/search
POST /v1/fetch

POST /v1/chat/completions

Standard OpenAI Chat Completions. Tool calls, multi-part content, and finish_reason pass through unchanged. Common parameters:

FieldTypeNotes
messagesarrayRequired. The conversation, OpenAI format.
modelstringAn id from /v1/models. Empty or omitted uses your account's default model.
streambooleanWhen true, responses stream as SSE (see below).
max_tokens, temperature, …Passed through to the provider unchanged.
requireZdrbooleanPrivateer extension. Defaults to your account setting (ZDR on). Set false to allow non-ZDR routing.

A successful response is the provider's standard Chat Completion object, including a usage block that determines billing.

Streaming

Set "stream": true to receive Server-Sent Events. Each event is a chat.completion.chunk; the stream ends with data: [DONE]. The final chunk carries usage.

curl -N https://api.privateer.pro/v1/chat/completions \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"model":"","stream":true,"messages":[{"role":"user","content":"Count to 3"}]}'

With the OpenAI SDKs, pass stream=True (Python) / stream: true (Node) and iterate the returned stream as usual.


Vision (image input)

Image input works on /v1/chat/completions with no extra endpoint — pass OpenAI multi-part content and a vision-capable model:

curl https://api.privateer.pro/v1/chat/completions \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "near/Qwen/Qwen3.5-122B-A10B",
    "messages": [{
      "role": "user",
      "content": [
        {"type": "text", "text": "What is in this image?"},
        {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,…"}}
      ]
    }]
  }'

Pass a model whose id supports image input (see Models). Data-URI and remote image_urls are both accepted.


Models

GET /v1/models returns the models available to your account in OpenAI's list shape. Use any returned id as the model field; pass an empty string to use your account default.

curl https://api.privateer.pro/v1/models \
  -H "Authorization: Bearer sk-priv-…"
{
  "object": "list",
  "data": [
    { "id": "near/deepseek-ai/DeepSeek-V4-Flash", "object": "model", "owned_by": "privateer" }
  ]
}

Image generation

OpenAI Images shape. Returns base64 by default (b64_json), or set response_format:"url" for a short-lived signed URL. n is capped at 4. Leave model empty for your account's default image model.

curl https://api.privateer.pro/v1/images/generations \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"an otter surfing, watercolor","n":1}'
{
  "created": 1783437233,
  "data": [ { "b64_json": "/9j/4AAQ…" } ]
}
FieldNotes
promptRequired.
modelImage model id, or empty for the account default.
n1–4 images.
size, aspect_ratioOptional, passed to the model.
response_formatb64_json (default) or url.

Video generation

Video generation is asynchronous: submit a job, then poll it. This is a Privateer extension (no OpenAI equivalent). The finished video is returned as a short-lived signed URL.

1 — submit
curl https://api.privateer.pro/v1/videos \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"a butterfly landing on a flower","seconds":4}'

# → 202
{ "id": "NzX8ue0…", "object": "video.job", "status": "queued" }
2 — poll until completed
curl https://api.privateer.pro/v1/videos/NzX8ue0… \
  -H "Authorization: Bearer sk-priv-…"

# processing…
{ "id": "NzX8ue0…", "status": "processing" }

# done
{ "id": "NzX8ue0…", "status": "completed",
  "url": "https://…signed…", "expires_at": 1783440833 }

This endpoint is text-to-video: the prompt is the only conditioning it takes. Image conditioning — pinned first/last frames, reference images, continuing or altering an existing clip — is available in the Privateer apps, not on /v1. For image conditioning that is on the API, see 3D model generation below.

Optional submit fields: model, seconds, size, generate_audio, requireZdr. The signed url is short-lived — each poll of a completed job returns a fresh one. Video requires a paid plan (or held pay-as-you-go credit).

Poll every few seconds. Billing is reserved at submit and settled against the provider's actual cost on completion; a failed job releases the hold.


3D model generation

Turn reference images into a 3D mesh you can import into a game engine or DCC tool. Like video, this is asynchronous and a Privateer extension: submit a job, then poll it. The finished mesh is returned as a short-lived signed URL.

It is image-to-mesh — there is no text-to-mesh endpoint. Send between one and four views of the same object as base64 (or a data: URI); extra views stop the model inventing the sides it cannot see. Remote image URLs are not accepted.

Several models are available, from different labs, and they do not take the same options — one is configured by output resolution, another by a texture tier, another by a quality preset. So start by listing them: GET /v1/models3d returns every model with its price, its output formats, how many views it uses, and the exact options it accepts. Pass the ones you choose as axes.

1 — list the models and their options
curl https://api.privateer.pro/v1/models3d \
  -H "Authorization: Bearer sk-priv-…"

{ "object": "list", "data": [
  { "id": "fal-ai/trellis-2", "object": "model3d", "name": "Trellis 2",
    "formats": ["glb"], "max_views": 1, "price_usd": { "min": 0.35, "max": 0.49 },
    "axes": [
      { "name": "resolution", "kind": "enum", "priced": true,
        "default": "1024", "values": ["512", "1024", "1536"] },
      { "name": "faceCount", "kind": "int", "priced": false,
        "min": 5000, "max": 2000000 } ],
    "conflicts": [] }, … ] }
2 — submit
curl https://api.privateer.pro/v1/models3d \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"image":"iVBORw0KGgo…","model":"fal-ai/trellis-2",
       "axes":{"resolution":"1536","faceCount":250000}}'

# → 202
{ "id": "01a0051b…", "object": "model3d.job", "status": "queued",
  "format": "glb", "estimated_cost_usd": 0.49 }
3 — poll until completed
curl https://api.privateer.pro/v1/models3d/01a0051b… \
  -H "Authorization: Bearer sk-priv-…"

# processing… (typically 60–90 seconds)
{ "id": "01a0051b…", "status": "processing" }

# done
{ "id": "01a0051b…", "status": "completed", "format": "glb",
  "url": "https://…signed…", "expires_at": 1783440833 }
FieldTypeNotes
image / imagesstring / arrayRequired. 1–4 base64 images or data: URIs of the same object, read as front, back, left, right. 12 MB total. How many are actually used is per-model — see max_views.
modelstringAn id from GET /v1/models3d. Omit for your account's default.
axesobjectThe chosen model's own options, as {"name": value}. Names and legal values come from that model's axes in the list response; an option the model doesn't have is rejected rather than ignored. Omit one to take its default — for a numeric budget that means letting the provider choose, which on some models is what avoids a surcharge.
formatstringOne of the model's formats; the first is the default. Prefer glb — it is the only container that carries the materials.
generate_typestringHunyuan models only, and equivalent to axes.generateType. Normal (default, textured), LowPoly (retopologised, best for anything that deforms), Geometry (untextured, for blockouts).
polygon_typestringHunyuan models only. triangle (default) or quadrilateral. Quads deform better under animation.
face_countintegerTarget face budget, honoured exactly; the legal range is per-model. Omit for the provider's default, which produces a much larger file.
pbrbooleanGenerate PBR materials (base colour, normal, metallic/roughness). Rejected on any model producing untextured geometry.

The named fields above are the older, Hunyuan-shaped spelling and still work; axes is the general one and wins where both are sent. It is the only way to reach the options on the other models.

Cost depends on which model you pick and which options you set — the spread across the catalog is more than tenfold, and an option that is a surcharge on one model is free on another, which is why the price lives in the list response rather than here. The submit response reports estimated_cost_usd for your exact request before you commit to polling; that is the figure that gets charged. 3D generation requires a paid plan (or held pay-as-you-go credit).

Poll every few seconds. Billing is reserved at submit and settled on completion; a failed job releases the hold. Each poll of a completed job returns a fresh signed URL until the mesh is swept from storage.


Audio (speech-to-text, text-to-speech & sound effects)

Transcription (STT) — POST /v1/audio/transcriptions

OpenAI Whisper shape: multipart upload with a file field (or JSON { "audioBase64", "format" }). Returns { "text": … }.

curl https://api.privateer.pro/v1/audio/transcriptions \
  -H "Authorization: Bearer sk-priv-…" \
  -F file=@speech.mp3 \
  -F model=openai/whisper-1 \
  -F requireZdr=false

Speech (TTS) — POST /v1/audio/speech

Returns raw audio bytes. The default voice model returns audio/wav; request response_format:"mp3" only with an mp3-capable model.

curl https://api.privateer.pro/v1/audio/speech \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"input":"Hello from Privateer.","voice":"Zephyr"}' \
  --output speech.wav
🔒

Audio is ZDR-pinned by default, like chat. The default STT model has no Zero-Data-Retention endpoint, so transcription needs requireZdr:false or a confidential (enclave) STT model.

Sound effects — POST /v1/audio/sfx

One short sound from a text description. Returns raw audio/mpeg bytes, plus an X-Privateer-Duration-Seconds header giving the length actually rendered.

input is required (max 500 characters). duration is in seconds and is clamped to 1–30, defaulting to 5 — a value outside that range is corrected, not rejected.

curl https://api.privateer.pro/v1/audio/sfx \
  -H "Authorization: Bearer sk-priv-…" \
  -H "Content-Type: application/json" \
  -d '{"input":"a heavy wooden door slamming shut","duration":3}' \
  --output door.mp3
🔒

This is the one audio endpoint behind the non-ZDR media gate. Sound effects run on a provider with no Zero-Data-Retention endpoint, so if your account requires ZDR you'll get 403 ZDR_MEDIA_BLOCKED until non-ZDR media generation is enabled — either per request with "allowNonZdrMedia": true, or as an account setting. This is the same rule the app enforces, not an API-only restriction. Your prompt is sent unattributed and we ask the provider to store neither it nor the generated file, but we can't promise zero retention the way we can for a ZDR-pinned model — so the choice stays yours to make explicitly.



Agent Client Protocol

Everything above is the hosted inference API. This section is different: it's about the Privateer agent — the one that runs on your own machine — and how other tools can drive it.

Privateer speaks the Agent Client Protocol (ACP), the same interface Zed and other editors use to talk to coding agents. A host launches privateer acp and speaks newline-delimited JSON-RPC over stdio; the host owns the transport and the interface, Privateer owns the agent.

privateer acp

The point of running it this way is that the moat comes with it. The host renders approvals, it does not grant them:

  • The permission gate still decides. Every action is classified locally and gated actions are sent to the host as session/request_permission. A cancelled dialog, an unparseable answer, or a host that can't be reached all resolve to deny.
  • The tool ceiling is yours, not the host's. Set acp.tools in ~/.privateer/config.json. A host cannot widen it.
  • Destructive actions can never become standing permission. Dangerous shell and guarded files are never offered an "allow always" option, so they re-confirm every time.
  • Your models, your account. The agent offers its own model list over the protocol, including the confidential TEE models, and runs on your subscription.

Configure it under an acp block in ~/.privateer/config.json. The default ceiling is read-only:

{
  "acp": {
    "tools": ["read", "grep", "find", "ls"],
    "posture": "approve"
  }
}

posture is readonly, approve (default — each risky action asks), or auto. The working directory comes from the host per session, and access outside it is refused rather than prompted.


Add Privateer to Buzz

Buzz is Block's self-hostable workspace where people and agents share the same channels. Because Buzz drives agents over ACP, Privateer drops in as a custom harness and you can message it like a teammate.

In Buzz Desktop, add a custom harness with:

FieldValue
NamePrivateer
Commandthe absolute path to your node binary, e.g. /usr/local/bin/node
Argumentsone argument: the absolute path to bin/privateer-acp.mjs

Use absolute paths for both. Buzz is a desktop app and inherits a minimal PATH, so a version-managed node (nvm, asdf) usually isn't on it. The path to privateer-acp.mjs is a separate argument, not part of the command.

Then create an agent that uses the harness. Two settings are worth changing:

SettingValueWhy
Parallelism (on the agent, not the harness)1Privateer holds one account session per process, so parallel copies contend for it. One process also cuts startup from about a minute to a few seconds. Set this on the agent record — Buzz passes it as an explicit flag, and flags beat the BUZZ_ACP_AGENTS env var.
BUZZ_ACP_LAZY_POOL (env var)trueJoin the channel first and warm the agent pool after, so the agent shows online immediately. It is a boolean — 1 is rejected and the harness exits with status 2.
⚠️

Buzz auto-approves every permission request. Its harness answers session/request_permission itself by selecting the "allow once" option — there is no path to a human, and no setting changes that. (Buzz's --permission-mode flag only reaches agents that implement session/set_config_option, which Privateer does not.) Privateer still classifies every action and still refuses anything outside the session's working directory, but in Buzz a posture of approve behaves like auto. Your real controls are acp.tools and acp.posture — a host cannot widen either. Choose the ceiling on the assumption that everything in it will run unattended.

Buzz holds the agent's Nostr identity, so its display name and avatar are set on the Buzz side, not by Privateer.


Connectors (MCP)

🔌

There is no MCP endpoint on this API. The hosted API above is inference only — it has no tools and holds no credentials of yours. MCP is a capability of the Privateer agent, the one that runs on your own machine. If you're looking for a way to give your product tools, that's your MCP client's job; this section is about giving the agent tools.

Privateer is an MCP client. Point it at a Model Context Protocol server and that server's tools become first-class agent tools — available to the CLI, the desktop app, the always-on harbor's unattended routine runs, and to any ACP host driving the agent. Two kinds:

TransportWhat it means
stdioLocal. Privateer spawns the server as a child process on your machine. Nothing leaves the box except what that server itself chooses to send.
httpRemote. An https endpoint someone else hosts, authenticating with oauth (you authorize in a browser on that machine), a static bearer token, or nothing. Whatever the agent hands it leaves your machine.

Configuration

Connectors live in two files under the agent's home. The first is the source of truth; the second is generated from it and is the standard shape the MCP adapter reads — edit the source, never the projection.

~/.privateer/agent/mcp-desktop.json   # source of truth — every connector, each with `enabled`
~/.privateer/agent/mcp.json           # projection: enabled connectors only
mcp.json (generated)
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "…" }
    },
    "linear": {
      "url": "https://mcp.linear.app/sse",
      "auth": "oauth"
    }
  },
  "settings": { "toolPrefix": "server" }
}

Both files sit in the shared ~/.privateer home, so one machine has one coherent connector config whichever surface edited it — the terminal, the desktop app over loopback IPC, or the phone over the relay.

Adding one

In the terminal, /connect opens a picker over a curated catalog of 22 connectors (GitHub, Slack, Notion, Linear, Jira & Confluence, Sentry, Stripe, Asana, Supabase, Figma, Gmail, Google Drive, PostgreSQL, Playwright, Filesystem, …) plus a Custom connector entry that takes any stdio command line or any https:// URL. It reloads the adapter in place, so new tools are live in the session you're already in. /mcp is the adapter's own status view — what actually connected. The Privateer app and the desktop app have the same editor.

Tools and the permission gate

By default the adapter exposes MCP through a single proxy tool named mcp: one grant covers every enabled server. When a scheduled routine carries a per-connector allow-list, Privateer instead scopes that run to exactly the selected servers and tools, each registered under its own <server>_<tool> name — so an unattended task can hold GitHub's create_issue without holding all of MCP.

Either way, every MCP tool is classified and gated exactly like a built-in. Under ACP that means an MCP tool call surfaces as a session/request_permission to the host, and your acp.tools ceiling still applies — a tool is not trusted because you configured the server it came from.

Credentials

⚠️

A connector's secrets — env values, a bearer token, an Authorization: header — are stored in plaintext in mcp-desktop.json on that machine. That is unavoidable: the adapter has to hand the real token to the server. Protect the file the way you protect ~/.aws/credentials. The masked input in /connect and the app is screen-share hygiene, not a storage claim.

Editing connectors from the phone or web app is the one place that's different, because the relay in between is untrusted: secrets are write-only in both directions. A listing returns env and header names and which of them are set, never a value; a value you type on the phone is sealed to that terminal's pinned key before it leaves the device, and the whole save is signed by your account — so the relay can neither read a credential nor forge a connector.

Hosted (Harbor) agents are OAuth-only by design: no stdio child processes and no stored tokens, because a hosted tenant's home is tmpfs and a durable secret would have to rest somewhere we could read. During the current preview they carry no connectors at all.

Full setup and troubleshooting live in the CLI guide.


Billing & limits

Usage is billed to your Privateer credit balance at your plan's rate, metered by the usage token counts on each completion. Top up and see your balance in the app. Requests are subject to your plan's per-minute rate limit, daily message cap, and a small minimum-balance check — the same limits that govern the app.

LimitDefaultOn breach
Requests per minute120, over a rolling 60-second window (plans may raise this)429 RATE_LIMITED with retryAfter
Requests in flight at once8 — a pool of its own, separate from the app's and the agent's429 CONCURRENCY_LIMIT with cap
Daily messagesYour plan's cap429 naming the tier
Minimum balanceChecked before each billable call402 INSUFFICIENT_FUNDS with a top-up link
Audio upload size25 MB per file on /v1/audio/transcriptionsThe upload is rejected before transcription

Retry 429s with backoff, honouring retryAfter when present. A CONCURRENCY_LIMIT means slow down your fan-out, not your rate — waiting for an in-flight request to settle is what clears it.

No key, no charge you can't see. Every billed request writes a usage record (model, tokens, cost) you can reconcile — prompts and responses are never stored.


Errors

There are two error shapes, and it matters which one you get. Errors raised by an endpoint itself — bad input, a provider failure, a blocked model — use OpenAI's envelope, so an OpenAI SDK parses them normally. Errors raised by the gates that run before the endpoint (rate limit, credit check, plan caps, concurrency) return a flat body instead. An SDK that only reads error.message will see nothing on those; read the top-level code as well.

OpenAI envelope — from the endpoint
{ "error": { "message": "…", "type": "invalid_request_error", "code": "INVALID_REQUEST" } }
Flat — from a gate
{ "code": "RATE_LIMITED", "message": "…", "retryAfter": 37 }
StatusCodeShapeMeaning
400INVALID_REQUEST, PROMPT_REQUIRED, QUERY_REQUIRED, URL_REQUIRED, URL_INVALIDenvelopeMalformed body (e.g. missing messages, empty input, no query, a relative or non-http URL) or an unknown model.
401invalid_api_keyenvelopeMissing, malformed, or revoked key; or the key's account isn't active.
402INSUFFICIENT_FUNDSflat or envelopeBalance too low. The pre-flight credit check is flat and adds balance, required and topUpUrl; a shortfall found mid-request comes back in the envelope.
403ZDR_MEDIA_BLOCKEDenvelopeThe model has no Zero-Data-Retention endpoint and your account requires ZDR. See sound effects.
403plan-gated featureflatYour plan doesn't include this (e.g. video). Adds effectiveTier, requiredTier, feature, graceEndsAt.
429RATE_LIMITEDflatOver the per-minute limit. Adds retryAfter in seconds.
429CONCURRENCY_LIMITflatToo many requests in flight at once. Adds cap.
429daily capflatPlan's daily message cap reached. Adds effectiveTier and cap.
502IMAGE_GEN_FAILED, INFERENCE_ERROR, SEARCH_FAILED, FETCH_FAILEDenvelopeThe provider failed. The upstream status and detail are folded into message so it's actionable. A failed search isn't billed.
503ZDR_KEY_UNAVAILABLEenvelopeZDR routing was required and no compliant key was available. This fails loudly by design rather than quietly falling back.

Provider errors are forwarded rather than reinterpreted, so an unfamiliar code in the envelope generally came from upstream.