Skip to main content

How to Add a New Agent

This step-by-step tutorial will guide you through creating a Listing Agent from scratch.

Overview

By the end of this tutorial, you'll have:

  • ✅ A fully functional Listing Agent
  • ✅ Agent registered in the system
  • ✅ Custom state machine for property listings
  • ✅ Message templates for listing queries
  • ✅ Integration with Prophunt webserver

Time: ~30 minutes
Difficulty: Intermediate

Step 1: Create Agent Folder Structure

cd modules/agents
mkdir listing
cd listing

Create these files:

touch index.ts                    # Main agent implementation
touch agent.config.ts # Agent metadata & config
touch agent.state-machine.ts # State flow logic
touch agent.message-builder.ts # Response templates
touch agent.service.ts # Webserver API client
touch listing.module.ts # NestJS module

Step 2: Define Agent Configuration

agent.config.ts:

import { AgentConfig } from '../../agent-orchestrator/types';

export const LISTING_AGENT_CONFIG: AgentConfig = {
id: 'listing',
name: 'Property Listing',
description: 'Helps users search and manage property listings',
capabilities: [
'search_properties',
'filter_by_location',
'filter_by_price',
'schedule_viewing',
'save_favorite'
],
triggerKeywords: [
'listing',
'property',
'apartment',
'house',
'flat',
'real estate',
'buy',
'rent'
],
channelPrefix: 'prophunt_listing'
};

Step 3: Create State Machine

agent.state-machine.ts:

import { ConversationContext } from '../../agent-orchestrator/types';

export enum ListingState {
INIT = 'INIT',
COLLECTING_PREFERENCES = 'COLLECTING_PREFERENCES',
SHOWING_RESULTS = 'SHOWING_RESULTS',
SHOWING_DETAILS = 'SHOWING_DETAILS',
SCHEDULING_VIEWING = 'SCHEDULING_VIEWING',
VIEWING_SCHEDULED = 'VIEWING_SCHEDULED',
COMPLETED = 'COMPLETED',
ERROR = 'ERROR'
}

export class ListingStateMachine {
getCurrentState(context: ConversationContext): ListingState {
return (context.state as ListingState) || ListingState.INIT;
}

isValidState(state: string): boolean {
return Object.values(ListingState).includes(state as ListingState);
}

transition(current: ListingState, next: ListingState): boolean {
const validTransitions: Record<ListingState, ListingState[]> = {
[ListingState.INIT]: [ListingState.COLLECTING_PREFERENCES],
[ListingState.COLLECTING_PREFERENCES]: [ListingState.SHOWING_RESULTS],
[ListingState.SHOWING_RESULTS]: [
ListingState.SHOWING_DETAILS,
ListingState.COLLECTING_PREFERENCES // Refine search
],
[ListingState.SHOWING_DETAILS]: [
ListingState.SCHEDULING_VIEWING,
ListingState.SHOWING_RESULTS // Back to list
],
[ListingState.SCHEDULING_VIEWING]: [ListingState.VIEWING_SCHEDULED],
[ListingState.VIEWING_SCHEDULED]: [ListingState.COMPLETED],
[ListingState.COMPLETED]: [],
[ListingState.ERROR]: [ListingState.INIT]
};

return validTransitions[current]?.includes(next) || false;
}
}

Step 4: Build Message Templates

agent.message-builder.ts:

import { Injectable } from '@nestjs/common';
import {
MessageEnvelope,
MessageType,
ButtonType,
QuickReply
} from '../../agent-orchestrator/types';

