Develop
Invoke agents from your backend
POST /v1/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 returns the existing invocation instead of writing another caller message.
It does not restart a cancelled, failed, or completed turn.
Omitting it or sending a blank value disables retry deduplication: an
overlapping retry receives session_turn_active, while a retry after the turn
is terminal starts another turn.
These names identify different things. session_key identifies the durable
conversation across many turns. idempotency_key identifies one input
operation so a retry does not create another turn. Session responses may still
include the deprecated scope_name alias; read session_key instead.
scope_ref_id is advanced provenance for the resource that owns the session
namespace, and Mobius derives it from the agent for normal API invocation.
Find a session by key
Session keys are unique within one agent, not across the whole organization. Resolve a conversation without creating it by pairing the key with either the agent ID or its organization-unique name:
GET /v1/sessions?agent_name=Scout&session_key=app%3Aacct_123%3Auser_456%3AsupportUse agent_name when your application configuration already stores a readable
agent name. Use agent_id when you have the ID. Supplying both returns
session_agent_ref_conflict; supplying a key without either returns
session_key_scope_required. A valid lookup with no matching conversation
returns an empty items array.
Attach application context
Use input.context when your application keeps authoritative state outside the
chat transcript and the agent needs the current snapshot for this turn. Each
item has a stable name and a verbatim string value:
{
"agent_ref": { "id": "agt_namer" },
"session": {
"mode": "continue_or_create",
"session_key": "account:acct_123:naming"
},
"input": {
"content": [
{
"type": "text",
"text": "Give me five more names like Weftly."
}
],
"idempotency_key": "msg_01J8...",
"context": [
{
"name": "naming-board",
"content": "Current naming board:\nChosen: none\nShortlisted: Weftly (weftly.com), Fablio (fablio.io)\nRejected: Threadline"
}
]
}
}The model receives this item under the namespaced name
app-naming-board. If the agent's instructions refer to the context by name,
use that app--prefixed name. Application context guides the model but never
grants permissions or overrides API authorization. Supply names without that
namespace: Mobius always prepends app-, so app-board is delivered as
app-app-board.
Send the full current value for each name on every relevant invocation. Mobius records a new value only when that name is first seen, its exact UTF-8 bytes change, or compaction has removed the prior value from the model's active window. Make renderers deterministic: sort unordered records and omit volatile timestamps unless they are part of the state. Stable rendering avoids needless context rows and keeps the reusable prompt prefix intact.
If one of your custom HTTP actions changes that state during the turn, return
the updated snapshot in an
action response envelope.
It uses the same item shape and app-* namespace, so an unchanged write is a
no-op and a changed value becomes the next recorded point-in-time snapshot.
Omitting a name means no update; the previous value continues to apply. To
clear application state, send an explicit replacement such as Chosen: none
or Shortlisted: none. A retry with the same input.idempotency_key returns
the original turn and ignores newly supplied context, just as it ignores
different message content.
Per invocation, context is limited to eight unique names. Names must match
^[a-z][a-z0-9-]*$ and be at most 64 characters. Content is limited to 8 KiB
per item and 16 KiB total.
Runtime context is hidden from normal transcript reads. To inspect what Mobius
actually recorded, pass include=context to either message-list endpoint:
GET /v1/sessions/{session_id}/messages?include=context
GET /v1/turns/{turn_id}/messages?include=contextThose reads include only your app-* rows; Mobius-owned runtime context stays
hidden. An unchanged snapshot correctly produces no new row, so compare the
latest recorded item rather than expecting one row per turn. The lower-level
POST /sessions/{session_id}/turns endpoint accepts the same top-level
context array alongside content and idempotency_key.
One direct invocation at a time
A session accepts one nonterminal direct invocation at a time. If its
direct turn is queued, running, or waiting, a second invoke with a
different idempotency key returns 409 session_turn_active before Mobius
appends the second input:
{
"error": {
"code": "session_turn_active",
"message": "session already has an active direct turn",
"details": {
"turn_id": "turn_01J...",
"status": "running"
}
}
}Retrying the original idempotency key still returns its existing turn. Once that turn is terminal, a new key is accepted. Loop- and channel-owned turns keep their existing admission rules.
When new user direction arrives during a turn, make the product choice explicit:
- For
runningorwaiting, callnudgeSessionto steer the active turn at its next iteration boundary. Setwake: trueif a waiting interruptible tool should stop immediately. - For
queued, a nudge cannot steer work that has not started. Normally wait for the blocking turn to finish and invoke again. If "run this next" is the intended behavior, nudge and inspect the returneddelivery; it will benew_turnif no turn was nudgeable.
The nudge endpoint is race-safe: if the blocking turn finishes before the nudge lands, Mobius promotes the input to a follow-up turn instead of losing it. It is independently idempotent, so use the same inbound-message id as the nudge idempotency key. The SDK error and fallback shape is:
import { MobiusAPIError } from "@deepnoodle/mobius";
try {
await client.invokeAgent(request);
} catch (error) {
if (!(error instanceof MobiusAPIError) || error.code !== "session_turn_active") {
throw error;
}
const status = error.details?.status;
if (status === "running" || status === "waiting") {
const ack = await client.nudgeSession(sessionId, {
content: incomingText,
idempotencyKey: inboundMessageId,
wake: status === "waiting",
});
// ack.delivery is "current_turn" or "new_turn" after a terminal race.
} else {
await waitForTurn(error.details?.turn_id);
await client.invokeAgent(request);
}
}A delivered nudge becomes a durable user-role reminder row with
metadata.session_nudge_id. Correlate it with the acknowledgement's
nudge_id if your UI renders steering as a chip instead of a normal message.
Cancellation and retries
Cancelling a turn is idempotent and terminal. The first cancellation that wins
marks the turn cancelled; repeating the request returns the current terminal
turn without emitting another lifecycle transition. Cancellation also retires
pending jobs, waits, human interactions, and nudges owned by that turn.
Cancellation is cooperative. A process or external service may already have completed work by the time Mobius records the cancellation, and Mobius cannot roll back those effects. Committed transcript messages remain. Live-only, uncommitted preview text is removed when cancellation wins, so text that was briefly visible while streaming may disappear.
A cancelled turn cannot resume. Retrying invoke with the original
input.idempotency_key returns the same cancelled turn and writes no new input.
To attempt the task again, invoke with a new idempotency key. That new turn has
new tool-call and delivery identities, so an external effect may happen again.
Custom HTTP actions should deduplicate the Idempotency-Key or
X-Mobius-Delivery-Id value for an appropriate retention window. Retries of
one durable action job use the same value.
resume_cursor, after_sequence, and Last-Event-ID resume stream
observation only; they never resume execution.
Send images and files
input.content is an ordered list of content blocks, so one turn can mix text
with images and documents. Add an image or document block alongside the
text block in the same request.
Reference an image by URL:
{
"input": {
"content": [
{ "type": "text", "text": "What's in this screenshot?" },
{
"type": "image",
"source": {
"type": "url",
"url": "https://files.acme.com/tickets/shot.png"
}
}
]
}
}Or inline it as base64 with its media type (image/png, image/jpeg,
image/gif, or image/webp):
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "<base64-bytes>"
}
}For a PDF or other file, use a document block, with a url or base64
source:
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "<base64-bytes>"
}
}The agent sees images and documents only when its resolved model is vision- or document-capable. A block sent to a text-only model is ignored, so match the model to the input.
Warning: Send files as
document, never astype: "file". Mobius does not recognize afileblock, and any message that contains one is dropped before the turn runs, so the agent never sees the message at all. Artifact references are not resolvable inside message content either; to pass a Mobius artifact, put its URL in animageordocumentsource.url.
Select a model when creating the session
The invoked agent's stored configuration supplies its instructions, default model, tools, skills, memory policy, reasoning effort, and timeout. Invocation does not accept a second agent definition.
If your product has a model picker, add model_override to the session spec:
{
"agent_ref": { "id": "agt_scout" },
"session": {
"mode": "continue_or_create",
"session_key": "app:acct_123:user_456:support",
"model_override": "gpt-5.6-sol"
},
"input": {
"content": [{ "type": "text", "text": "Summarize my open tickets." }],
"idempotency_key": "msg_01J8..."
}
}The override is stored only when this call creates the session. Continuing an existing session keeps its original choice; create a new session to switch models. Omit it to inherit the agent's model. It does not change any other agent behavior, and worker-routed agents reject managed model overrides.
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/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.
The SDK transcript watcher stops on idle by default. Set follow: true when
you are building a dashboard, SSE proxy, or other tail that should remain live
for turns started later. Follow mode waits for the configured reconnect delay
and reopens from the last durable cursor; cancel its context or abort signal to
stop it.
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/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/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. - Treat
409 session_turn_activeas an application decision, not a transport retry. Choose nudge or wait-and-reinvoke using the rule above.
Related
- Structured output attaches a JSON Schema to a turn so you read a validated object back instead of parsing prose.
- Agent configuration documents stored behavior and the session model override.
- 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 request scope.