Skip to content

Runtime integration

Chat ships three runtime modes, declared at chat/src/runtime/resolve-chat-config.ts:9:

ts
type TChatRuntimeMode = 'standalone-local' | 'standalone-connected' | 'daemon-hosted';

Each mode picks a different set of service implementations and a different transport.

standalone-local

Self-contained. No network, no NATS, no other actor. Used in tests, demos, and the docs sandbox.

create-standalone-local-chat.ts:

ts
const config     = await resolveConfiguredChatConfig({ ...overrides, mode: 'standalone-local' });
const stateStore = new LocalChatStateStore();
const runtime    = createChatRuntime({ config, services, logging: false });

const boundServices = {
  conversation:     services.conversation     ?? new LocalConversationService(runtime.getRootContext(), stateStore),
  componentLibrary: services.componentLibrary ?? new LocalComponentLibraryService(),
  identity:         services.identity         ?? new LocalIdentityService({ authenticated: true, principalId: 'local-user', ... }),
  preferences:      services.preferences      ?? new LocalPreferencesService(),
  sessionState:     services.sessionState     ?? new LocalSessionStateService(stateStore),
  mcp:              services.mcp              ?? new NullMcpStatusService(),
};

bindChatServices(runtime, config, boundServices);
const app = await runtime.createElement<HTMLElement>('chat-app', { mount: config.mount, name: config.app.appName });
app.setAttribute('runtime-mode', config.mode);
await runtime.mount(app);

The Local* implementations live in chat/src/app/services/impl/. They keep state in a single LocalChatStateStore (in-memory + optional IndexedDB).

standalone-connected

Browser-side, but with a caller-supplied ITransportAdapter (typically NATS-WS to a remote bus). Useful when embedding Chat into another app's runtime that already owns a transport.

create-standalone-connected-chat.ts:

ts
const config  = await resolveConfiguredChatConfig({ ...overrides, mode: 'standalone-connected' });
const runtime = createChatRuntime({ config, services, transport, logging: false });
const app     = await runtime.createElement('chat-app', { mount: config.mount, name: config.app.appName });
app.setAttribute('runtime-mode', config.mode);
await runtime.mount(app);

The caller is responsible for binding services. Typically the caller supplies RemoteActorConversationAdapter, RemoteActorComponentLibraryAdapter, etc., pointed at appropriate bus mounts.

daemon-hosted

The default mode for /apps/chat/ on a real Host. The runner (Host Service) provides the MatrixRuntime and Chat just adds its actors.

create-host-managed-chat.ts:

ts
const runtime = services.runtime;
if (!runtime) throw new Error('createHostManagedChat requires a runner-provided MatrixRuntime service.');

const config = await resolveConfiguredChatConfig({
  ...overrides,
  mode: 'daemon-hosted',
  ...(bootstrapContext.mount ? { mount: bootstrapContext.mount } : {}),
  ...(bootstrapContext.root ? {
    transport: { ...overrides.transport, root: bootstrapContext.root, addressRoot: bootstrapContext.root, daemonRoot: bootstrapContext.root },
  } : {}),
});

const boundServices = {
  componentLibrary: services.componentLibrary ?? new LocalComponentLibraryService(),
  identity:         services.identity         ?? new LocalIdentityService({ authenticated: true, ... }),
  preferences:      services.preferences      ?? new LocalPreferencesService(),
  mcp:              services.mcp              ?? new NullMcpStatusService(),
  // conversation and sessionState come from the runner if provided
};
bindChatServices(runtime, config, boundServices);

const mounted = await mountHostManagedChatActors(runtime, mount);
return { runtime, config, mounts: mounted };

Note: in daemon-hosted mode, LocalConversationService is not bound by default. The runner is expected to provide a RemoteActorConversationAdapter that points at system.agents. The browser side then receives bound config via bindChatServices.

mountHostManagedChatActors mounts:

chat                       (ChatRootActor)
chat.security-realm        (ChatSecurityRealmService)
chat.topic-claims          (ChatTopicClaimService)
chat.identity              (ChatIdentityProxy)
chat.conversation          (ChatConversationProxy)
chat.component-library     (ChatComponentLibraryProxy)
chat.preferences           (ChatPreferencesProxy)
chat.session-state         (ChatSessionStateProxy)
chat.mcp                   (ChatMcpProxy)
chat.export                (ChatExportService)

Each is mounted via runtime.createSupervised(ActorClass, mount) so the supervisor restarts them if they crash.

Mode resolution at boot

resolve-chat-config.ts:110-115:

ts
export function inferChatMode(overrides?: DeepPartial<IChatRuntimeConfig>): TChatRuntimeMode {
  if (overrides?.mode) return overrides.mode;
  throw new Error(
    '[MatrixChat] Runtime mode must be explicit. Pass mode directly or bootstrap chat through an explicit standalone/hosted entrypoint.',
  );
}

The mode is never inferred from the environment. Either you pass mode in the overrides or you call one of the explicit create* entrypoints. There is no auto-detection that switches between local and hosted at runtime.

Service binding

bind-chat-services.ts exposes:

  • bindChatServices(runtime, config, overrides) — registers the config and overrides on the runtime so the browser-side proxies can find them.
  • getBoundChatConfig(context) — used by ChatApp at boot.
  • getBoundChatServiceOverrides(context) — same, for service overrides.
  • resolveChatServiceTarget(context, serviceRef) — turn an IChatServiceRef into an actual mount.

The browser side reads these from runtime.getService(CHAT_CONFIG_SERVICE_KEY) and friends. If unbound, ChatApp throws on first access — there is no silent default.

See also

Source: projects/matrix-3/packages/chat/src/runtime/ directory.