Skip to main content

Invoke API

Premium feature

POST /api/invoke/{id} is BasePeak.AI's native way to run an agent. Unlike the OpenAI-compatible surface, it exposes the agent's full event stream: tool calls, step transitions, OAuth prompts and errors, not only the assistant's text.

Use it when you want to show what the agent is doing, or when you need a long-running turn that does not hold a connection open.

Request

POST /api/invoke/{id} HTTP/1.1
Authorization: Bearer sk-bpai-1-42-…
Accept: text/event-stream
Content-Type: text/plain

Summarise the Q3 report and list the three biggest risks.

The body is plain text, not JSON

This is the most common mistake when moving from the OpenAI surface. The request body is used verbatim as the agent's prompt. There is no messages array, no JSON envelope, no model field.

If you send {"messages":[…]} here, the agent receives that JSON as the literal text of your question.

Path parameter {id}

{id} selects what to run and accepts three forms:

FormExampleBehavior
Agent IDa17jlm7Runs that agent.
Agent aliassupport-botResolved to the agent behind the alias.
Thread IDt1abcdeRuns the agent that owns the thread, continuing that thread.
Scope is derived from the path, not a header

The required scope is agent:chat:<the value in the path>. This differs from /v1/chat/completions, which derives it from the X-BPAI-Agent header.

So a key scoped to agent:chat:a17jlm7 can call /api/invoke/a17jlm7, but calling /api/invoke/t1abcde with a thread ID in the path requires agent:chat:t1abcde or a wildcard agent:chat:* key. To continue a thread with an agent-scoped key, keep the agent in the path and pass the thread separately — see Selecting a thread.

Selecting a thread

Two routes carry the thread in the path, and there is also a header:

POST /api/invoke/{id}
POST /api/invoke/{id}/thread/{thread}
POST /api/invoke/{id}/threads/{thread}
HowPrecedence
{thread} path segmentWins if present.
X-BPAI-Thread-Id headerUsed when the path carries no thread.
NeitherA new thread is created.

The response always carries X-BPAI-Thread-Id, for new and resumed threads alike. Record it — that remains the most direct way to continue the conversation. See Threads.

Headers

HeaderRequiredPurpose
AuthorizationyesBearer sk-bpai-<user>-<key>-<secret>
AcceptnoSelects the response format — see below.
X-BPAI-Thread-IdnoResume a thread.
X-BPAI-Scoped-ToolsnoRestrict the run to a subset of the agent's tools.

Query parameters

ParameterDefaultMeaning
asyncfalsetrue returns immediately with IDs instead of streaming.

Choosing a response format

For a synchronous call the Accept header — and nothing else — decides what comes back:

AcceptResponse
text/event-streamSSE stream of events, live as the run progresses.
application/jsonOne JSON envelope {"items":[…]} after the run finishes.
anything else, or absentPlain text: the content of each event, concatenated, streamed.
Accept is matched exactly, so */* breaks streaming

The header value is compared literally against the whole Accept header. Accept: text/event-stream works. Accept: text/event-stream, */* does not — it falls through to the plain-text branch.

Many HTTP clients append */* or send it by default. If you expect SSE and get an unframed text blob, this is why. Send the bare value:

curl -H 'Accept: text/event-stream' …   # correct
curl -H 'Accept: text/event-stream, */*' … # silently NOT SSE

The same applies to application/json.

The plain-text form flushes at most every 500 ms, so it streams but in small batches rather than token-by-token.

Server-sent events

With Accept: text/event-stream the stream is framed as follows.

1. Stream opens immediately, before the agent produces anything:

event: start
data: {}

2. Each event carries the run ID as the SSE id field, then the event object as data:

id: r1xyz
data: {"runID":"r1xyz","threadID":"t1abcde","content":"The Q3 report "}

3. The final event of a run uses a distinct id suffix — :after — alongside runComplete:

id: r1xyz:after
data: {"runID":"r1xyz","runComplete":true,"content":""}

4. Keepalives appear as SSE comments whenever the run goes quiet for 20 seconds:

: ping

This exists because a slow first token or a long tool call would otherwise leave the socket idle until an intermediate proxy closes it. Comment lines are dropped automatically by EventSource and every conformant SSE parser — you only need to handle them if you parse the stream by hand.

5. Stream closes:

event: close
data: {}

Treat a missing event: close as an abnormal end.

The event object

Every data: frame is a JSON object. The fields you will actually use:

FieldMeaning
contentOutput text. Concatenate this across events to build the answer.
contentIDIdentifies a content run, so repeated fragments can be tracked.
runID / threadIDWhich run and thread the event belongs to.
runCompletetrue on the last event of the run.
errorSet when the run failed.
timeWhen the event was generated.

Richer fields, at most one of which is set per event:

FieldMeaning
waitingOnModelThe model has not started responding yet — good for a spinner.
toolInputThe model is composing tool arguments (this can be slow).
toolCallA tool is being invoked.
stepThe current step changed; following events belong to it.
promptThe agent needs something from the user — most often an OAuth login. Carries fields, message and a sensitive flag.
inputInput that was supplied to the run.
replayCompleteAll pre-existing events have been delivered; what follows is live.
usernameWho triggered the run.

The simple integration is genuinely simple: if none of the richer fields is set, just print content. Handle the others only when you want to surface progress.

Fire-and-forget with async

curl -sS -X POST \
'https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7?async=true' \
-H "Authorization: Bearer sk-bpai-1-42-…" \
--data 'Produce the monthly report and email it to the team.'

Returns at once:

