Files
helix-engage/docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
saridsa2 b9b7ee275f feat: appointments page, data refresh on login, multi-agent spec + plan
- 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>
2026-03-23 21:08:23 +05:30

6.0 KiB

Multi-Agent SIP Credentials + Duplicate Login Lockout

Date: 2026-03-23 Status: Approved design


Problem

Single Ozonetel agent account (global) and SIP extension (523590) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents.

Solution

Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response.


1. Data Model

Agent entity (already created on platform via admin portal):

Field (GraphQL) Type Purpose
wsmemberId Relation Links to workspace member
ozonetelagentid Text Ozonetel agent ID (e.g. "global", "agent2")
sipextension Text SIP extension number (e.g. "523590")
sippassword Text SIP auth password
campaignname Text Ozonetel campaign (e.g. "Inbound_918041763265")

Custom fields use all-lowercase GraphQL names. One Agent record per CC user.


2. Sidecar Changes

2.1 Redis Integration

Add ioredis dependency to helix-engage-server. Connect to REDIS_URL (default redis://redis:6379).

New service: src/auth/session.service.ts

lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600
isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null
refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600
unlockSession(agentId) → DEL agent:session:{agentId}

2.2 Auth Controller — Login Flow

Modify POST /auth/login:

  1. Authenticate with platform → get JWT + user profile + workspace member ID
  2. Determine role (same as today)
  3. If CC agent: a. Query platform: agents(filter: { wsmemberId: { eq: "<memberId>" } }) using server API key b. No Agent record → 403: "Agent account not configured. Contact administrator." c. Check Redis: isSessionLocked(agent.ozonetelagentid) d. Locked by different user → 409: "You are already logged in on another device. Please log out there first." e. Locked by same user → refresh TTL (re-login from same browser) f. Not locked → lockSession(agent.ozonetelagentid, memberId) g. Login to Ozonetel with agent's specific credentials h. Return agentConfig in response
  4. If manager/executive: No Agent query, no Redis, no SIP. Same as today.

Login response (CC agent):

{
  "accessToken": "...",
  "refreshToken": "...",
  "user": { "id": "...", "role": "cc-agent", ... },
  "agentConfig": {
    "ozonetelAgentId": "global",
    "sipExtension": "523590",
    "sipPassword": "523590",
    "sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com",
    "sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444",
    "campaignName": "Inbound_918041763265"
  }
}

SIP domain (blr-pub-rtc4.ozonetel.com) and WS port (444) remain from env vars — these are shared infrastructure, not per-agent.

2.3 Auth Controller — Logout

Modify POST /auth/logout (or add if doesn't exist):

  1. Resolve agent from JWT
  2. unlockSession(agent.ozonetelagentid)
  3. Ozonetel agent logout

2.4 Auth Controller — Heartbeat

New endpoint: POST /auth/heartbeat

  1. Resolve agent from JWT
  2. refreshSession(agent.ozonetelagentid) → extends TTL to 1 hour
  3. Return { status: 'ok' }

2.5 Agent Config Cache

On login, store agent config in an in-memory Map<workspaceMemberId, AgentConfig>.

All Ozonetel controller endpoints currently use this.defaultAgentId. Change to:

  1. Resolve workspace member from JWT (already done in worklist controller's resolveAgentName)
  2. Lookup agent config from the in-memory map
  3. Use the agent's ozonetelagentid for Ozonetel API calls

This avoids querying Redis/platform on every API call.

Clear the cache entry on logout.

2.6 Config

New env var: REDIS_URL (default: redis://redis:6379)

Existing env vars (OZONETEL_AGENT_ID, OZONETEL_SIP_ID, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev).


3. Frontend Changes

3.1 Store Agent Config

On login, store agentConfig from the response in localStorage (helix_agent_config).

On logout, clear it.

3.2 SIP Provider

sip-provider.tsx: Read SIP credentials from stored agentConfig instead of env vars.

const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config'));
const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI;
const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD;
const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER;

If no agentConfig and no env vars → don't connect SIP.

3.3 Heartbeat

Add a heartbeat interval in AppShell (only for CC agents):

  • Every 5 minutes: POST /auth/heartbeat
  • If heartbeat fails with 401 → session expired, redirect to login

3.4 Login Error Handling

Handle new error codes from login:

  • 403 → "Agent account not configured. Contact administrator."
  • 409 → "You are already logged in on another device. Please log out there first."

3.5 Logout

On logout, call POST /auth/logout before clearing tokens (so sidecar can clean up Redis + Ozonetel).


4. Docker Compose

Add REDIS_URL to sidecar environment in docker-compose.yml:

sidecar:
  environment:
    REDIS_URL: redis://redis:6379

5. Edge Cases

  • Sidecar restart: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily).
  • Redis restart: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry.
  • Browser crash (no logout): Heartbeat stops → Redis key expires in ≤1 hour → lock clears.
  • Same user, same browser re-login: Detected by comparing memberId in Redis → refreshes TTL instead of blocking.
  • Agent record deleted while logged in: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.