Per-thread "fake users" (participant personas)
To be able to implement this guide, you need to learn how to insert PHP snippets to your website.
You can find guide here: WP Beginner
Sometimes the WordPress user who actually receives a chat is not the identity the other side should see. Examples:
- A buyer messages an "Agent CPT" that isn't linked to a real WP user — the chat is routed to a generic fallback account, but the buyer should still see the agent's name and profile photo.
- A WooCommerce vendor's store-front persona should be shown to customers instead of the WP user behind the store.
- A single fallback account routes inquiries for several different agent CPTs at once — each thread needs to show its own persona, all in the same conversation list.
Better Messages exposes a per-thread participant override map for exactly this case. Same WP user, different identity in different threads — at the same time.
What it looks like
The buyer's view of two unrelated threads that are both routed to the same underlying WordPress account. Each thread shows its own persona — name, profile photo and clickable profile URL — both in the sidebar and in the message sender chips. The Houzez integration is used here as an example, but the pattern is reusable from any addon.

The agent side (the WordPress user the chats are actually routed to) sees the buyer's real identity in the conversation header, sees their own WordPress profile in their bottom-left widget (no leak into self-view), and gets a yellow "Chatting as Mike Moore" banner so they know which persona they are representing for this conversation:

How it works
Add an optional participantOverrides map to the thread payload via the better_messages_rest_thread_item filter:
$thread_item['participantOverrides'] = array(
(string) $user_id => array(
'name' => 'Display Name', // optional
'avatar' => 'https://…/photo.png', // optional
'url' => 'https://…/profile/', // optional
),
);
Keys are stringified user IDs (positive WP IDs and negative guest IDs both work). All fields are optional — omit any field and the participant's real record fills the gap.
The map travels with the thread payload across every transport — REST, AJAX polling and WebSocket relays all build their payloads through the same filter, so the override flows everywhere automatically.
On the frontend, every surface that renders a participant inside a thread (conversation header, sidebar item, message sender chip, reactions modal, mini-chat-head widget, call screen, info panel, toasts, etc.) merges the override on top of the real user record at render time. Surfaces that step outside the thread context — the current-user widget, mention picker, user search, "Add participant" modal, user profile popup — deliberately keep using the real record, so the override never leaks into self-view or non-thread contexts.
Recipe
<?php
add_filter( 'better_messages_rest_thread_item', 'my_addon_persona_overrides', 10, 5 );
function my_addon_persona_overrides( $thread_item, $thread_id, $thread_type, $include_personal, $user_id ) {
if ( $thread_type !== 'thread' ) {
return $thread_item;
}
// 1) Decide which participant in this thread should wear a persona.
// Resolve it from your own data (CPT meta, vendor settings, etc.).
$persona = my_addon_resolve_persona_for_thread( $thread_id );
if ( ! $persona ) {
return $thread_item;
}
// 2) Only override participants who are actually in this thread.
$participants = isset( $thread_item['participants'] )
? array_map( 'intval', $thread_item['participants'] )
: array();
if ( ! in_array( $persona['user_id'], $participants, true ) ) {
return $thread_item;
}
// 3) Emit the override.
if ( ! isset( $thread_item['participantOverrides'] ) || ! is_array( $thread_item['participantOverrides'] ) ) {
$thread_item['participantOverrides'] = array();
}
$thread_item['participantOverrides'][ (string) $persona['user_id'] ] = array(
'name' => $persona['name'],
'avatar' => $persona['avatar'],
'url' => $persona['url'],
);
return $thread_item;
}
Three principles
1. Snapshot the persona on first resolve
Resolve once from your data sources, then cache the result in thread meta so subsequent renders are stable even if the upstream data drifts later:
$cached = Better_Messages()->functions->get_thread_meta( $thread_id, 'my_persona_user_id' );
if ( $cached ) {
// use cached values
} else {
// resolve fresh
Better_Messages()->functions->update_thread_meta( $thread_id, 'my_persona_user_id', $resolved_user_id );
// …same for any other persona fields you need (CPT post id, type, etc.)
}
This protects your buyers and agents from a confusing experience if a CPT title gets edited, a vendor changes their store name, or the linked WP account changes after the conversation already started.
2. Only override participants who are in the thread
The participants array on the thread item is your source of truth. Don't add participantOverrides entries for user IDs that aren't part of the thread — they would never be rendered anyway, but you'd be putting noise into every payload (and in WebSocket pushes that fan out to many recipients).
3. Don't double-override
If you also want the persona to apply to that user in non-thread contexts (user search, mention picker, "@" autocomplete) — meaning the user always appears as the persona, everywhere — additionally hook better_messages_rest_user_item and rewrite name / avatar / url there. The per-thread override and the global one compose cleanly.
Do not implement ad-hoc "global override based on last thread we saw" tricks. That's exactly the kind of fragile global-state pattern the per-thread mechanism replaces, and it breaks the moment the same user appears in two threads with different personas.
Optional: a "Chatting as X" banner
If a thread is routed to a generic fallback account, you'll want to tell the receiver that the other side sees a different identity. Append HTML to $thread_item['threadInfo'] only when the viewer is on the persona side and is not the persona's primary linked user:
if ( $viewer_is_on_persona_side && $viewer_id !== $persona_primary_user_id ) {
$banner = '<div class="my-addon-persona-banner">'
. '<img src="' . esc_url( $persona['avatar'] ) . '" alt="" /> '
. sprintf( esc_html__( 'Chatting as %s', 'my-addon' ), '<strong>' . esc_html( $persona['name'] ) . '</strong>' )
. '</div>';
$thread_item['threadInfo'] = ( $thread_item['threadInfo'] ?? '' ) . $banner;
}
threadInfo is rendered above the conversation pane and is per-viewer — the filter receives the viewer's $user_id, so a banner you set there is correctly scoped to the receiver who needs it.
Reference implementations
Two integrations inside the plugin use this exact pattern and are the reference implementations:
addons/houzez.php— Houzez agent CPTs, including the case where the agent CPT has no linked WordPress user and the chat is silently routed to the property's post author.addons/realhomes.php— RealHomes agent and agency CPTs, same routing behavior, plus the agency case (an agency CPT routed to its post author, with the agency name and logo shown to the buyer).
Both share the same method names so the pattern is easy to copy:
thread_item()— the filter callbackresolve_thread_persona()— snapshots persona post id and type to thread metaresolve_thread_routed_user()— snapshots the actual routed user idapply_realtor_to_user_item()(andresolve_realtor_avatar_url()in Houzez) — build the override valuespersona_banner_html()— the "Chatting as X" banner
Testing checklist
When adding a persona to a new integration:
- Open a thread that should have the persona — header, sidebar item and message sender chip all show the persona name, avatar, and clickable profile URL.
- The routed receiver opens their own dashboard — their bottom-left profile widget shows their real WordPress identity (the override does not leak into self-view).
- Open a second thread routed to the same WP user but with a different persona — both threads in the sidebar must show their own persona side-by-side. No "last-write-wins" effects.
- Send a message from each side over WebSocket — the inbound payload carries
thread.participantOverridesand the receiving UI updates without a refresh. - Trash the persona's source post (CPT, vendor, etc.) — the override is dropped silently and the thread falls back to the routed user's real identity.
- Reload the page — the override survives because it's snapshotted in thread meta and re-emitted on every fetch.
Related
better_messages_rest_thread_item— the filter where you write the override.better_messages_rest_user_item— the global per-user filter; use it in addition toparticipantOverrideswhen you want a persona to apply everywhere, not just inside one thread.