DocsGuides

Build a custom interface with Mobius

Put your own product experience around a configured Mobius assistant, with stable conversations and resumable streaming.

A custom interface lets your product own the user experience while Mobius runs an existing assistant and records its conversations.

Use this path when you need your own navigation, authentication, or workflow. If the hosted app already fits and only the visible identity should change, configure the hosted brand instead.

Divide the work clearly

Mobius stores the assistant definition, resolves its model and account access, runs each turn, and exposes a resumable event stream. Your application decides who may enter the interface, maps that person to the correct Mobius organization and assistant, and prevents one customer from requesting another customer's session.

A session_key helps your code find the same conversation again. It is not an authorization boundary. Keep the account, user, and conversation identifiers in the key, and still authorize every request before you invoke or read the session.

Prepare credentials and an assistant

Use API keys issued to software principals. The deployment principal needs mobius.org.admin for this example because it creates a team-owned, organization-visible assistant. That permission includes mobius.resource.build, mobius.resource.use, and mobius.resource.view.

Give the runtime principal mobius.resource.use and make sure it can reach the assistant's audience. Use already includes mobius.resource.view; a process with view alone can read reachable resources but cannot invoke the assistant. A human caller can use mobius.resource.build for eligible personal configuration, but a software principal cannot be the human custodian that private configuration requires.

Create the assistant once and keep its ID or an assign-once external_ref. Set the audience explicitly. This example prepares one team-owned assistant for the organization:

const agentResponse = await fetch(`${process.env.MOBIUS_BASE_URL}/v1/agents`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.MOBIUS_DEPLOY_API_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    name: "Support",
    external_ref: "primary-support-agent",
    if_exists: "adopt",
    owner: { kind: "team" },
    visibility: "organization",
    model: "gpt-6-luna",
    system_prompt:
      "Summarize the supplied support facts. Cite ticket numbers and do not invent status changes.",
    timeout_seconds: 120,
  }),
});
 
const agent = await agentResponse.json();

Reusing external_ref with if_exists: "adopt" returns the live assistant unchanged. It does not apply new mutable fields. Use the update endpoint when a deployment intentionally changes the saved definition.

Invoke one conversation

Persist the inbound message and its idempotency key before you call Mobius. Then invoke the assistant with a stable conversation key:

const response = await fetch(
  `${process.env.MOBIUS_BASE_URL}/v1/agents/invoke`,
  {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.MOBIUS_RUNTIME_API_KEY}`,
      "content-type": "application/json",
      accept: "text/event-stream",
    },
    body: JSON.stringify({
      agent_ref: { id: agent.id },
      session: {
        mode: "continue_or_create",
        session_key: "app:acct_123:user_456:support_789",
        title: "Support chat",
      },
      input: {
        content: [{ type: "text", text: "Summarize my open tickets." }],
        idempotency_key: "message_01",
      },
    }),
  },
);

Use a different session_key for each end-user conversation. Use a different input.idempotency_key for each logical message, then reuse that same key when retrying the message. A retry with the original key resolves to the existing turn instead of adding the input again.

Stream and recover

An inline text/event-stream response is convenient for a product chat. If the connection drops, reconnect with the returned cursor or Last-Event-ID. The stream reads the saved record, so reconnecting does not restart the turn.

Treat turn.completed, turn.failed, and turn.cancelled as final for that turn. A 409 session_turn_active means the same conversation already has a direct turn that is queued, running, or waiting. Wait, or use the documented nudge path when the new message is meant to steer active work. Do not change the idempotency key merely to get around the conflict.

Offer a model choice carefully

If your interface includes a model picker, send model_override only when creating the session:

{
  "session": {
    "mode": "continue_or_create",
    "session_key": "app:acct_123:user_456:support_789",
    "model_override": "gpt-6-luna"
  }
}

The model must be available to that assistant. The override stays with the conversation; resolving an existing session does not replace it. Omit the field to inherit the assistant's current saved model, or create a new session when the person deliberately switches. Worker-routed agents reject managed model overrides.

Check the boundary before launch

Test two users from separate application accounts. Confirm that each stable key returns the intended conversation, a repeated message key does not duplicate a turn, and neither user can read the other's transcript through your backend. Then interrupt one stream and prove that the interface resumes from the saved cursor.

Mobius authorization still applies to the credential your backend presents. Your application's end-user login and account checks remain your responsibility. Do not treat metadata, a user ID inside session_key, or a branded hostname as proof of identity.

Read agent invocation for the full request, nudge, cancellation, and stream contracts. Agent configuration covers updates and model behavior, while provisioning covers stable external identities.