@Injectable()
export class ListingMessageBuilder {
buildWelcomeMessage(): MessageEnvelope {
return {
type: MessageType.QUICK_REPLIES,
text: '🏠 Welcome to Property Listings! What are you looking for?',
quick_replies: [
{ title: 'Buy', payload: 'SEARCH_BUY' },
{ title: 'Rent', payload: 'SEARCH_RENT' },
{ title: 'Sell', payload: 'SELL_PROPERTY' }
]
};
}

buildPreferenceQuestions(): MessageEnvelope[] {
return [
{
type: MessageType.QUICK_REPLIES,
text: 'Which city are you interested in?',
quick_replies: [
{ title: 'Mumbai', payload: 'CITY_MUMBAI' },
{ title: 'Bangalore', payload: 'CITY_BANGALORE' },
{ title: 'Delhi', payload: 'CITY_DELHI' },
{ title: 'Other', payload: 'CITY_OTHER' }
]
},
{
type: MessageType.TEXT,
text: 'What is your budget range? (e.g., "50L-1Cr" or "20k-30k per month")'
}
];
}

buildSearchResults(properties: any[]): MessageEnvelope {
return {
type: MessageType.BUTTONS,
text: `Found ${properties.length} properties matching your criteria:

${properties.map((p, i) => `${i + 1}. ${p.title} - ${p.location} - ₹${p.price}`).join('\n')}`,
buttons: properties.slice(0, 3).map(p => ({
type: ButtonType.POSTBACK,
title: `View ${p.title.substring(0, 15)}...`,
payload: `VIEW_PROPERTY_${p.id}`
}))
};
}

buildPropertyDetails(property: any): MessageEnvelope {
return {
type: MessageType.BUTTONS,
text: `🏠 ${property.title}

📍 Location: ${property.location}
💰 Price: ₹${property.price}
🛏️ Bedrooms: ${property.bedrooms}
🚿 Bathrooms: ${property.bathrooms}
📐 Area: ${property.area} sq.ft.

${property.description}`,
buttons: [
{
type: ButtonType.POSTBACK,
title: 'Schedule Viewing',
payload: `SCHEDULE_${property.id}`
},
{
type: ButtonType.WEB_URL,
title: 'View on Map',
url: property.mapUrl
},
{
type: ButtonType.POSTBACK,
title: 'Back to Results',
payload: 'BACK_TO_RESULTS'
}
]
};
}

buildViewingScheduled(date: string, time: string): MessageEnvelope {
return {
type: MessageType.TEXT,
text: `✅ Viewing scheduled for ${date} at ${time}. You'll receive a confirmation email shortly.`
};
}

buildErrorMessage(error: string): MessageEnvelope {
return {
type: MessageType.TEXT,
text: `${error}`
};
}
}

Step 5: Create Service Layer

agent.service.ts:

import { Injectable } from '@nestjs/common';
import axios from 'axios';

@Injectable()
export class ListingService {
private readonly apiUrl =
process.env.PROPHUNT_API_URL || 'http://localhost:3000/api';

async searchProperties(filters: {
city: string;
minPrice: number;
maxPrice: number;
type: 'buy' | 'rent';
}): Promise<any[]> {
const response = await axios.post(
`${this.apiUrl}/listings/search`,
filters
);
return response.data.properties;
}

async getPropertyDetails(propertyId: string): Promise<any> {
const response = await axios.get(
`${this.apiUrl}/listings/${propertyId}`
);
return response.data;
}

async scheduleViewing(propertyId: string, userId: string, datetime: string): Promise<any> {
const response = await axios.post(
`${this.apiUrl}/listings/schedule-viewing`,
{ propertyId, userId, datetime }
);
return response.data;
}

async saveFavorite(propertyId: string, userId: string): Promise<void> {
await axios.post(`${this.apiUrl}/listings/favorites`, {
propertyId,
userId
});
}
}

Step 6: Implement Main Agent

index.ts:

import { Injectable } from '@nestjs/common';
import {
Agent,
AgentEvent,
ChannelMessage,
ConversationContext,
MessageEnvelope
} from '../../agent-orchestrator/types';
import { LISTING_AGENT_CONFIG } from './agent.config';
import { ListingStateMachine, ListingState } from './agent.state-machine';
import { ListingService } from './agent.service';
import { ListingMessageBuilder } from './agent.message-builder';

