Skip to main content
Skip to main content

Developers Doc

Agent Workspace — Developer Guide

12 mins read 9 Views 3+

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 source src/workspace/. It uses its own REST namespace hvnly/v1, distinct from the admin/settings namespace hvnlynab/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 by WorkspacePage::ensure_page() with its content set to the shortcode. The resolved page ID is stored in the option hvnly_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 var hvnly_ws_route) so client-side deep links never 404.
  • A full-bleed template templates/agent-portal/canvas.php, swapped in via template_include at priority 99.
  • The SPA mount id hvnly-ws-root (WorkspaceConstants::MOUNT_ID), where React’s createRoot() renders <WorkspaceApp/>.

Bootstrap & registration

The module is registered from Core/Bootstrap.php, which stores WorkspaceBootstrap::get_instance() as the service workspaceWorkspaceBootstrap is a singleton whose init() is guarded by a static $initialized flag, so it wires exactly once. Its init() wires, in order:

  1. AgentAdminChrome (first — redirects agents out of wp-admin, hooks login_redirect).
  2. WorkspaceAvailability::register_hooks() — the kill switch across REST and front end.
  3. AgentProvisioner::register_write_guard() — protects the identity link meta.
  4. Core objects: WorkspaceTemplateLoaderWorkspacePageWorkspaceAssetsWorkspaceShortcodeAgentIdentityService (singleton), PortalAuthorization.
  5. Nine REST controllers, each constructed with ($identity, $authorization) and then ->register()ed (see the table below).
  6. Auth/identity/email services: CapabilityRegistrarSessionAuthControllerAgentActivityTrackerRegistrationAdminActionsRegistrationEmailNotifierIdentityVerificationAuditEmailVerificationNotifierEmailChangeReverifierEmailVerificationAdminActionsWorkspaceAccountNotifier.
  7. Property-workflow services: PropertyWorkflowNotifierPropertyListingStatusBridgePropertyWorkflowAdminActionsPropertyPendingReviewCounter.
  8. Admin surfaces plus a one-time email-verification back-fill on admin_init (idempotent, guarded by OPTION_MIGRATED), and WorkspaceSettings::register_hooks(). Asset handles register on init priority 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 (basePathclean), 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, filter hvnly_workspace_me_response (see the extension examples below) — that keeps the boot payload and the live /me response 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.

ControllerBacking service(s)Representative routes (hvnly/v1)
MeController(shares build_identity_payload() with boot)GET /me — identity + permissions + verification
DashboardControllerDashboardServiceGET /dashboard
PropertiesControllerPropertyManagementServicePropertyDraftValidatorPropertyFormMapperPropertyWorkflowServicePropertyPreviewServicePropertyAccessGateGET /properties/schema; CRUD /properties/properties/bulk/properties/{id} + /restore /force /duplicate /preview /submit /publish /status
PropertyMediaControllerPropertyMediaService/properties/{id}/media/media/featured/media/gallery/media/{attachment_id}
AnalyticsControllerAnalyticsServiceGET /analytics
ProfileControllerProfileServiceGET/POST /profile
InquiriesControllerInquiryService/inquiries/inquiries/{id}/inquiries/{id}/reply
SettingsControllerSettingsServiceGET/POST /settings
NotificationsControllerNotificationService/notifications/notifications/read-all/notifications/{id}

Supporting API classes include PropertyAccessGate (resource-scoped ownership), PropertyFormMapperPropertyBuilderSchemaServicePropertyDraftValidatorPropertyListingStatusBridge (the single dispatch path for status writes), PropertyPendingReviewCounter (admin badge), PropertyWorkflowAdminActionsPropertyWorkflowService and PropertyPreviewService.

The Auth layer

Under includes/Workspace/Auth/, six classes carry the identity and access model:

ClassResponsibility
AgentIdentityServiceSSOT resolving the current WP user to their linked Agent CPT (request-cached).
PortalAuthorizationPure boolean access gate; the single choke point compute_can_access_workspace().
PortalCapabilitiesCapability and role constants — the WP agent role slug hvnly_agent and caps such as hvnly_portal_access.
WorkspaceRegistrationStatusLifecycle SSOT (user meta _hvnly_ws_registration_status): pending/approved/active/suspended/blocked/archived.
AgentProvisionerSole authorized writer of the 1:1 link meta _hvnly_agent_linked_user_id.
SessionAuthControllerAll 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 => postmap_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>.

FolderContents
Auth/AuthRouter.js (client router); pages LoginPageRegisterPageForgotPasswordPageResetPasswordPageVerifyEmailPageAuthStatusPage; plus AuthLayoutauthHistory.jsapi.jspasswords.jsverificationBootstrap.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 statuspermissionscanAccessWorkspaceregistrationStatusemailVerifiedrefresh()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.jshistory.jsurlCleanup.jsicons.js.
Layouts/WorkspaceLayoutHeaderSidebarFooterNotificationBell.
Pages/One *Page.js per view, plus per-feature subfolders (each a component + a use* hook).
Services/REST repositories per domain; Security/WorkspaceSecurity.jsValidation/Preview/PreviewService.js.
Forms/Property/Schema-driven builder: BuilderFormRendererBuilderGroupsBuilderField, a repeater kit, media tab, usePropertyFormvalidate.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_enabledhvnly_workspace_should_enqueuehvnly_workspace_localize_datahvnly_workspace_permissionshvnly_workspace_identityhvnly_workspace_me_responsehvnly_identity_factors (the primary seam for registering a VerificationFactorInterface), hvnly_workspace_shortcode_output, and settings filters such as hvnly_workspace_registration_modehvnly_workspace_default_registration_rolehvnly_workspace_clean_routinghvnly_workspace_logout_redirect and hvnly_workspace_agents_can_direct_publish.

Key actions include hvnly_workspace_inithvnly_workspace_page_createdhvnly_workspace_assets_registered/_enqueuedhvnly_workspace_registration_status_changedhvnly_workspace_agent_provisionedhvnly_workspace_user_provisioned_for_agenthvnly_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 VerificationFactorInterface implementation 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 PropertyAccessGate does). The SPA gating is a convenience, not a security boundary — clients can call hvnly/v1 directly. 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_factorshvnly_workspace_me_responsehvnly_workspace_localize_data, and viewRegistry.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 PropertyListingStatusBridge so notifiers and workflow logic fire.
  • Keep the boot payload and /me aligned. Filter hvnly_workspace_me_response rather 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 its refresh() when you genuinely need fresh data.
  • useIdentity makes a single GET /me per mount and caches to hvnly_ws_me_v1; keep anything you add to /me cheap 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 under hvnlynab/v1.
  • Writing the identity-link meta directly. _hvnly_agent_linked_user_id is guarded — go through AgentProvisioner.
  • 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.

Need more help?

Can't find what you're looking for? Our team and community are here to help you ship faster.