API
Invoke agents from your backend
POST /v1/projects/{project}/agents/invoke sends one message to an agent
from your backend. Mobius resolves or creates an
agent session, appends the caller input, and
starts one turn.
Use this endpoint for embedded product chats, provider relays, and other server-side integrations where your app owns the user-facing transport. Use the lower-level session APIs only when your code already owns session creation or transcript pagination.
Choose a response mode
The invoke endpoint has two response modes. Both start the same turn.
| Mode | How to request it | Use when |
|---|---|---|
| Inline stream | Send Accept: text/event-stream on POST /agents/invoke. | Your HTTP request is already a chat stream or another long-lived server response. |
| Acknowledge, then stream | Use the default JSON response, then open GET /sessions/{session_id}/stream?after_sequence=N. | You need session.id and turn.id before streaming, or you need to acknowledge a provider webhook quickly. |
When in doubt for product chat, use the inline stream. It has fewer moving parts. Use the two-request shape when your backend needs the acknowledgement as a durable handoff point.
Request shape
Send an agent reference, a session policy, and one input message:
{
"agent_ref": {
"id": "agt_scout"
},
"session": {
"mode": "continue_or_create",
"session_key": "app:acct_123:user_456:support",
"title": "Support chat",
"metadata": {
"account_id": "acct_123",
"user_id": "user_456"
}
},
"input": {
"content": [
{
"type": "text",
"text": "Can you summarize my open tickets?"
}
],
"idempotency_key": "msg_01J8..."
}
}session_key is your stable conversation key inside the agent. For an
embedded app, derive it from your account, user, and conversation identifiers.
For a Slack or Telegram relay, derive it from the provider workspace,
conversation, and thread identifiers.
input.idempotency_key is scoped to the resolved session. Derive it from the
inbound message id your system already stores. A repeated invoke with the same
key resumes the existing turn instead of writing another caller message.
Inline stream
Inline streaming starts the turn and returns the session stream on the same HTTP response:
const response = await fetch(
`${process.env.MOBIUS_BASE_URL}/v1/projects/platform/agents/invoke`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.MOBIUS_API_KEY}`,
"content-type": "application/json",
accept: "text/event-stream"
},
body: JSON.stringify({
agent_ref: { id: "agt_scout" },
session: {
mode: "continue_or_create",
session_key: "app:acct_123:user_456:support",
title: "Support chat"
},
input: {
content: [{ type: "text", text: "What changed since yesterday?" }],
idempotency_key: "msg_01J8..."
}
})
}
);
if (!response.ok || !response.body) {
throw new Error(`Mobius invoke failed: ${response.status}`);
}The body is text/event-stream. Durable transcript frames carry an SSE
id: equal to the message sequence. Persist that id as your reconnect
cursor. Live preview frames do not carry a durable cursor.
Example frames:
id: 42
event: user.message
data: {"message_id":"sesmsg_...","sequence":42,"role":"user","turn_id":"turn_...","content":[{"type":"text","text":"What changed since yesterday?"}]}
event: turn.started
data: {"event_type":"turn.started","session_id":"ses_...","turn_id":"turn_..."}
id: 43
event: agent.message
data: {"message_id":"sesmsg_...","sequence":43,"role":"assistant","turn_id":"turn_...","content":[{"type":"text","text":"The main change is..."}]}
event: turn.completed
data: {"event_type":"turn.completed","session_id":"ses_...","turn_id":"turn_...","dedupe_key":"turn_...:completed"}
event: stream.end
data: {"event_type":"stream.end","session_id":"ses_...","reason":"idle"}Stop rendering the turn when you see turn.completed, turn.failed, or
turn.cancelled for the turn you started. A stream.end frame with
reason: "idle" means the server is closing the connection because the
session has no active turns. A dropped connection without stream.end is not
a terminal signal; reconnect with your last durable cursor.
Acknowledge, then stream
By default, POST /agents/invoke returns 202 Accepted with a session,
turn, and stream cursor:
{
"session": {
"id": "ses_01J8..."
},
"turn": {
"id": "turn_01J8...",
"status": "queued"
},
"after_sequence": 41,
"deduped": false
}Open the session stream from after_sequence:
const invoke = await fetch(
`${process.env.MOBIUS_BASE_URL}/v1/projects/platform/agents/invoke`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.MOBIUS_API_KEY}`,
"content-type": "application/json"
},
body: JSON.stringify({
agent_ref: { id: "agt_scout" },
session: {
mode: "continue_or_create",
session_key: "app:acct_123:user_456:support",
title: "Support chat"
},
input: {
content: [{ type: "text", text: "What changed since yesterday?" }],
idempotency_key: "msg_01J8..."
}
})
}
);
if (!invoke.ok) {
throw new Error(`Mobius invoke failed: ${invoke.status}`);
}
const ack = (await invoke.json()) as {
session: { id: string };
turn: { id: string };
after_sequence: number;
};
const stream = await fetch(
`${process.env.MOBIUS_BASE_URL}/v1/projects/platform/sessions/${ack.session.id}/stream?after_sequence=${ack.after_sequence}`,
{
headers: {
authorization: `Bearer ${process.env.MOBIUS_API_KEY}`,
accept: "text/event-stream"
}
}
);Do not open the stream from the beginning and filter old history by turn_id.
Use after_sequence as the cursor, then keep turn_id filtering as a guard
so your UI ignores frames from another in-flight turn in the same session.
If stream connection fails after invoke succeeded, reopen the stream with the
last durable SSE id: you processed. Do not invoke again unless you reuse the
same input.idempotency_key.
Parse the stream
The stream uses standard server-sent events (SSE). A minimal parser needs to
track event:, id:, and data: lines:
async function* readSSE(response: Response) {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let event = "message";
let id: string | undefined;
let data: string[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line === "") {
if (data.length > 0) {
yield { event, id, data: JSON.parse(data.join("\n")) };
}
event = "message";
id = undefined;
data = [];
} else if (line.startsWith("event:")) {
event = line.slice(6).trim();
} else if (line.startsWith("id:")) {
id = line.slice(3).trim();
} else if (line.startsWith("data:")) {
data.push(line.slice(5).trimStart());
}
}
}
}Persist only id values from durable frames. generation.delta,
session.message.preview, tool.call, tool.result, and other ephemeral
frames may arrive between durable rows, but they are not replay cursors.
Retry rules
- Always send
input.idempotency_keyfrom your own stored message or provider event id. - If invoke times out and you do not know whether Mobius received it, retry invoke with the same request body and idempotency key.
- If invoke returned an acknowledgement and only the stream failed, reconnect to the session stream. Do not invoke again.
- On reconnect, send the last durable SSE
id:asafter_sequenceorLast-Event-ID. - Treat
turn.completed,turn.failed, andturn.cancelledas terminal for the matchingturn_id.
Related
- Agent messaging explains when to use messaging instead of a loop.
- Agent sessions explains transcripts, turns, compaction, and stream frames.
- Event catalog lists every session-stream frame.
- API Introduction covers authentication and project scope.