@Injectable()
export class ListingAgent implements Agent {
config = LISTING_AGENT_CONFIG;
private stateMachine = new ListingStateMachine();

constructor(
private readonly service: ListingService,
private readonly messageBuilder: ListingMessageBuilder
) {}

async processMessage(
message: ChannelMessage,
context: ConversationContext
): Promise<MessageEnvelope[]> {
const currentState = this.stateMachine.getCurrentState(context);
const text = message.text?.toLowerCase() || '';
const payload = message.payload as string | undefined;

// Handle commands
if (text.includes('help')) {
return [this.buildHelpMessage()];
}

// State-based processing
switch (currentState) {
case ListingState.INIT:
return this.handleInit();

case ListingState.COLLECTING_PREFERENCES:
return this.handlePreferenceCollection(message, context);

case ListingState.SHOWING_RESULTS:
return this.handleResultsView(payload, context);

case ListingState.SHOWING_DETAILS:
return this.handleDetailsView(payload, context);

case ListingState.SCHEDULING_VIEWING:
return this.handleScheduling(message, context);

default:
return [this.messageBuilder.buildWelcomeMessage()];
}
}

private async handleInit(): Promise<MessageEnvelope[]> {
return [
this.messageBuilder.buildWelcomeMessage(),
...this.messageBuilder.buildPreferenceQuestions()
];
}

private async handlePreferenceCollection(
message: ChannelMessage,
context: ConversationContext
): Promise<MessageEnvelope[]> {
// Parse user preferences
const city = this.extractCity(message.text || '');
const budget = this.extractBudget(message.text || '');

if (!city || !budget) {
return [{
type: 'text' as any,
text: 'Please provide both city and budget.'
}];
}

// Search properties
const properties = await this.service.searchProperties({
city,
minPrice: budget.min,
maxPrice: budget.max,
type: context.vars?.listingType || 'rent'
});

// Store results in context
context.vars = {
...context.vars,
searchResults: properties
};

return [this.messageBuilder.buildSearchResults(properties)];
}

private async handleResultsView(
payload: string | undefined,
context: ConversationContext
): Promise<MessageEnvelope[]> {
if (!payload || !payload.startsWith('VIEW_PROPERTY_')) {
return [this.messageBuilder.buildWelcomeMessage()];
}

const propertyId = payload.replace('VIEW_PROPERTY_', '');
const property = await this.service.getPropertyDetails(propertyId);

return [this.messageBuilder.buildPropertyDetails(property)];
}

private async handleDetailsView(
payload: string | undefined,
context: ConversationContext
): Promise<MessageEnvelope[]> {
if (payload === 'BACK_TO_RESULTS') {
const results = context.vars?.searchResults || [];
return [this.messageBuilder.buildSearchResults(results)];
}

if (payload?.startsWith('SCHEDULE_')) {
return [{
type: 'text' as any,
text: 'Please provide your preferred date and time (e.g., "Tomorrow 2PM")'
}];
}

return [this.messageBuilder.buildWelcomeMessage()];
}

private async handleScheduling(
message: ChannelMessage,
context: ConversationContext
): Promise<MessageEnvelope[]> {
const datetime = message.text || '';
const propertyId = context.vars?.selectedProperty;

await this.service.scheduleViewing(
propertyId,
message.user_id,
datetime
);

return [this.messageBuilder.buildViewingScheduled(datetime, datetime)];
}

async handleEvent(
event: AgentEvent,
context: ConversationContext
): Promise<void> {
// Handle events if needed
}

getState(context: ConversationContext): string {
return this.stateMachine.getCurrentState(context);
}

async transition(
from: string,
to: string,
context: ConversationContext
): Promise<ConversationContext> {
return { ...context, state: to };
}

canHandle(message: ChannelMessage): boolean {
const text = message.text?.toLowerCase() || '';
return this.config.triggerKeywords.some(keyword =>
text.includes(keyword.toLowerCase())
);
}

private extractCity(text: string): string | null {
// Simple extraction - improve with NLP
const cities = ['mumbai', 'bangalore', 'delhi', 'pune'];
return cities.find(city => text.includes(city)) || null;
}

private extractBudget(text: string): { min: number; max: number } | null {
// Simple regex - improve as needed
const match = text.match(/(\d+).*?(\d+)/);
if (match) {
return { min: parseInt(match[1]), max: parseInt(match[2]) };
}
return null;
}

private buildHelpMessage(): MessageEnvelope {
return {
type: 'text' as any,
text: `🏠 Listing Agent Help:

• Search properties by location and budget
• View property details
• Schedule viewings
• Save favorites

Type "search" to start a new search!`
};
}
}

Step 7: Create NestJS Module

listing.module.ts:

import { Module } from '@nestjs/common';
import { ListingAgent } from './index';
import { ListingService } from './agent.service';
import { ListingMessageBuilder } from './agent.message-builder';

