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.

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 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%3Asupport

Use 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=context

Those 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 running or waiting, call nudgeSession to steer the active turn at its next iteration boundary. Set wake: true if 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 returned delivery; it will be new_turn if 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 as type: "file". Mobius does not recognize a file block, 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 an image or document source.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_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.
  • Treat 409 session_turn_active as an application decision, not a transport retry. Choose nudge or wait-and-reinvoke using the rule above.