- Appointment Master page with status tabs, search, PhoneActionCell - Login calls DataProvider.refresh() to load data after auth - Sidebar: appointments nav for CC agents + executives - Multi-agent SIP + lockout spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout.
Architecture: Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive.
Tech Stack: NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend
Spec: docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
File Map
Sidecar (helix-engage-server/src/)
| File | Action | Responsibility |
|---|---|---|
auth/session.service.ts |
Create | Redis session lock/unlock/refresh |
auth/agent-config.service.ts |
Create | Query Agent entity, cache agent configs |
auth/auth.controller.ts |
Modify | Use agent config + session locking on login, add logout + heartbeat |
auth/auth.module.ts |
Modify | Register new services, import Redis |
config/configuration.ts |
Modify | Add REDIS_URL + SIP domain config |
Frontend (helix-engage/src/)
| File | Action | Responsibility |
|---|---|---|
pages/login.tsx |
Modify | Store agentConfig, handle 403/409 errors |
providers/sip-provider.tsx |
Modify | Read SIP config from agentConfig instead of env vars |
components/layout/app-shell.tsx |
Modify | Add heartbeat interval for CC agents |
lib/api-client.ts |
Modify | Add logout API call |
providers/auth-provider.tsx |
Modify | Call sidecar logout on sign-out |
Docker
| File | Action | Responsibility |
|---|---|---|
VPS docker-compose.yml |
Modify | Add REDIS_URL to sidecar env |
Task 1: Install ioredis + Redis Session Service
Files:
-
Modify:
helix-engage-server/package.json -
Create:
helix-engage-server/src/auth/session.service.ts -
Modify:
helix-engage-server/src/config/configuration.ts -
Step 1: Install ioredis
cd helix-engage-server && npm install ioredis
- Step 2: Add Redis URL to config
In config/configuration.ts, add to the returned object:
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
sip: {
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: process.env.SIP_WS_PORT ?? '444',
},
- Step 3: Create session service
// src/auth/session.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService implements OnModuleInit {
private readonly logger = new Logger(SessionService.name);
private redis: Redis;
constructor(private config: ConfigService) {}
onModuleInit() {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url);
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}
private key(agentId: string): string {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string): Promise<void> {
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
}
async isSessionLocked(agentId: string): Promise<string | null> {
return this.redis.get(this.key(agentId));
}
async refreshSession(agentId: string): Promise<void> {
await this.redis.expire(this.key(agentId), SESSION_TTL);
}
async unlockSession(agentId: string): Promise<void> {
await this.redis.del(this.key(agentId));
}
}
- Step 4: Verify sidecar compiles
cd helix-engage-server && npm run build
- Step 5: Commit
git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts
git commit -m "feat: Redis session service for agent login lockout"
Task 2: Agent Config Service
Files:
-
Create:
helix-engage-server/src/auth/agent-config.service.ts -
Step 1: Create agent config service
// src/auth/agent-config.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type AgentConfig = {
id: string;
ozonetelAgentId: string;
sipExtension: string;
sipPassword: string;
campaignName: string;
sipUri: string;
sipWsServer: string;
};
@Injectable()
export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>();
private readonly sipDomain: string;
private readonly sipWsPort: string;
constructor(
private platform: PlatformGraphqlService,
private config: ConfigService,
) {
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
this.sipWsPort = config.get<string>('sip.wsPort', '444');
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
// Check cache first
const cached = this.cache.get(memberId);
if (cached) return cached;
try {
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension,
sipPassword: node.sippassword ?? node.sipextension,
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};
this.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
return agentConfig;
} catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
}
}
getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null;
}
clearCache(memberId: string): void {
this.cache.delete(memberId);
}
}
- Step 2: Verify sidecar compiles
cd helix-engage-server && npm run build
- Step 3: Commit
git add src/auth/agent-config.service.ts
git commit -m "feat: agent config service with platform query + in-memory cache"
Task 3: Update Auth Module + Controller
Files:
-
Modify:
helix-engage-server/src/auth/auth.module.ts -
Modify:
helix-engage-server/src/auth/auth.controller.ts -
Step 1: Update auth module to register new services
Read src/auth/auth.module.ts and add imports:
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
import { PlatformModule } from '../platform/platform.module';
@Module({
imports: [PlatformModule],
controllers: [AuthController],
providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService],
})
- Step 2: Rewrite auth controller login for multi-agent
Inject new services into AuthController:
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
) { ... }
Modify the CC agent section of login() (currently lines 115-128). Replace the hardcoded Ozonetel login with:
if (appRole === 'cc-agent') {
const memberId = workspaceMember?.id;
if (!memberId) throw new HttpException('Workspace member not found', 400);
// Look up agent config from platform
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (!agentConfig) {
throw new HttpException('Agent account not configured. Contact administrator.', 403);
}
// Check for duplicate login
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
if (existingSession && existingSession !== memberId) {
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
}
// Lock session
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
// Login to Ozonetel with agent-specific credentials
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
this.ozonetelAgent.loginAgent({
agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
// Return agent config to frontend
return {
accessToken,
refreshToken: tokens.refreshToken.token,
user: { ... }, // same as today
agentConfig: {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
},
};
}
Note: workspaceMember.id is already available from the profile query on line 87-88 of the existing code.
- Step 3: Add logout endpoint
Add after the refresh endpoint:
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
// Resolve workspace member from JWT
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
// Unlock Redis session
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
// Logout from Ozonetel
this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
// Clear cache
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
- Step 4: Add heartbeat endpoint
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
- Step 5: Verify sidecar compiles
cd helix-engage-server && npm run build
- Step 6: Commit
git add src/auth/auth.module.ts src/auth/auth.controller.ts
git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat"
Task 4: Update Ozonetel Controller for Per-Agent Calls
Files:
-
Modify:
helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts -
Step 1: Add AgentConfigService to Ozonetel controller
Import and inject AgentConfigService. Add a helper to resolve the agent config from the auth header:
import { AgentConfigService } from '../auth/agent-config.service';
// In constructor:
private readonly agentConfig: AgentConfigService,
// Helper method:
private async resolveAgentId(authHeader: string): Promise<string> {
try {
const data = await this.platform.queryWithAuth<any>(
'{ currentUser { workspaceMember { id } } }',
undefined, authHeader,
);
const memberId = data.currentUser?.workspaceMember?.id;
const config = memberId ? this.agentConfig.getFromCache(memberId) : null;
return config?.ozonetelAgentId ?? this.defaultAgentId;
} catch {
return this.defaultAgentId;
}
}
- Step 2: Update dispose, agent-state, dial, and other endpoints
Replace this.defaultAgentId with await this.resolveAgentId(authHeader) in the endpoints that pass the auth header. The key endpoints to update:
dispose()— add@Headers('authorization') auth: stringparam, resolve agent IDagentState()— samedial()— sameagentReady()— same
For endpoints that don't currently take the auth header, add it as a parameter.
- Step 3: Update auth module to handle circular dependency
The OzonetelAgentModule now needs AgentConfigService from AuthModule. Use forwardRef if needed, or export AgentConfigService from a shared module.
Simplest approach: move AgentConfigService export from AuthModule and import it in OzonetelAgentModule.
- Step 4: Verify sidecar compiles
cd helix-engage-server && npm run build
- Step 5: Commit
git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts
git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints"
Task 5: Frontend — Store Agent Config + Dynamic SIP
Files:
-
Modify:
helix-engage/src/pages/login.tsx -
Modify:
helix-engage/src/providers/sip-provider.tsx -
Modify:
helix-engage/src/providers/auth-provider.tsx -
Step 1: Store agentConfig on login
In login.tsx, after successful login, store the agent config:
if (response.agentConfig) {
localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig));
}
Handle new error codes:
} catch (err: any) {
if (err.message?.includes('not configured')) {
setError('Agent account not configured. Contact your administrator.');
} else if (err.message?.includes('already logged in')) {
setError('You are already logged in on another device. Please log out there first.');
} else {
setError(err.message);
}
setIsLoading(false);
}
- Step 2: Update SIP provider to use stored agent config
In sip-provider.tsx, replace the hardcoded DEFAULT_CONFIG:
const getAgentSipConfig = (): SIPConfig => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
} catch {}
// Fallback to env vars
return {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
};
};
Use getAgentSipConfig() where DEFAULT_CONFIG was used.
- Step 3: Update auth provider logout to call sidecar
In auth-provider.tsx, modify logout() to call the sidecar first:
const logout = async () => {
try {
const token = localStorage.getItem('helix_access_token');
if (token) {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
} finally {
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
localStorage.removeItem('helix_user');
localStorage.removeItem('helix_agent_config');
setUser(null);
}
};
Note: API_URL needs to be available here. Import from api-client.ts or read from env.
- Step 4: Verify frontend compiles
cd helix-engage && npm run build
- Step 5: Commit
git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx
git commit -m "feat: dynamic SIP config from login response, logout cleanup"
Task 6: Frontend — Heartbeat
Files:
-
Modify:
helix-engage/src/components/layout/app-shell.tsx -
Step 1: Add heartbeat interval for CC agents
In AppShell, add a heartbeat effect:
const { isCCAgent } = useAuth();
useEffect(() => {
if (!isCCAgent) return;
const interval = setInterval(() => {
const token = localStorage.getItem('helix_access_token');
if (token) {
fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
}, 5 * 60 * 1000); // Every 5 minutes
return () => clearInterval(interval);
}, [isCCAgent]);
- Step 2: Verify frontend compiles
cd helix-engage && npm run build
- Step 3: Commit
git add src/components/layout/app-shell.tsx
git commit -m "feat: heartbeat every 5 min to keep agent session alive"
Task 7: Docker + Deploy
Files:
-
Modify: VPS
docker-compose.yml -
Step 1: Add REDIS_URL to sidecar in docker-compose
SSH to VPS and add REDIS_URL: redis://redis:6379 to the sidecar environment section. Also add redis to the sidecar's depends_on.
- Step 2: Deploy using deploy script
./deploy.sh all
- Step 3: Verify sidecar connects to Redis
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis"
Expected: Redis connected
- Step 4: Test login flow
Login as rekha.cc → should get agentConfig in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error.
- Step 5: Commit docker-compose change and push all to Azure
cd helix-engage && git add . && git push origin dev
cd helix-engage-server && git add . && git push origin dev