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.