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.

ModeHow to request itUse when
Inline streamSend Accept: text/event-stream on POST /agents/invoke.Your HTTP request is already a chat stream or another long-lived server response.
Acknowledge, then streamUse 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_key from 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: as after_sequence or Last-Event-ID.
  • Treat turn.completed, turn.failed, and turn.cancelled as terminal for the matching turn_id.