@Module({
providers: [
ListingAgent,
ListingService,
ListingMessageBuilder
],
exports: [ListingAgent]
})
export class ListingModule {}

Step 8: Register Agent

Update modules/agents/agents.module.ts:

import { Module } from '@nestjs/common';
import { AgentOrchestratorModule } from '../agent-orchestrator/agent-orchestrator.module';
import { AgentRegistry } from '../agent-orchestrator/agent-registry';

// Import existing agents
import { BGVAgent } from './bgv';
import { LoanAgent } from './loan';

// Import NEW agent
import { ListingAgent } from './listing';
import { ListingModule } from './listing/listing.module';

@Module({
imports: [
AgentOrchestratorModule,
ListingModule // Add module
],
providers: [
BGVAgent,
LoanAgent,
ListingAgent // Add provider
]
})
export class AgentsModule {
constructor(
private readonly agentRegistry: AgentRegistry,
private readonly bgvAgent: BGVAgent,
private readonly loanAgent: LoanAgent,
private readonly listingAgent: ListingAgent // Inject
) {
// Register all agents
this.agentRegistry.register(this.bgvAgent);
this.agentRegistry.register(this.loanAgent);
this.agentRegistry.register(this.listingAgent); // Register!
}
}

Step 9: Test Your Agent

Manual Testing

  1. Start Docker:

    npm run dev
  2. Connect frontend:

    // In Angular ChatService
    this.socket.emit('join_channel', {
    channel_id: 'prophunt_listing',
    user_id: 'user123'
    });
  3. Send test message:

    this.socket.emit('message', {
    channel_id: 'prophunt_listing',
    user_id: 'user123',
    text: 'I want to rent an apartment'
    });
  4. Check logs:

    docker logs -f api

Unit Testing

Create listing/index.spec.ts:

import { Test } from '@nestjs/testing';
import { ListingAgent } from './index';
import { ListingService } from './agent.service';
import { ListingMessageBuilder } from './agent.message-builder';

describe('ListingAgent', () => {
let agent: ListingAgent;
let service: ListingService;

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ListingAgent,
{
provide: ListingService,
useValue: {
searchProperties: jest.fn()
}
},
ListingMessageBuilder
]
}).compile();

agent = module.get<ListingAgent>(ListingAgent);
service = module.get<ListingService>(ListingService);
});

it('should activate on keyword "listing"', () => {
const message = {
channel_id: 'prophunt_listing',
user_id: 'test',
text: 'show me listings'
};

expect(agent.canHandle(message)).toBe(true);
});

it('should process initial message', async () => {
const context = { state: 'INIT', vars: {} };
const message = {
channel_id: 'prophunt_listing',
user_id: 'test',
text: 'I want to rent'
};

const responses = await agent.processMessage(message, context);
expect(responses.length).toBeGreaterThan(0);
});
});

Step 10: Frontend Integration

Add to Angular ChatComponent:

// Agent selector
agents = [
{ id: 'bgv', name: 'BGV', icon: '📋' },
{ id: 'loan', name: 'Loan', icon: '💰' },
{ id: 'listing', name: 'Listings', icon: '🏠' } // NEW!
];

selectAgent(agentId: string) {
this.chatService.switchAgent(agentId as 'bgv' | 'loan' | 'listing');
}

Checklist

Before deploying, ensure:

  • Agent config is complete
  • State machine has all states
  • Message builder has all templates
  • Service layer handles API calls
  • Agent is registered in AgentsModule
  • Unit tests pass
  • Manual testing successful
  • Frontend can select agent
  • Webserver APIs are ready

Common Issues

Agent not activating

Problem: Messages not routing to your agent

Solution: Check trigger keywords in agent.config.ts and ensure they're not too generic

State not persisting

Problem: Agent state resets

Solution: Ensure you're updating context.state and calling ConversationManager.updateContext()

Messages not sending

Problem: Responses not appearing in frontend

Solution: Check that ConversationManager.sendMessage() is called for each response

Next Steps

  • Add NLP for better intent detection
  • Implement caching for frequently accessed data
  • Add analytics to track agent usage
  • Create admin panel for agent management

Additional Resources


Congratulations! 🎉 You've created a new agent. Now users can interact with listings through your chatbot!