The Havenlytics Agent Workspace (internally the “Agent Portal”) is a self-contained front-end single-page application plus a REST subsystem that gives real-estate agents a logged-in dashboard — profile, listings, inquiries, analytics, notifications and account management — entirely separate from wp-admin. This guide documents the module as it ships in Havenlytics 3.3.1 (WorkspaceConstants::MODULE_VERSION) for developers who need to extend, theme, or integrate with it. It is not an end-user manual.
Note: The workspace lives under the PHP namespace
HvnlyNab\Workspace(includes/Workspace/) and the React sourcesrc/workspace/. It uses its own REST namespacehvnly/v1, distinct from the admin/settings namespacehvnlynab/v1. For how access is gated, read the companion Authentication & Authorization guide.
What it is and how it is delivered
The workspace is delivered by several cooperating pieces, so a deep link like /agent-dashboard/property/5/edit resolves to the SPA rather than a 404:
- A dedicated WP page at slug
agent-dashboard(WorkspaceConstants::PAGE_SLUG), auto-created byWorkspacePage::ensure_page()with its content set to the shortcode. The resolved page ID is stored in the optionhvnly_workspace_page_id. - A shortcode
[hvnly_agent_dashboard](WorkspaceConstants::SHORTCODE) that emits the SPA mount node<div id="hvnly-ws-root" class="hvnly-ws-mount">— or a server-rendered “unavailable” page when the workspace is off. - A catch-all rewrite (
WorkspacePage::register_rewrite_rules(), query varhvnly_ws_route) so client-side deep links never 404. - A full-bleed template
templates/agent-portal/canvas.php, swapped in viatemplate_includeat priority 99. - The SPA mount id
hvnly-ws-root(WorkspaceConstants::MOUNT_ID), where React’screateRoot()renders<WorkspaceApp/>.
Bootstrap & registration
The module is registered from Core/Bootstrap.php, which stores WorkspaceBootstrap::get_instance() as the service workspace. WorkspaceBootstrap is a singleton whose init() is guarded by a static $initialized flag, so it wires exactly once. Its init() wires, in order:
AgentAdminChrome(first — redirects agents out ofwp-admin, hookslogin_redirect).WorkspaceAvailability::register_hooks()— the kill switch across REST and front end.AgentProvisioner::register_write_guard()— protects the identity link meta.- Core objects:
WorkspaceTemplateLoader,WorkspacePage,WorkspaceAssets,WorkspaceShortcode,AgentIdentityService(singleton),PortalAuthorization. - Nine REST controllers, each constructed with
($identity, $authorization)and then->register()ed (see the table below). - Auth/identity/email services:
CapabilityRegistrar,SessionAuthController,AgentActivityTracker,RegistrationAdminActions,RegistrationEmailNotifier,IdentityVerificationAudit,EmailVerificationNotifier,EmailChangeReverifier,EmailVerificationAdminActions,WorkspaceAccountNotifier. - Property-workflow services:
PropertyWorkflowNotifier,PropertyListingStatusBridge,PropertyWorkflowAdminActions,PropertyPendingReviewCounter. - Admin surfaces plus a one-time email-verification back-fill on
admin_init(idempotent, guarded byOPTION_MIGRATED), andWorkspaceSettings::register_hooks(). Asset handles register oninitpriority 20.
When wiring completes it fires the hvnly_workspace_init action — the safe moment to register your own workspace extensions.
<?php
// Run setup once the workspace module has fully wired its services.
add_action( 'hvnly_workspace_init', function () {
// AgentIdentityService, PortalAuthorization and the hvnly/v1
// controllers are all available from here on.
} );
Asset boot & the localized data object
WorkspaceAssets enqueues on wp_enqueue_scripts priority 20 (via maybe_enqueue()), honouring the filter hvnly_workspace_should_enqueue (priority 5). Build artifacts come from HVNLYNAB_BUILD_PATH/workspace/ (with workspace.asset.php); the script handle is hvnly-ws-app.
Boot configuration is localized as the JavaScript global HvnlyWorkspaceData (WorkspaceConstants::LOCALIZE_OBJECT) through get_localize_data(). It carries the module version, mount id, an enabled flag ('1'/'0'), the REST namespace/root/nonce//me URL, routing config (basePath, clean), and i18n. Crucially, for a logged-in user on an enabled workspace it also embeds the fully resolved identity payload (from MeController::build_identity_payload()) so the SPA boots synchronously without a first round-trip. When the workspace is disabled, only CSS is enqueued and the SPA script is never registered.
Tip: To inject extra boot data for your SPA extension, filter
hvnly_workspace_localize_data. To add fields to the runtime identity, filterhvnly_workspace_me_response(see the extension examples below) — that keeps the boot payload and the live/meresponse consistent.
The kill switch
The feature-flag SSOT is WorkspaceSettings::is_enabled() (option key hvnly_workspace_enabled within the hvnly_workspace_settings option / contact-agent settings group). A hard override exists in wp-config.php: defining HVNLY_WORKSPACE_ENABLED as false disables the workspace regardless of settings. WorkspaceAvailability::is_available() wraps both.
Backend services
REST controllers live in includes/Workspace/Api/ and are deliberately thin: a permission callback plus delegation to a paired Service. Client-supplied IDs are never trusted — identity is resolved server-side.
| Controller | Backing service(s) | Representative routes (hvnly/v1) |
|---|---|---|
MeController | (shares build_identity_payload() with boot) | GET /me — identity + permissions + verification |
DashboardController | DashboardService | GET /dashboard |
PropertiesController | PropertyManagementService, PropertyDraftValidator, PropertyFormMapper, PropertyWorkflowService, PropertyPreviewService, PropertyAccessGate | GET /properties/schema; CRUD /properties, /properties/bulk, /properties/{id} + /restore /force /duplicate /preview /submit /publish /status |
PropertyMediaController | PropertyMediaService | /properties/{id}/media, /media/featured, /media/gallery, /media/{attachment_id} |
AnalyticsController | AnalyticsService | GET /analytics |
ProfileController | ProfileService | GET/POST /profile |
InquiriesController | InquiryService | /inquiries, /inquiries/{id}, /inquiries/{id}/reply |
SettingsController | SettingsService | GET/POST /settings |
NotificationsController | NotificationService | /notifications, /notifications/read-all, /notifications/{id} |
Supporting API classes include PropertyAccessGate (resource-scoped ownership), PropertyFormMapper, PropertyBuilderSchemaService, PropertyDraftValidator, PropertyListingStatusBridge (the single dispatch path for status writes), PropertyPendingReviewCounter (admin badge), PropertyWorkflowAdminActions, PropertyWorkflowService and PropertyPreviewService.
The Auth layer
Under includes/Workspace/Auth/, six classes carry the identity and access model:
| Class | Responsibility |
|---|---|
AgentIdentityService | SSOT resolving the current WP user to their linked Agent CPT (request-cached). |
PortalAuthorization | Pure boolean access gate; the single choke point compute_can_access_workspace(). |
PortalCapabilities | Capability and role constants — the WP agent role slug hvnly_agent and caps such as hvnly_portal_access. |
WorkspaceRegistrationStatus | Lifecycle SSOT (user meta _hvnly_ws_registration_status): pending/approved/active/suspended/blocked/archived. |
AgentProvisioner | Sole authorized writer of the 1:1 link meta _hvnly_agent_linked_user_id. |
SessionAuthController | All authentication over admin-ajax.php (login, register, verify email, resend, …) with per-action nonces. |
Note: The full two-condition authorization model, the pluggable identity-factor framework, the auth AJAX actions, and the token security are documented in depth in Authentication & Authorization. This guide references them but does not repeat them.
Agent CPT & the identity link
Agents are the hvnly_agent custom post type (AgentConstants::POST_TYPE), registered by AgentPostType::register_custom_post_type() with capability_type => post, map_meta_cap => true, supporting title, editor, thumbnail, excerpt and revisions. The Agent module boots via AgentBootstrap (includes/Agent/AgentBootstrap.php).
Each Agent is linked 1:1 to a WordPress user through the Agent post meta _hvnly_agent_linked_user_id (AgentConstants::META_LINKED_USER_ID), writable only via AgentProvisioner. Resolution uses AgentIdentityService::resolve_linked_agent() (backed by PropertyAgentResolver::get_cpt_agents_for_user()). Registration status is deliberately decoupled from the Agent post’s post_status. Agent profile fields are stored as _hvnly_agent_* meta enumerated by AgentConstants::profile_meta_keys().
See also: Custom Post Types for the full Agent CPT registration.
The React SPA (src/workspace/)
The front end is a webpack-built React application. workspace.js is the entry point: it imports CSS layers in a significant order, polyfills crypto.randomUUID, runs URL-migration helpers (migrateLegacyHashUrl(), captureVerificationTokenFromLocation(), cleanWorkspaceUrl()), refuses to mount when HvnlyWorkspaceData.enabled is falsy, and calls createRoot(#hvnly-ws-root).render(<WorkspaceApp/>). WorkspaceApp.js is the top gate: when disabled it renders AuthStatusPage; otherwise it renders <IdentityProvider><AuthRouter/><ToastHost/></IdentityProvider>.
| Folder | Contents |
|---|---|
Auth/ | AuthRouter.js (client router); pages LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage, VerifyEmailPage, AuthStatusPage; plus AuthLayout, authHistory.js, api.js, passwords.js, verificationBootstrap.js. |
Hooks/ | useIdentity.js — IdentityProvider context. Seeds from the boot payload or the session cache hvnly_ws_me_v1, makes a single GET /me per mount, and exposes status, permissions, canAccessWorkspace, registrationStatus, emailVerified, refresh(), applyAgentAvatar(). |
Navigation/ | viewRegistry.js (registerView(s) / getNavGroups() / subscribe); registerDefaultViews.js (the 7 core views + a dev ui-components view); ViewManager.js (History API + hashchange); plus routing.js, history.js, urlCleanup.js, icons.js. |
Layouts/ | WorkspaceLayout, Header, Sidebar, Footer, NotificationBell. |
Pages/ | One *Page.js per view, plus per-feature subfolders (each a component + a use* hook). |
Services/ | REST repositories per domain; Security/WorkspaceSecurity.js; Validation/; Preview/PreviewService.js. |
Forms/Property/ | Schema-driven builder: BuilderFormRenderer, BuilderGroups, BuilderField, a repeater kit, media tab, usePropertyForm, validate.js. |
Components/ | Design-system primitives and hvnly-ui.css. |
There are two routers by design: AuthRouter chooses between the auth screens and the app shell, while ViewManager handles in-shell navigation between registered views.
Extension points
The workspace is built to be extended without touching plugin files — from PHP, from JavaScript, and from your theme.
PHP filters & actions
Key filters include hvnly_workspace_enabled, hvnly_workspace_should_enqueue, hvnly_workspace_localize_data, hvnly_workspace_permissions, hvnly_workspace_identity, hvnly_workspace_me_response, hvnly_identity_factors (the primary seam for registering a VerificationFactorInterface), hvnly_workspace_shortcode_output, and settings filters such as hvnly_workspace_registration_mode, hvnly_workspace_default_registration_role, hvnly_workspace_clean_routing, hvnly_workspace_logout_redirect and hvnly_workspace_agents_can_direct_publish.
Key actions include hvnly_workspace_init, hvnly_workspace_page_created, hvnly_workspace_assets_registered/_enqueued, hvnly_workspace_registration_status_changed, hvnly_workspace_agent_provisioned, hvnly_workspace_user_provisioned_for_agent, hvnly_workspace_identity_link_set and hvnly_workspace_agent_activity.
Register a verification factor (PHP)
Add an identity requirement — phone, 2FA, KYC — by implementing VerificationFactorInterface and registering it on hvnly_identity_factors. The authorization gate composes it automatically:
<?php
add_filter( 'hvnly_identity_factors', function ( array $factors ) {
// $my_factor implements HvnlyNab\Workspace\Identity\VerificationFactorInterface
$factors[ $my_factor->slug() ] = $my_factor;
return $factors;
} );
Note: A complete
VerificationFactorInterfaceimplementation is in the Authentication & Authorization guide, along with the safety rules for making a factor required.
Add data to /me (PHP)
The identity payload is the SPA’s source of truth. Because it is embedded at boot and served by GET /me, filtering hvnly_workspace_me_response keeps both in sync:
<?php
add_filter( 'hvnly_workspace_me_response', function ( array $payload, $user_id ) {
$payload['myIntegration'] = array(
'crmLinked' => (bool) get_user_meta( $user_id, '_my_crm_linked', true ),
);
return $payload;
}, 10, 2 );
Add a navigation view (JavaScript)
Register a new view with the SPA’s viewRegistry to add both a sidebar entry and its page. Do this before the shell reads the registry (the default views register at startup):
import { registerView } from './Navigation/viewRegistry';
import MyReportsPage from './Pages/MyReportsPage';
registerView( {
id: 'my-reports',
label: 'Reports',
group: 'main',
icon: 'chart',
component: MyReportsPage,
} );
Add a REST endpoint (controller → service)
New endpoints follow the same thin-controller pattern: a controller under hvnly/v1 whose permission callback re-checks enabled + logged in + can access, delegating to a service. Expose the route’s URL to the SPA by filtering hvnly_workspace_localize_data so the front end can discover it without hard-coding paths.
Security: Every controller must re-verify authorization in its own permission callback and gate per-object ownership (as
PropertyAccessGatedoes). The SPA gating is a convenience, not a security boundary — clients can callhvnly/v1directly. See the REST API and Security guides.
Template overrides (theme)
Templates in templates/agent-portal/*.php are overridable from your theme at your-theme/havenlytics/agent-portal/*.php (WorkspaceTemplateLoader uses TEMPLATE_SUBDIR = 'agent-portal'). This is how you re-skin the full-bleed canvas.php shell or the unavailable page without editing the plugin.
See also: Template Overrides for the resolution order and the general override folder
havenlytics/.
Best practices
- Extend, don’t fork. Reach for
hvnly_identity_factors,hvnly_workspace_me_response,hvnly_workspace_localize_data, andviewRegistry.registerView()before considering any core edit. - Resolve identity server-side. Trust
AgentIdentityService, never client-supplied user/agent IDs. - One dispatch path for status. Route property-status writes through
PropertyListingStatusBridgeso notifiers and workflow logic fire. - Keep the boot payload and
/mealigned. Filterhvnly_workspace_me_responserather than injecting divergent data at boot.
Performance recommendations
- The boot identity payload lets the SPA render without a first REST round-trip — do not add a second identity fetch; use
useIdentity()‘s cached context and itsrefresh()when you genuinely need fresh data. useIdentitymakes a singleGET /meper mount and caches tohvnly_ws_me_v1; keep anything you add to/mecheap to compute.- When the workspace is disabled, the SPA script is never enqueued — respect the kill switch in your own asset logic (hook
hvnly_workspace_should_enqueue).
Common mistakes
- Using the wrong REST namespace. The workspace is
hvnly/v1; admin/settings/builders live underhvnlynab/v1. - Writing the identity-link meta directly.
_hvnly_agent_linked_user_idis guarded — go throughAgentProvisioner. - Assuming Agent post status equals access. Registration status is separate; read it via
WorkspaceRegistrationStatus. - Skipping the permission callback in a new controller. Each controller re-checks authorization independently.