{
"threadID": "t1abcde",
"runID": "r1xyz"
}

The run continues server-side. This is the right choice for work that outlives an HTTP timeout.

Polling an async run

An async invoke is not one-way: with a conversation-read scope — thread:read for the agent, or project:threads for the conversation's project — GET /api/threads/{id}/events delivers a run's events after the fact, and POST /api/threads/{id}/abort cancels it (covered by agent:chat).

Pass the runID from the response above. Without it the route returns the thread's last completed run — on a resumed thread, the previous turn's answer.

curl -sS -H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
'https://your-instance.platform.basepeak.ai/api/threads/t1abcde/events?runID=r1xyz'

With Accept: application/json this call stays open until the run finishes. For runs that outlast an HTTP timeout, poll GET /api/threads/{id} instead — it answers immediately and reports completion through lastRunID.

Details and both options in full: Threads.

Restricting the agent's tools

X-BPAI-Scoped-Tools limits a single run to a subset of the agent's configured tools, without changing the agent:

X-BPAI-Scoped-Tools: google-calendar, gmail

Comma-separated tool references; whitespace is trimmed and blanks dropped. Omit the header to inherit the agent's tools — subject to the key's credential scopes, which can strip credential-backed tools from the run. See Credential scopes.

Useful for making one integration narrower than the agent it calls — a status-page bot that may read the calendar but not send mail, for instance.

Chatting inside a project

POST /api/invoke/{id} always talks to the bare agent — no shared workspace, none of its tasks as tools, no project-scoped credentials or model settings. That is the thinner surface, and deliberately so: correct for headless agents and system agents that belong to no project, but not the conversation the browser runs.

For that, there is a matching route pair addressed by project:

POST /api/projects/{project_id}/invoke
POST /api/projects/{project_id}/invoke/thread/{thread_id}

Scope project:chat:<project_id> instead of agent:chat:<id>; otherwise identical behavior — same response body, same X-BPAI-Thread-Id header, same Accept variants, same ?async=true. The agent comes from the project — you never name one:

curl -sS -X POST \
https://your-instance.platform.basepeak.ai/api/projects/p1abc/invoke \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
-H "Content-Type: text/plain" \
--data 'Summarise the files in this project.'

Full details, the scope table, and the other project routes (files, knowledge, tasks, environment variables): Projects.

Errors

Errors on this surface use the platform's problem format, not the OpenAI error envelope:

StatusWhen
400{id} resolves to no agent.
401Missing, malformed, unknown or expired bearer.
403The key's scopes do not cover the requested agent; or the key's owner has no access to that agent; or API access is disabled for the instance.
404The agent, alias or thread does not exist.
500Server-side failure resolving the agent or starting the run.

A wildcard agent:chat:* key is re-checked against its owner's own permissions, so it cannot reach agents its owner could not open in the browser.

Once the SSE stream has started, a failure arrives as an event with error set rather than as an HTTP status — the status line was already sent. If the client disconnects, the server notices on the next write and cancels the run.

Examples

curl — streaming

curl -N -sS -X POST \
https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7 \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: text/event-stream" \
-H "Content-Type: text/plain" \
--data 'Give me three bullet points on the Q3 numbers.'
event: start
data: {}

id: r1xyz
data: {"runID":"r1xyz","threadID":"t1abcde","waitingOnModel":true,"content":""}

id: r1xyz
data: {"runID":"r1xyz","content":"- Revenue up 12%"}

: ping

id: r1xyz:after
data: {"runID":"r1xyz","runComplete":true,"content":""}

event: close
data: {}

Python — stream and print text

import httpx

BASE = "https://your-instance.platform.basepeak.ai"
TOKEN = "sk-bpai-1-42-…"
AGENT = "a17jlm7"

with httpx.stream(
"POST", f"{BASE}/api/invoke/{AGENT}",
headers={
"Authorization": f"Bearer {TOKEN}",
# Exactly this value — appending */* silently disables SSE.
"Accept": "text/event-stream",
"Content-Type": "text/plain",
},
content="Summarise today's support tickets.",
timeout=None,
) as r:
r.raise_for_status()
thread = r.headers["x-bpai-thread-id"] # keep this to continue later

import json
for line in r.iter_lines():
if not line.startswith("data: "):
continue # skips ': ping' and 'id:'/'event:'
event = json.loads(line[6:])
if event.get("error"):
raise RuntimeError(event["error"])
if text := event.get("content"):
print(text, end="", flush=True)
if event.get("runComplete"):
break

print(f"\n\nthread: {thread}")

Python — show tool activity

for line in r.iter_lines():
if not line.startswith("data: "):
continue
e = json.loads(line[6:])

if e.get("waitingOnModel"):
print("[thinking…]")
elif tc := e.get("toolCall"):
print(f"[calling {tc.get('name', 'tool')}]")
elif st := e.get("step"):
print(f"[step: {st.get('description', '')}]")
elif p := e.get("prompt"):
# Usually an OAuth consent the agent needs before it can continue.
print(f"[action needed: {p.get('message', '')}]")
elif txt := e.get("content"):
print(txt, end="", flush=True)

Node — collect the whole answer as JSON

Ask for one envelope instead of a stream when you only want the result:

const res = await fetch(`${BASE}/api/invoke/${AGENT}`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/json", // exactly this — no ', */*'
"Content-Type": "text/plain",
},
body: "What changed in the roadmap this week?",
});

const thread = res.headers.get("x-bpai-thread-id");
const { items } = await res.json();
const answer = items.map((e) => e.content ?? "").join("");

console.log(answer, "\nthread:", thread);